feat(notifications): node-local notif.written hook

NotificationService emits notif.written after each non-silent write and
after an in-place rewrite, on the node that wrote the row, so an
extension can act on new notifications without a second bus round trip.
It stays off the published event stream; notif.created carries the
notification itself.
This commit is contained in:
Juan Castro committed 2026-10-26 16:34:18 -04:00
1 parent 47a6122c37
commit fceaee6372
4 files changed
+112 -1

No files matched your search

+11
View File
@@ -552,6 +552,17 @@ export type EventMap = {
/** Events handler runs behind whatever created it. */
handlerDepth?: number;
};
/** Every non-silent write or update, only on the node that wrote it. */
'notif.written': {
userId: number;
uid: string;
type: string;
/** The row's app. Absent on an update, which keeps the row's own. */
appUid?: string | null;
value: Record<string, unknown>;
/** An earlier notification rewritten in place. */
updated?: true;
};
/**
* One notification, addressed at whatever sockets each region holds for the
* recipient. `outer.*` reaches peer regions and never sibling nodes, so
+4
View File
@@ -444,6 +444,10 @@ export const UNPUBLISHED_INTERNAL_EVENTS = [
event: 'kv.share.retired',
reason: 'A share handle stopping is not a change to any key; it ends the subscriptions made through the handle rather than being delivered to them.',
},
{
event: 'notif.written',
reason: 'A node-local hook after a notification is written, for extensions; the notification itself publishes through `notif.created`.',
},
] as const satisfies readonly UnpublishedInternalEvent[];
// Every `fs.*`/`kv.*`/`notif.*` bus key needs a decision. A new key that is in
@@ -338,6 +338,79 @@ describe('NotificationService.notifyUpdate', () => {
});
});
describe("NotificationService — 'notif.written'", () => {
it('fires once per recipient whose row was written', async () => {
const a = await makeUser();
const b = await makeUser();
const appUid = `app-${uuidv4()}`;
const written = collect('notif.written');
await notifications.notify(
[a.id, b.id],
{ title: 'ended' },
{ type: 'app.events.ended', appUid },
);
await waitFor(() => written.seen.length === 2);
const rows = await Promise.all(
[a, b].map((u) =>
server.stores.notification.listByUserId(u.id, {}),
),
);
const expected = [a, b].map((u, i) => ({
userId: u.id,
uid: rows[i][0].uid,
type: 'app.events.ended',
appUid,
value: { title: 'ended', type: 'app.events.ended' },
}));
expect(written.seen).toEqual(expect.arrayContaining(expected));
written.stop();
});
it('stays quiet for a silent write', async () => {
const user = await makeUser();
const written = collect('notif.written');
await notifications.notify(
[user.id],
{ title: 'quiet' },
{ type: 'share.received', silent: true },
);
await new Promise((r) => setTimeout(r, 50));
expect(written.seen).toEqual([]);
written.stop();
});
it('marks a rewrite in place as updated', async () => {
const user = await makeUser();
const uid = await notifications.notify(
[user.id],
{ title: 'one share' },
{ type: 'share.received' },
);
const written = collect('notif.written');
await notifications.notifyUpdate(
uid!,
user.id,
{ title: 'two shares' },
{ type: 'share.received' },
);
await waitFor(() => written.seen.length === 1);
expect(written.seen[0]).toEqual({
userId: user.id,
uid,
type: 'share.received',
value: { title: 'two shares', type: 'share.received' },
updated: true,
});
written.stop();
});
});
describe('NotificationService.markAcknowledged', () => {
it("acknowledges the row and pushes an ack to the user's other tabs", async () => {
const user = await makeUser();
@@ -169,12 +169,24 @@ export class NotificationService extends PuterService {
return null;
}
if (!opts.silent)
if (!opts.silent) {
this.clients.event.emit(
'notif.written',
{
userId,
uid,
type: registered.type,
appUid,
value: payload,
},
{},
);
await this.#push(userId, uid, payload, {
type: registered.type,
audience: registered.audience,
appUid,
});
}
return uid;
}),
);
@@ -217,6 +229,17 @@ export class NotificationService extends PuterService {
// to anything deduplicating on `event.id`.
this.#socket().message(userId, uid, payload);
this.#markDelivered(uid, userId);
this.clients.event.emit(
'notif.written',
{
userId,
uid,
type: registered.type,
value: payload,
updated: true,
},
{},
);
}
return true;
}