feat(infra): project pendingConfirmation into feeds:list

saveMetadata now also upserts the list entry so the pendingConfirmation
flag is reflected in the dashboard without an extra per-feed KV read.
toListItemDTO gains an optional third parameter for the flag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-25 09:02:16 +02:00
parent 79bb4902b9
commit 36d58ade48
4 changed files with 87 additions and 6 deletions
+1
View File
@@ -43,6 +43,7 @@ describe("feed-mapper", () => {
description: "desc",
mailbox_id: "a.b.42",
expires_at: 3000,
pendingConfirmation: false,
});
});
});
+6 -1
View File
@@ -43,12 +43,17 @@ export function toConfigDTO(state: FeedState): FeedConfig {
}
/** Domain state → the projection cached in the global `feeds:list` registry. */
export function toListItemDTO(id: FeedId, state: FeedState): FeedListItem {
export function toListItemDTO(
id: FeedId,
state: FeedState,
pendingConfirmation = false,
): FeedListItem {
return {
id: id.value,
title: state.title,
description: state.description,
mailbox_id: state.mailboxId,
expires_at: state.expiresAt,
...(pendingConfirmation !== undefined ? { pendingConfirmation } : {}),
};
}
@@ -214,3 +214,68 @@ describe("FeedRepository feed list", () => {
);
});
});
describe("FeedRepository pendingConfirmation projection", () => {
function makeFeed(): Feed {
return Feed.create(
FeedId.generate(),
{
title: "T",
description: "",
language: "en",
allowedSenders: [],
blockedSenders: [],
},
{ mailboxId: MailboxId.unchecked("alpha.beta.11") },
);
}
it("saveMetadata projects pendingConfirmation into feeds:list", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
const feed = makeFeed();
await repo.save(feed);
feed.ingest(
{
key: "k1",
subject: "s",
receivedAt: Date.now(),
size: 10,
confirmation: { links: ["https://x/confirm"] },
},
{ maxBytes: 1_000_000 },
);
await repo.saveMetadata(feed);
const list = await repo.listFeeds();
const entry = list.find((f) => f.id === feed.id.value);
expect(entry?.pendingConfirmation).toBe(true);
});
it("saveMetadata clears the projected flag after dismiss", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
const feed = makeFeed();
feed.ingest(
{
key: "k1",
subject: "s",
receivedAt: Date.now(),
size: 10,
confirmation: { links: ["https://x/confirm"] },
},
{ maxBytes: 1_000_000 },
);
await repo.save(feed);
expect(
(await repo.listFeeds()).find((f) => f.id === feed.id.value)
?.pendingConfirmation,
).toBe(true);
feed.dismissConfirmation();
await repo.saveMetadata(feed);
expect(
(await repo.listFeeds()).find((f) => f.id === feed.id.value)
?.pendingConfirmation,
).toBe(false);
});
});
+15 -5
View File
@@ -87,18 +87,26 @@ export class FeedRepository {
await Promise.all([
this.putConfig(feed.id, toConfigDTO(feed.state())),
this.putMetadata(feed.id, feed.toMetadataSnapshot()),
this.upsertListEntry(toListItemDTO(feed.id, feed.state())),
this.upsertListEntry(
toListItemDTO(feed.id, feed.state(), feed.pendingConfirmation),
),
this.putInboundIndex(feed.mailboxId, feed.id),
]);
}
/**
* Persist only the email index. Used by the ingest/delete paths where config
* is unchanged — avoids a redundant config write on the hot path. The list
* projection (title/description/expiry) is untouched, so it is not rewritten.
* is unchanged — avoids a redundant config write on the hot path. Also
* refreshes the `feeds:list` entry's `pendingConfirmation` projection so the
* dashboard reflects the latest flag state with a single subsequent KV read.
*/
async saveMetadata(feed: Feed): Promise<void> {
await this.putMetadata(feed.id, feed.toMetadataSnapshot());
await Promise.all([
this.putMetadata(feed.id, feed.toMetadataSnapshot()),
this.upsertListEntry(
toListItemDTO(feed.id, feed.state(), feed.pendingConfirmation),
),
]);
}
/**
@@ -109,7 +117,9 @@ export class FeedRepository {
async saveConfig(feed: Feed): Promise<void> {
await Promise.all([
this.putConfig(feed.id, toConfigDTO(feed.state())),
this.upsertListEntry(toListItemDTO(feed.id, feed.state())),
this.upsertListEntry(
toListItemDTO(feed.id, feed.state(), feed.pendingConfirmation),
),
this.putInboundIndex(feed.mailboxId, feed.id),
]);
}