cursedbelt-server 4.26.1 → 4.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/server/activity/d1.d.ts +70 -0
  2. package/dist/server/activity/d1.js +68 -0
  3. package/dist/server/activity/migrations.d.ts +1 -3
  4. package/dist/server/activity/migrations.js +7 -32
  5. package/dist/server/activity/query.d.ts +123 -0
  6. package/dist/server/activity/query.js +171 -0
  7. package/dist/server/activity/schema.d.ts +13 -0
  8. package/dist/server/activity/schema.js +43 -0
  9. package/dist/server/activity/shareSource.d.ts +1 -1
  10. package/dist/server/activity/shareSource.js +1 -1
  11. package/dist/server/activity/store.d.ts +4 -62
  12. package/dist/server/activity/store.js +14 -145
  13. package/dist/server/engagement/d1.d.ts +67 -0
  14. package/dist/server/engagement/d1.js +81 -0
  15. package/dist/server/engagement/manifestPlaces.d.ts +9 -0
  16. package/dist/server/engagement/manifestPlaces.js +17 -0
  17. package/dist/server/engagement/places.js +2 -18
  18. package/dist/server/notifications/d1.d.ts +80 -0
  19. package/dist/server/notifications/d1.js +226 -0
  20. package/dist/server/notifications/service.d.ts +2 -20
  21. package/dist/server/notifications/types.d.ts +27 -0
  22. package/dist/server/notifications/types.js +1 -0
  23. package/dist/server/sharing/contract.d.ts +94 -0
  24. package/dist/server/sharing/contract.js +3 -0
  25. package/dist/server/sharing/d1.d.ts +77 -0
  26. package/dist/server/sharing/d1.js +309 -0
  27. package/dist/server/sharing/index.d.ts +1 -0
  28. package/dist/server/sharing/migrations.d.ts +1 -4
  29. package/dist/server/sharing/migrations.js +7 -72
  30. package/dist/server/sharing/router.d.ts +8 -4
  31. package/dist/server/sharing/router.js +7 -5
  32. package/dist/server/sharing/schema.d.ts +19 -0
  33. package/dist/server/sharing/schema.js +88 -0
  34. package/dist/server/sharing/sql.js +1 -1
  35. package/dist/server/sharing/statements.d.ts +116 -0
  36. package/dist/server/sharing/statements.js +139 -0
  37. package/dist/server/sharing/store.d.ts +3 -28
  38. package/dist/server/sharing/store.js +32 -105
  39. package/docs/activity.md +7 -0
  40. package/docs/engagement.md +6 -0
  41. package/docs/notifications.md +9 -0
  42. package/package.json +25 -1
  43. package/src/server/activity/d1.spec.ts +169 -0
  44. package/src/server/activity/d1.ts +172 -0
  45. package/src/server/activity/migrations.ts +6 -40
  46. package/src/server/activity/query.ts +283 -0
  47. package/src/server/activity/schema.ts +45 -0
  48. package/src/server/activity/shareSource.ts +2 -2
  49. package/src/server/activity/store.ts +45 -257
  50. package/src/server/engagement/d1.spec.ts +102 -0
  51. package/src/server/engagement/d1.ts +141 -0
  52. package/src/server/engagement/manifestPlaces.ts +24 -0
  53. package/src/server/engagement/places.ts +2 -16
  54. package/src/server/notifications/d1.spec.ts +179 -0
  55. package/src/server/notifications/d1.ts +334 -0
  56. package/src/server/notifications/service.ts +7 -23
  57. package/src/server/notifications/types.ts +29 -0
  58. package/src/server/sharing/contract.ts +109 -0
  59. package/src/server/sharing/d1.spec.ts +242 -0
  60. package/src/server/sharing/d1.ts +430 -0
  61. package/src/server/sharing/index.ts +3 -0
  62. package/src/server/sharing/migrations.ts +12 -85
  63. package/src/server/sharing/router.ts +21 -11
  64. package/src/server/sharing/schema.ts +90 -0
  65. package/src/server/sharing/sql.ts +1 -1
  66. package/src/server/sharing/statements.ts +213 -0
  67. package/src/server/sharing/store.ts +46 -221
  68. package/src/server/telemetryIsWorkerSafe.spec.ts +64 -0
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The Worker's engagement recorder is the kit's, over D1 — the SAME tables and the SAME rows for
3
+ * the same views, because station reads both with one query set (tasks 2081, 2096).
4
+ */
5
+ import { Database } from 'bun:sqlite';
6
+ import { afterEach, describe, expect, test } from 'bun:test';
7
+ import { mkdtempSync, rmSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { Hono } from 'hono';
11
+ import { createLocalD1 } from '../d1/local.js';
12
+ import { createD1EngagementRecorder, ENGAGEMENT_DDL, placesFromManifest, recordViewD1 } from './d1.js';
13
+ import { engagementPlacesFor } from './places.js';
14
+ import { EngagementStore } from './store.js';
15
+
16
+ const dirs: string[] = [];
17
+ afterEach(() => {
18
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true });
19
+ });
20
+ const tempDir = (): string => {
21
+ const dir = mkdtempSync(join(tmpdir(), 'engagement-d1-'));
22
+ dirs.push(dir);
23
+ return dir;
24
+ };
25
+ const kitStore = (): EngagementStore => new EngagementStore({ dbPath: join(tempDir(), 'engagement.sqlite') });
26
+ const d1 = () => {
27
+ const sqlite = new Database(':memory:');
28
+ for (const ddl of ENGAGEMENT_DDL) sqlite.run(ddl);
29
+ return { sqlite, db: createLocalD1(sqlite) };
30
+ };
31
+ const shape = (db: Database) =>
32
+ (
33
+ db
34
+ .query("SELECT type, name, sql FROM sqlite_master WHERE name LIKE '%engagement%' AND sql IS NOT NULL ORDER BY name")
35
+ .all() as { type: string; name: string; sql: string }[]
36
+ ).map((r) => ({ type: r.type, name: r.name, sql: r.sql.replace(/\s+/g, ' ').replace('IF NOT EXISTS ', '').trim() }));
37
+ const dump = (db: Database) => ({
38
+ days: db.query('SELECT * FROM engagement_days ORDER BY user_key, place, day').all(),
39
+ places: db.query('SELECT * FROM engagement_places ORDER BY user_key, place').all(),
40
+ users: db.query('SELECT * FROM engagement_users ORDER BY user_key').all(),
41
+ });
42
+
43
+ describe('cursedbelt-server/engagement/d1', () => {
44
+ test("🔴 creates exactly the kit store's tables and indexes — station reads both with one query set", () => {
45
+ const kit = kitStore();
46
+ const { sqlite } = d1();
47
+ expect(shape(sqlite)).toEqual(shape(kit.handle));
48
+ });
49
+
50
+ test("🔴 the same views produce the same rows as the kit's store — sessions, dwell, days", async () => {
51
+ const kit = kitStore();
52
+ const { sqlite, db } = d1();
53
+ const t0 = Date.UTC(2026, 8, 23, 10, 0, 0);
54
+ const views = [
55
+ { userKey: 'u1', place: 'library', ts: t0 },
56
+ { userKey: 'u1', place: 'downloads', ts: t0 + 40_000 },
57
+ { userKey: 'u2', place: 'library', ts: t0 + 50_000 },
58
+ { userKey: 'u1', place: 'library', ts: t0 + 90_000 },
59
+ // A new session (a long gap) on the next day.
60
+ { userKey: 'u1', place: 'library', ts: t0 + 26 * 3_600_000 },
61
+ ];
62
+ for (const v of views) expect(await recordViewD1(db, v)).toEqual(kit.recordView(v));
63
+ expect(dump(sqlite)).toEqual(dump(kit.handle));
64
+ });
65
+
66
+ test('records only a signed-in account, and buckets an off-manifest place as `other`', async () => {
67
+ const { sqlite, db } = d1();
68
+ const places = placesFromManifest({ routes: [{ id: 'library', path: '/library' }, { id: 'library' }, { path: '/x' }] });
69
+ expect(places).toEqual([{ id: 'library', path: '/library' }]);
70
+ let user: string | null = null;
71
+ const app = new Hono().route('/api/engagement', createD1EngagementRecorder({ db, places, resolveUser: () => user }));
72
+ const post = (place: unknown) =>
73
+ app.request('http://x/api/engagement/view', {
74
+ method: 'POST',
75
+ headers: { 'content-type': 'application/json' },
76
+ body: JSON.stringify({ place }),
77
+ });
78
+ expect((await post('library')).status).toBe(401);
79
+ user = 'u1';
80
+ expect(await (await post('library')).json()).toMatchObject({ ok: true, place: 'library', bucketed: false });
81
+ // An id, an album name, a search string — never a row of its own.
82
+ expect(await (await post('album:Some Private Name')).json()).toMatchObject({ place: 'other', bucketed: true });
83
+ expect((await post(42)).status).toBe(400);
84
+ expect(sqlite.query('SELECT place FROM engagement_places ORDER BY place').all()).toEqual([
85
+ { place: 'library' },
86
+ { place: 'other' },
87
+ ]);
88
+ });
89
+
90
+ test('🔴 ONE manifest rule: a Worker handed the JSON and a Bun server reading the file agree', async () => {
91
+ const doc = { routes: [{ id: 'queue', path: '/#/queue' }, { id: 'home', path: '/' }, { id: 'queue' }, { id: '' }, null] };
92
+ const dir = tempDir();
93
+ await Bun.write(join(dir, 'routes.manifest.json'), JSON.stringify(doc));
94
+ expect(placesFromManifest(doc)).toEqual([
95
+ { id: 'home', path: '/' },
96
+ { id: 'queue', path: '/#/queue' },
97
+ ]);
98
+ expect(engagementPlacesFor(dir)).toEqual(placesFromManifest(doc));
99
+ expect(placesFromManifest('not a manifest')).toEqual([]);
100
+ expect(placesFromManifest(null)).toEqual([]);
101
+ });
102
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * `cursedbelt-server/engagement/d1` — the engagement recorder over D1, for an app a Worker serves.
3
+ *
4
+ * ── Why it exists ───────────────────────────────────────────────────────────
5
+ * `./engagement` is Bun-only: its store is a synchronous `bun:sqlite` file (`engagement.sqlite`)
6
+ * that station's Metrics page reads off disk. A Worker has neither, so an app that moved to one
7
+ * stopped being heard with no error anywhere — `collections` from its 2026-09-22 cutover until
8
+ * this subpath (task 2096). The SAME three tables live in the app's own D1 instead (its
9
+ * `db/schema.sql`, from {@link ENGAGEMENT_DDL}), written with the SAME fold (`./policy.ts` →
10
+ * `foldView`, `dayKey`, `resolvePlace`), and station reads them from D1 for any Worker-served app.
11
+ *
12
+ * It was `apps/music/src/server/engagementD1.ts` plus a copied `kit/engagement/policy.ts` held
13
+ * byte-identical by a parity test (task 2081). collections was the second consumer, so it was
14
+ * lifted here rather than copied again — and the policy copy went with it: this module imports
15
+ * the library's own `policy.ts`, so there is one privacy rule, not one per Worker.
16
+ *
17
+ * 🔴 Worker-safe: nothing reachable from here at runtime imports `bun:*`
18
+ * (`telemetryIsWorkerSafe.spec.ts` walks it). Do not import `./store.js` or `./api.js` here.
19
+ *
20
+ * 🔴 The kit's privacy story holds unchanged: the place is resolved against the app's own route
21
+ * manifest AT THE DOOR, so an id, an album name or a search string never reaches a row; the
22
+ * account is the session's subject, never anything in the body. Mount it INSIDE the session gate.
23
+ *
24
+ * 🔴 {@link ENGAGEMENT_DDL} must stay byte-identical in effect to `EngagementStore`'s `migrate()` —
25
+ * station reads both shapes with one query set. `d1.spec.ts` builds both and compares their
26
+ * `sqlite_master` rows, so a column added to one and not the other is a red.
27
+ *
28
+ * ```ts
29
+ * import { createD1EngagementRecorder, placesFromManifest } from "cursedbelt-server/engagement/d1";
30
+ * import routesManifest from "../routes.manifest.json";
31
+ *
32
+ * gated.route("/api/engagement", createD1EngagementRecorder({
33
+ * db, places: placesFromManifest(routesManifest), resolveUser: (c) => sso.userOf(c)?.id ?? null,
34
+ * }));
35
+ * ```
36
+ */
37
+ import type { D1LikeDatabase } from "../d1/types.js";
38
+ import type { Context } from "hono";
39
+ import { Hono } from "hono";
40
+ import { dayKey, foldView, OTHER_PLACE, type PlaceRef, resolvePlace } from "./policy.js";
41
+
42
+ /** The kit store's own tables and indexes, as one-line DDL — see the header. */
43
+ export const ENGAGEMENT_DDL: readonly string[] = [
44
+ "CREATE TABLE IF NOT EXISTS engagement_days ( user_key TEXT NOT NULL, place TEXT NOT NULL, day TEXT NOT NULL, views INTEGER NOT NULL DEFAULT 0, active_ms INTEGER NOT NULL DEFAULT 0, sessions INTEGER NOT NULL DEFAULT 0, first_ts INTEGER NOT NULL, last_ts INTEGER NOT NULL, PRIMARY KEY (user_key, place, day) )",
45
+ "CREATE INDEX IF NOT EXISTS idx_engagement_days_day ON engagement_days (day)",
46
+ "CREATE TABLE IF NOT EXISTS engagement_places ( user_key TEXT NOT NULL, place TEXT NOT NULL, views INTEGER NOT NULL DEFAULT 0, sessions INTEGER NOT NULL DEFAULT 0, active_ms INTEGER NOT NULL DEFAULT 0, first_ts INTEGER NOT NULL, last_ts INTEGER NOT NULL, PRIMARY KEY (user_key, place) )",
47
+ "CREATE INDEX IF NOT EXISTS idx_engagement_places_last ON engagement_places (last_ts)",
48
+ "CREATE TABLE IF NOT EXISTS engagement_users ( user_key TEXT PRIMARY KEY, views INTEGER NOT NULL DEFAULT 0, sessions INTEGER NOT NULL DEFAULT 0, active_ms INTEGER NOT NULL DEFAULT 0, first_ts INTEGER NOT NULL, last_ts INTEGER NOT NULL, last_place TEXT NOT NULL DEFAULT '' )",
49
+ ];
50
+
51
+ /**
52
+ * Record one view — the kit's `EngagementStore.recordView`, asynchronously: one read (the
53
+ * account's previous view) and ONE batch of up to five statements, so a view is atomic and
54
+ * costs two D1 round trips however much it credits.
55
+ */
56
+ export async function recordViewD1(
57
+ db: D1LikeDatabase,
58
+ input: { userKey: string; place: string; ts: number },
59
+ ): Promise<{ newSession: boolean; activeMs: number }> {
60
+ const { userKey, place, ts } = input;
61
+ const prior = await db
62
+ .prepare("SELECT last_ts, last_place FROM engagement_users WHERE user_key = ?")
63
+ .bind(userKey)
64
+ .first<{ last_ts: number; last_place: string }>();
65
+ const fold = foldView(prior ? prior.last_ts : null, ts);
66
+ const sessionDelta = fold.newSession ? 1 : 0;
67
+ const statements = [];
68
+ if (fold.activeMs > 0 && prior?.last_place) {
69
+ // The time since the last view is credited to the place the account was LOOKING AT.
70
+ statements.push(
71
+ db
72
+ .prepare("UPDATE engagement_days SET active_ms = active_ms + ? WHERE user_key = ? AND place = ? AND day = ?")
73
+ .bind(fold.activeMs, userKey, prior.last_place, dayKey(prior.last_ts)),
74
+ db
75
+ .prepare("UPDATE engagement_places SET active_ms = active_ms + ? WHERE user_key = ? AND place = ?")
76
+ .bind(fold.activeMs, userKey, prior.last_place),
77
+ );
78
+ }
79
+ statements.push(
80
+ db
81
+ .prepare(
82
+ `INSERT INTO engagement_days (user_key, place, day, views, active_ms, sessions, first_ts, last_ts)
83
+ VALUES (?, ?, ?, 1, 0, ?, ?, ?)
84
+ ON CONFLICT(user_key, place, day) DO UPDATE SET
85
+ views = views + 1, sessions = sessions + excluded.sessions, last_ts = excluded.last_ts`,
86
+ )
87
+ .bind(userKey, place, dayKey(ts), sessionDelta, ts, ts),
88
+ db
89
+ .prepare(
90
+ `INSERT INTO engagement_places (user_key, place, views, sessions, active_ms, first_ts, last_ts)
91
+ VALUES (?, ?, 1, ?, 0, ?, ?)
92
+ ON CONFLICT(user_key, place) DO UPDATE SET
93
+ views = views + 1, sessions = sessions + excluded.sessions, last_ts = excluded.last_ts`,
94
+ )
95
+ .bind(userKey, place, sessionDelta, ts, ts),
96
+ db
97
+ .prepare(
98
+ `INSERT INTO engagement_users (user_key, views, sessions, active_ms, first_ts, last_ts, last_place)
99
+ VALUES (?, 1, ?, 0, ?, ?, ?)
100
+ ON CONFLICT(user_key) DO UPDATE SET
101
+ views = views + 1, sessions = sessions + excluded.sessions, active_ms = active_ms + ?,
102
+ last_ts = excluded.last_ts, last_place = excluded.last_place`,
103
+ )
104
+ .bind(userKey, sessionDelta, ts, ts, place, fold.activeMs),
105
+ );
106
+ await db.batch(statements);
107
+ return fold;
108
+ }
109
+
110
+ /**
111
+ * The recorder as the kit's `createEngagementRecorder` mounts it — `POST /view` under
112
+ * `ENGAGEMENT_BASE_PATH` — over D1. Recording only: there is no read surface here, as there is
113
+ * none on the Mac; station reads the tables directly.
114
+ */
115
+ export function createD1EngagementRecorder(options: {
116
+ db: D1LikeDatabase;
117
+ places: readonly PlaceRef[];
118
+ resolveUser: (c: Context) => string | null;
119
+ now?: () => number;
120
+ }): Hono {
121
+ const app = new Hono();
122
+ const now = options.now ?? (() => Date.now());
123
+ app.post("/view", async (c) => {
124
+ const userKey = options.resolveUser(c);
125
+ if (!userKey) return c.json({ error: "unauthenticated" }, 401);
126
+ let body: unknown;
127
+ try {
128
+ body = await c.req.json();
129
+ } catch {
130
+ return c.json({ error: "expected a json body" }, 400);
131
+ }
132
+ const claimed = (body as { place?: unknown } | null)?.place;
133
+ if (typeof claimed !== "string") return c.json({ error: "place must be a string" }, 400);
134
+ const place = resolvePlace(claimed, options.places);
135
+ const fold = await recordViewD1(options.db, { userKey, place, ts: now() });
136
+ return c.json({ ok: true, place, bucketed: place === OTHER_PLACE, ...fold });
137
+ });
138
+ return app;
139
+ }
140
+
141
+ export { placesFromManifest } from "./manifestPlaces.js";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A parsed `routes.manifest.json` → the place allowlist. Pure, and Worker-safe: `places.ts`
3
+ * reads the file off disk for a Bun server, and a Worker hands over the JSON it bundles
4
+ * (`./d1.ts`). ONE rule for both — until task 2096 music's Worker carried its own copy of it.
5
+ *
6
+ * Anything it cannot read is `[]`, never a throw: see `places.ts`, "Missing is EMPTY".
7
+ */
8
+ import type { PlaceRef } from "./policy.js";
9
+
10
+ export function placesFromManifest(doc: unknown): PlaceRef[] {
11
+ const routes = Array.isArray(doc) ? doc : ((doc as { routes?: unknown } | null)?.routes as unknown[] | undefined);
12
+ if (!Array.isArray(routes)) return [];
13
+ const seen = new Set<string>();
14
+ const places: PlaceRef[] = [];
15
+ for (const raw of routes) {
16
+ const r = raw as { id?: unknown; path?: unknown } | null;
17
+ if (!r || typeof r.id !== "string" || !r.id || seen.has(r.id)) continue;
18
+ seen.add(r.id);
19
+ places.push({ id: r.id, path: typeof r.path === "string" ? r.path : "" });
20
+ }
21
+ // Sorted so the allowlist a board renders is stable between deployments.
22
+ places.sort((a, b) => a.id.localeCompare(b.id));
23
+ return places;
24
+ }
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { existsSync, readFileSync } from "node:fs";
26
26
  import { join } from "node:path";
27
+ import { placesFromManifest } from "./manifestPlaces.js";
27
28
 
28
29
  /** Where the manifest sits, in a source tree and in a built artifact alike. */
29
30
  const MANIFEST = "routes.manifest.json";
@@ -54,22 +55,7 @@ export function engagementPlacesFor(dir: string): EngagementPlace[] {
54
55
  const path = join(dir, MANIFEST);
55
56
  if (!existsSync(path)) return [];
56
57
  try {
57
- const doc = JSON.parse(readFileSync(path, "utf8")) as unknown;
58
- const routes = Array.isArray(doc)
59
- ? doc
60
- : ((doc as { routes?: unknown }).routes as unknown[] | undefined);
61
- if (!Array.isArray(routes)) return [];
62
- const seen = new Set<string>();
63
- const places: EngagementPlace[] = [];
64
- for (const raw of routes) {
65
- const r = raw as { id?: unknown; path?: unknown };
66
- if (typeof r.id !== "string" || !r.id || seen.has(r.id)) continue;
67
- seen.add(r.id);
68
- places.push({ id: r.id, path: typeof r.path === "string" ? r.path : "" });
69
- }
70
- // Sorted so the allowlist a board renders is stable between deployments.
71
- places.sort((a, b) => a.id.localeCompare(b.id));
72
- return places;
58
+ return placesFromManifest(JSON.parse(readFileSync(path, "utf8")) as unknown);
73
59
  } catch {
74
60
  return [];
75
61
  }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The D1 notification service is the sync one over D1 — the same table, the same rows for the
3
+ * same notifications, the same routes minus the live stream (task 393, `apps/family`).
4
+ *
5
+ * Parity is proven at the STORE seam, which is the only thing that changed: cwip's own center
6
+ * drives cwip's sqlite store and the D1 store through one scenario, with one clock and one id
7
+ * sequence, and every result and every row is compared. It runs over the local driver and over
8
+ * the fake D1 binding, which refuses `undefined` binds exactly as D1 does.
9
+ */
10
+ import { Database } from "bun:sqlite";
11
+ import { describe, expect, test } from "bun:test";
12
+ import {
13
+ createNotificationCenter,
14
+ createSqliteNotificationStore,
15
+ type NotificationCenter,
16
+ type NotificationDb,
17
+ } from "cwip/notifications";
18
+ import { Hono } from "hono";
19
+ import { createFakeD1Binding } from "../d1/fakeD1.js";
20
+ import { createLocalD1 } from "../d1/local.js";
21
+ import { createRemoteD1 } from "../d1/remote.js";
22
+ import type { D1LikeDatabase } from "../d1/types.js";
23
+ import {
24
+ createD1NotificationService,
25
+ createD1NotificationStore,
26
+ NOTIFICATIONS_DDL,
27
+ notificationsDdl,
28
+ } from "./d1.js";
29
+ import { NOTIFICATION_MIGRATIONS } from "./migrations.js";
30
+
31
+ const DRIVERS: Record<string, (sqlite: Database) => D1LikeDatabase> = {
32
+ local: (sqlite) => createLocalD1(sqlite),
33
+ "remote (fake D1 binding)": (sqlite) => createRemoteD1(createFakeD1Binding(sqlite)),
34
+ };
35
+ const local = DRIVERS.local as (sqlite: Database) => D1LikeDatabase;
36
+
37
+ const withDdl = (ddl: readonly string[] = NOTIFICATIONS_DDL): Database => {
38
+ const sqlite = new Database(":memory:");
39
+ for (const statement of ddl) sqlite.run(statement);
40
+ return sqlite;
41
+ };
42
+
43
+ /** A clock that ticks 1 ms per call and a sequential id — identical for both centers. */
44
+ const seams = () => {
45
+ let t = 1_000;
46
+ let n = 0;
47
+ return { now: () => t++, newId: () => `n${++n}`, onError: (error: unknown) => { throw error; } };
48
+ };
49
+
50
+ const SCENARIO: ReadonlyArray<(c: NotificationCenter) => Promise<unknown>> = [
51
+ (c) => c.notify("u1", { type: "share.grant", title: "Shared with you", body: "Grandma", link: "/people/p1" }),
52
+ (c) => c.notify("u1", { type: "share.grant", title: "Two things", groupKey: "g1", severity: "warning" }),
53
+ (c) => c.notify("u1", { type: "share.grant", title: "Three things", groupKey: "g1" }),
54
+ (c) => c.notify("u2", { type: "auth.login", title: "New sign-in", source: "auth", actorId: "a1", actorName: "Ann", resourceType: "session", resourceId: "s1" }),
55
+ (c) => c.notify("u1", { type: "plain", title: "A later row" }),
56
+ (c) => c.list({ userId: "u1" }),
57
+ (c) => c.list({ userId: "u1", limit: 1 }),
58
+ (c) => c.list({ userId: "u1", limit: 1, offset: 1 }),
59
+ (c) => c.unreadCount("u1"),
60
+ (c) => c.get("u1", "n1"),
61
+ (c) => c.get("u2", "n1"),
62
+ (c) => c.markRead("u1", "n1", true),
63
+ (c) => c.markRead("u1", "n1", true),
64
+ (c) => c.list({ userId: "u1", filter: "unread" }),
65
+ (c) => c.markAllRead("u1"),
66
+ (c) => c.markRead("u1", "n1", false),
67
+ (c) => c.setDone("u1", "n2", true),
68
+ (c) => c.list({ userId: "u1", filter: "done" }),
69
+ (c) => c.notify("u1", { type: "share.grant", title: "g1 again, after done", groupKey: "g1" }),
70
+ (c) => c.doneAll("u2"),
71
+ (c) => c.remove("u1", "n4"),
72
+ (c) => c.remove("u1", "nothing"),
73
+ (c) => c.pruneDone(0),
74
+ (c) => c.list({ userId: "u2", filter: "all" }),
75
+ (c) => c.unreadCount("nobody"),
76
+ ];
77
+
78
+ describe("cursedbelt-server/notifications/d1", () => {
79
+ for (const [name, driver] of Object.entries(DRIVERS)) {
80
+ test(`🔴 parity over the ${name} driver: every result and every row match cwip's sqlite store`, async () => {
81
+ const syncDb = new Database(":memory:");
82
+ const sync = createNotificationCenter({
83
+ store: createSqliteNotificationStore(syncDb as unknown as NotificationDb),
84
+ ...seams(),
85
+ });
86
+ const d1Sqlite = withDdl();
87
+ const d1 = createNotificationCenter({ store: createD1NotificationStore(driver(d1Sqlite)), ...seams() });
88
+ const results: unknown[] = [];
89
+ for (const [index, step] of SCENARIO.entries()) {
90
+ const expected = await step(sync);
91
+ const actual = await step(d1);
92
+ expect({ step: index, result: actual }).toEqual({ step: index, result: expected });
93
+ results.push(actual);
94
+ }
95
+ const table = (db: Database) => db.query("SELECT * FROM notifications ORDER BY rowid").all();
96
+ expect(table(d1Sqlite)).toEqual(table(syncDb));
97
+ // Not vacuous: a group coalesced to a count of 2, the prune removed a done row, and
98
+ // rows survived it.
99
+ expect(JSON.stringify(results)).toContain('"count":2');
100
+ expect(results[SCENARIO.length - 3]).toBeGreaterThan(0);
101
+ expect(table(d1Sqlite).length).toBeGreaterThan(1);
102
+ });
103
+ }
104
+
105
+ test("the DDL builds exactly the table cwip's store and NOTIFICATION_MIGRATIONS build", () => {
106
+ const shape = (db: Database) =>
107
+ (
108
+ db
109
+ .query("SELECT type, name, sql FROM sqlite_master WHERE tbl_name = 'notifications' AND sql IS NOT NULL ORDER BY name")
110
+ .all() as { type: string; name: string; sql: string }[]
111
+ ).map((r) => ({ ...r, sql: r.sql.replace(/\s+/g, " ").replace("IF NOT EXISTS ", "").trim() }));
112
+ const viaStore = new Database(":memory:");
113
+ createSqliteNotificationStore(viaStore as unknown as NotificationDb);
114
+ const viaMigration = new Database(":memory:");
115
+ for (const migration of NOTIFICATION_MIGRATIONS) migration.up(viaMigration);
116
+ expect(shape(withDdl())).toEqual(shape(viaStore));
117
+ expect(shape(withDdl())).toEqual(shape(viaMigration));
118
+ expect(shape(viaStore).length).toBe(3);
119
+ // A renamed table carries its name into both indexes.
120
+ expect(notificationsDdl("inbox").join("\n")).toContain("idx_inbox_group ON inbox(");
121
+ });
122
+
123
+ test("🔴 a table name is an identifier or it is refused — it is interpolated into SQL", () => {
124
+ expect(() => notificationsDdl("notifications; DROP TABLE x")).toThrow(/invalid table identifier/);
125
+ expect(() => createD1NotificationStore(local(withDdl()), { table: "a b" })).toThrow(/invalid table identifier/);
126
+ });
127
+
128
+ test("🔴 coalescing is enforced by the index on D1 too — a second open row in a group is refused", async () => {
129
+ const store = createD1NotificationStore(local(withDdl()));
130
+ const row = (id: string) => ({
131
+ id,
132
+ userId: "u1",
133
+ createdAt: 0,
134
+ updatedAt: 0,
135
+ type: "t",
136
+ severity: "info" as const,
137
+ title: "x",
138
+ groupKey: "g",
139
+ count: 1,
140
+ read: false,
141
+ done: false,
142
+ });
143
+ await store.insert(row("a"));
144
+ await expect(store.insert(row("b"))).rejects.toThrow(/UNIQUE constraint/);
145
+ expect(await store.unreadCount("u1")).toBe(1);
146
+ });
147
+
148
+ test("🔴 runs no DDL: on a database without the table it creates nothing and fails loudly", async () => {
149
+ const sqlite = new Database(":memory:");
150
+ const service = createD1NotificationService({ db: local(sqlite) });
151
+ expect(sqlite.query("SELECT COUNT(*) AS n FROM sqlite_master").get()).toEqual({ n: 0 });
152
+ await expect(service.store.unreadCount("u1")).rejects.toThrow(/no such table/);
153
+ });
154
+
155
+ test("the service mounts the inbox routes, user-scoped, with no hub and no stream route", async () => {
156
+ let who: string | null = "u1";
157
+ const service = createD1NotificationService({ db: local(withDdl()), onError: () => {} });
158
+ expect(service.hub).toBeNull();
159
+ const app = new Hono();
160
+ service.mount(app, { authorize: () => who });
161
+ await service.center.notify("u1", { type: "share.grant", title: "Shared with you", link: "/people/p1" });
162
+ await service.center.notify("u2", { type: "share.grant", title: "Not yours" });
163
+
164
+ const list = (await (await app.request("/api/notifications")).json()) as { items: { id: string; title: string }[] };
165
+ expect(list.items.map((n) => n.title)).toEqual(["Shared with you"]);
166
+ expect(await (await app.request("/api/notifications/unread-count")).json()).toEqual({ count: 1 });
167
+ const id = list.items[0]?.id as string;
168
+ expect(await (await app.request(`/api/notifications/${id}/read`, { method: "POST" })).json()).toEqual({ changed: true });
169
+ expect(await (await app.request("/api/notifications/unread-count")).json()).toEqual({ count: 0 });
170
+ // u2's row cannot be reached by u1's session, even by id.
171
+ const u2 = (await service.center.list({ userId: "u2" })).items[0]?.id as string;
172
+ expect((await app.request(`/api/notifications/${u2}`)).status).toBe(404);
173
+ // No hub, so no stream: the client's poll transport falls back from the 404.
174
+ expect((await app.request("/api/notifications/stream")).status).not.toBe(200);
175
+ who = null;
176
+ expect((await app.request("/api/notifications")).status).toBe(401);
177
+ service.close();
178
+ });
179
+ });