cursedbelt-server 4.27.0 → 4.29.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 (69) 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/metrics/metricsBuffer.d.ts +0 -2
  14. package/dist/server/metrics/metricsBuffer.js +5 -2
  15. package/dist/server/metrics/requestPlace.d.ts +8 -0
  16. package/dist/server/metrics/requestPlace.js +8 -0
  17. package/dist/server/notifications/d1.d.ts +80 -0
  18. package/dist/server/notifications/d1.js +226 -0
  19. package/dist/server/notifications/service.d.ts +2 -20
  20. package/dist/server/notifications/types.d.ts +27 -0
  21. package/dist/server/notifications/types.js +1 -0
  22. package/dist/server/requestLog/requestMetrics.js +1 -1
  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/dist/server/telemetry.d.ts +1 -1
  40. package/dist/server/telemetry.js +1 -1
  41. package/docs/activity.md +7 -0
  42. package/docs/notifications.md +9 -0
  43. package/package.json +19 -1
  44. package/src/server/activity/d1.spec.ts +169 -0
  45. package/src/server/activity/d1.ts +172 -0
  46. package/src/server/activity/migrations.ts +6 -40
  47. package/src/server/activity/query.ts +283 -0
  48. package/src/server/activity/schema.ts +45 -0
  49. package/src/server/activity/shareSource.ts +2 -2
  50. package/src/server/activity/store.ts +45 -257
  51. package/src/server/metrics/metricsBuffer.ts +5 -2
  52. package/src/server/metrics/requestPlace.ts +9 -0
  53. package/src/server/notifications/d1.spec.ts +179 -0
  54. package/src/server/notifications/d1.ts +334 -0
  55. package/src/server/notifications/service.ts +7 -23
  56. package/src/server/notifications/types.ts +29 -0
  57. package/src/server/requestLog/requestMetrics.ts +1 -1
  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/telemetry.ts +1 -0
  69. package/src/server/telemetryIsWorkerSafe.spec.ts +54 -0
@@ -34,80 +34,36 @@
34
34
  * cannot be filtered, and filtering is the whole point of the column.
35
35
  */
36
36
  import type { Database, SQLQueryBindings } from "bun:sqlite";
37
- import {
38
- type ActivityEvent,
39
- type ActivityEventInput,
40
- type ActivityOutcome,
41
- isActivityAction,
42
- isActivityOutcome,
43
- } from "cursedbelt-core/activity/model";
37
+ import type { ActivityEvent, ActivityEventInput } from "cursedbelt-core/activity/model";
44
38
  import { runMigrations } from "../migration/runner.js";
45
- import { ACTIVITY_EVENTS_TABLE, ACTIVITY_MIGRATIONS } from "./migrations.js";
46
-
47
- /**
48
- * A read-only stream contributor: a SELECT projecting some other table into the
49
- * union's eleven columns, in this order and with these names —
50
- *
51
- * `source, seq, at, app, actor, action, kind, resource_id, outcome, note, data`
52
- *
53
- * `seq` is that table's own row id: it breaks ties within one `at`, and it is
54
- * what makes the event id (`"<source>:<seq>"`) stable and unique across sources.
55
- */
56
- export interface ActivitySource {
57
- /** Short, stable, unique within the log — it prefixes every id it produces. */
58
- name: string;
59
- /** The projection. See the column contract above. */
60
- sql: string;
61
- /** Bound in order, before any filter parameter. */
62
- params?: readonly SQLQueryBindings[];
63
- /**
64
- * A table this projection needs. When it does not exist the source is skipped
65
- * for that call rather than throwing — see property 3 in the header.
66
- */
67
- requiresTable?: string;
68
- }
69
-
70
- /** Everything the stream can be narrowed by. Every field is optional; the empty
71
- * query is "the newest `limit` events". */
72
- export interface ActivityQuery {
73
- /** ISO-8601, inclusive. */
74
- since?: string;
75
- /** ISO-8601, inclusive. */
76
- until?: string;
77
- /** Case-insensitive exact match on the account. */
78
- actor?: string;
79
- /** Exact, or a PREFIX when it ends in a dot: `"share."` matches `share.grant`. */
80
- action?: string;
81
- kind?: string;
82
- resourceId?: string;
83
- outcome?: ActivityOutcome;
84
- /** Restrict to one contributor (`"log"`, `"share"`). */
85
- source?: string;
86
- /** Substring, over actor + action + note + resource id. */
87
- q?: string;
88
- /** Default 100, hard cap 1000. */
89
- limit?: number;
90
- }
91
-
92
- export interface ActivityPage {
93
- events: ActivityEvent[];
94
- /** The limit actually applied, after defaulting and capping. */
95
- limit: number;
96
- /** There is at least one more row older than the last one returned. */
97
- truncated: boolean;
98
- }
99
-
100
- /** One dimension of "what is in this result", for the filter UI. */
101
- export interface ActivityFacet {
102
- value: string;
103
- count: number;
104
- }
105
-
106
- export interface ActivityFacets {
107
- actions: ActivityFacet[];
108
- actors: ActivityFacet[];
109
- kinds: ActivityFacet[];
110
- }
39
+ import { ACTIVITY_MIGRATIONS } from "./migrations.js";
40
+ import {
41
+ ACTIVITY_SQL,
42
+ type ActivityFacet,
43
+ type ActivityFacets,
44
+ type ActivityPage,
45
+ type ActivityQuery,
46
+ type ActivitySource,
47
+ buildStream,
48
+ facetLimit,
49
+ MAX_FACET,
50
+ pageLimit,
51
+ prepareRecord,
52
+ type StreamRow,
53
+ toStreamEvent,
54
+ } from "./query.js";
55
+
56
+ // The contract and the SQL have no driver in them, so `./d1.ts` (which runs on `workerd`)
57
+ // shares them without reaching this file. Re-exported so `cursedbelt-server/activity` is
58
+ // unchanged.
59
+ export type {
60
+ ActivityBindable,
61
+ ActivityFacet,
62
+ ActivityFacets,
63
+ ActivityPage,
64
+ ActivityQuery,
65
+ ActivitySource,
66
+ } from "./query.js";
111
67
 
112
68
  export interface ActivityLogOptions {
113
69
  db: Database;
@@ -136,59 +92,6 @@ export interface ActivityLog {
136
92
  sources(): string[];
137
93
  }
138
94
 
139
- interface StreamRow {
140
- source: string;
141
- seq: number;
142
- at: string;
143
- app: string;
144
- actor: string;
145
- action: string;
146
- kind: string | null;
147
- resource_id: string | null;
148
- outcome: ActivityOutcome;
149
- note: string;
150
- data: string | null;
151
- }
152
-
153
- const DEFAULT_LIMIT = 100;
154
- const MAX_LIMIT = 1000;
155
- const MAX_FACET = 25;
156
-
157
- /** `%` and `_` are wildcards and `\` is the escape — a note containing any of
158
- * them must still search literally. */
159
- const likeContains = (value: string): string =>
160
- `%${value.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
161
-
162
- const toEvent = (row: StreamRow): ActivityEvent => ({
163
- id: `${row.source}:${row.seq}`,
164
- source: row.source,
165
- at: row.at,
166
- app: row.app,
167
- actor: row.actor,
168
- action: row.action,
169
- resource:
170
- row.kind === null || row.resource_id === null
171
- ? null
172
- : { app: row.app, kind: row.kind, id: row.resource_id },
173
- outcome: isActivityOutcome(row.outcome) ? row.outcome : "ok",
174
- note: row.note ?? "",
175
- data: parseData(row.data),
176
- });
177
-
178
- /** A row whose `data` is unreadable must not take the page down with it — the
179
- * event still says who did what, which is the part that matters. */
180
- function parseData(raw: string | null): Record<string, unknown> | null {
181
- if (!raw) return null;
182
- try {
183
- const parsed = JSON.parse(raw) as unknown;
184
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
185
- ? (parsed as Record<string, unknown>)
186
- : { value: parsed };
187
- } catch {
188
- return { unparsed: raw };
189
- }
190
- }
191
-
192
95
  export function createActivityLog(options: ActivityLogOptions): ActivityLog {
193
96
  const { db, app } = options;
194
97
  const now = options.now ?? (() => new Date().toISOString());
@@ -196,146 +99,37 @@ export function createActivityLog(options: ActivityLogOptions): ActivityLog {
196
99
 
197
100
  runMigrations(db, ACTIVITY_MIGRATIONS);
198
101
 
199
- const insert = db.query(
200
- `INSERT INTO ${ACTIVITY_EVENTS_TABLE}
201
- (at, app, actor, action, kind, resource_id, outcome, note, data)
202
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
203
- );
102
+ const insert = db.query(ACTIVITY_SQL.insert);
204
103
 
205
- const tableExists = (name: string): boolean =>
206
- !!db
207
- .query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?")
208
- .get(name);
104
+ const tableExists = (name: string): boolean => !!db.query(ACTIVITY_SQL.tableExists).get(name);
209
105
 
210
106
  /** The contributors that can actually answer right now. */
211
107
  const liveSources = (): ActivitySource[] =>
212
108
  registered.filter((source) => !source.requiresTable || tableExists(source.requiresTable));
213
109
 
214
- const OWN_SELECT = `SELECT 'log' AS source, event_id AS seq, at, app, actor, action,
215
- kind, resource_id, outcome, note, data
216
- FROM ${ACTIVITY_EVENTS_TABLE}`;
217
-
218
- /** The union, plus the WHERE the caller asked for, plus their parameters in
219
- * bind order (sources first — they are inside the FROM). */
220
- const stream = (query: ActivityQuery): { sql: string; params: SQLQueryBindings[] } => {
221
- const params: SQLQueryBindings[] = [];
222
- const selects = [OWN_SELECT];
223
- for (const source of liveSources()) {
224
- if (query.source && query.source !== source.name) continue;
225
- selects.push(source.sql);
226
- params.push(...(source.params ?? []));
227
- }
228
- const where: string[] = [];
229
- if (query.source) {
230
- where.push("source = ?");
231
- params.push(query.source);
232
- }
233
- if (query.since) {
234
- where.push("at >= ?");
235
- params.push(query.since);
236
- }
237
- if (query.until) {
238
- where.push("at <= ?");
239
- params.push(query.until);
240
- }
241
- if (query.actor) {
242
- where.push("LOWER(actor) = LOWER(?)");
243
- params.push(query.actor);
244
- }
245
- if (query.action) {
246
- if (query.action.endsWith(".")) {
247
- where.push("action LIKE ? ESCAPE '\\'");
248
- params.push(`${query.action.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`);
249
- } else {
250
- where.push("action = ?");
251
- params.push(query.action);
252
- }
253
- }
254
- if (query.kind) {
255
- where.push("kind = ?");
256
- params.push(query.kind);
257
- }
258
- if (query.resourceId) {
259
- where.push("resource_id = ?");
260
- params.push(query.resourceId);
261
- }
262
- if (query.outcome) {
263
- where.push("outcome = ?");
264
- params.push(query.outcome);
265
- }
266
- if (query.q) {
267
- where.push(
268
- "(actor LIKE ? ESCAPE '\\' OR action LIKE ? ESCAPE '\\' OR note LIKE ? ESCAPE '\\' OR IFNULL(resource_id, '') LIKE ? ESCAPE '\\')",
269
- );
270
- const like = likeContains(query.q);
271
- params.push(like, like, like, like);
272
- }
273
- // `query.source` naming a contributor that is not live leaves only the own
274
- // select, filtered to a source it cannot be — an empty result, which is the
275
- // honest answer rather than every row.
276
- const sql = `SELECT * FROM (${selects.join("\n UNION ALL\n")}) AS stream${
277
- where.length ? ` WHERE ${where.join(" AND ")}` : ""
278
- }`;
279
- return { sql, params };
280
- };
110
+ const stream = (query: ActivityQuery): { sql: string; params: SQLQueryBindings[] } =>
111
+ buildStream(query, liveSources());
281
112
 
282
113
  return {
283
114
  app,
284
115
 
285
116
  record(input: ActivityEventInput): ActivityEvent {
286
- if (!isActivityAction(input.action)) {
287
- throw new Error(
288
- `activity: "${input.action}" is not an action — use lowercase subject.verb, e.g. "backup.delete"`,
289
- );
290
- }
291
- const actor = input.actor?.trim();
292
- if (!actor) {
293
- // An event that cannot say who is a log line, not an audit trail.
294
- throw new Error(`activity: ${input.action} has no actor`);
295
- }
296
- const at = input.at ?? now();
297
- const outcome = input.outcome ?? "ok";
298
- const data = input.data ? JSON.stringify(input.data) : null;
299
- const info = insert.run(
300
- at,
301
- app,
302
- actor,
303
- input.action,
304
- input.resource?.kind ?? null,
305
- input.resource?.id ?? null,
306
- outcome,
307
- input.note ?? "",
308
- data,
309
- );
310
- return {
311
- id: `log:${Number(info.lastInsertRowid)}`,
312
- source: "log",
313
- at,
314
- app,
315
- actor,
316
- action: input.action,
317
- resource: input.resource ? { app, ...input.resource } : null,
318
- outcome,
319
- note: input.note ?? "",
320
- data: input.data ?? null,
321
- };
117
+ const { binds, event } = prepareRecord(app, input, now);
118
+ const info = insert.run(...binds);
119
+ return { id: `log:${Number(info.lastInsertRowid)}`, ...event };
322
120
  },
323
121
 
324
122
  query(query: ActivityQuery = {}): ActivityPage {
325
- const limit = Math.min(MAX_LIMIT, Math.max(1, Math.floor(query.limit ?? DEFAULT_LIMIT)));
123
+ const limit = pageLimit(query.limit);
326
124
  const { sql, params } = stream(query);
327
- // One row past the limit, so "there is more" is a fact rather than a
328
- // guess made from `rows.length === limit`.
329
- const rows = db
330
- .query(`${sql} ORDER BY at DESC, seq DESC LIMIT ?`)
331
- .all(...params, limit + 1) as StreamRow[];
125
+ const rows = db.query(ACTIVITY_SQL.page(sql)).all(...params, limit + 1) as StreamRow[];
332
126
  const truncated = rows.length > limit;
333
- return { events: rows.slice(0, limit).map(toEvent), limit, truncated };
127
+ return { events: rows.slice(0, limit).map(toStreamEvent), limit, truncated };
334
128
  },
335
129
 
336
130
  count(query: ActivityQuery = {}): number {
337
131
  const { sql, params } = stream(query);
338
- const row = db.query(`SELECT COUNT(*) AS n FROM (${sql})`).get(...params) as {
132
+ const row = db.query(ACTIVITY_SQL.count(sql)).get(...params) as {
339
133
  n: number;
340
134
  } | null;
341
135
  return row?.n ?? 0;
@@ -343,17 +137,11 @@ export function createActivityLog(options: ActivityLogOptions): ActivityLog {
343
137
 
344
138
  facets(query: ActivityQuery = {}, limit = MAX_FACET): ActivityFacets {
345
139
  const { sql, params } = stream(query);
346
- const capped = Math.min(MAX_FACET, Math.max(1, Math.floor(limit)));
347
- const dimension = (column: string): ActivityFacet[] =>
348
- (
349
- db
350
- .query(
351
- `SELECT ${column} AS value, COUNT(*) AS count FROM (${sql})
352
- WHERE ${column} IS NOT NULL AND ${column} <> ''
353
- GROUP BY ${column} ORDER BY count DESC, value ASC LIMIT ?`,
354
- )
355
- .all(...params, capped) as { value: string; count: number }[]
356
- ).map((row) => ({ value: row.value, count: row.count }));
140
+ const capped = facetLimit(limit);
141
+ const dimension = (column: "action" | "actor" | "kind"): ActivityFacet[] =>
142
+ (db.query(ACTIVITY_SQL.facet(sql, column)).all(...params, capped) as { value: string; count: number }[]).map(
143
+ (row) => ({ value: row.value, count: row.count }),
144
+ );
357
145
  return {
358
146
  actions: dimension("action"),
359
147
  actors: dimension("actor"),
@@ -1,4 +1,5 @@
1
1
  import type { Database } from 'bun:sqlite';
2
+ import { PLACE_COLUMNS } from './requestPlace.js';
2
3
 
3
4
  /**
4
5
  * In-memory, batched writer for `request_metrics`. To keep request latency
@@ -30,8 +31,10 @@ export interface MetricRow {
30
31
  city?: string | null;
31
32
  }
32
33
 
33
- /** The optional place columns, in insert order. Each is written only when the table HAS it. */
34
- export const PLACE_COLUMNS = ['country', 'region', 'city'] as const;
34
+ // Every place column is a `MetricRow` field — a column declared in `requestPlace.ts` that this
35
+ // row cannot carry fails to compile here rather than inserting `undefined` for ever.
36
+ const _placeColumnsAreRowFields: readonly (keyof MetricRow)[] = PLACE_COLUMNS;
37
+ void _placeColumnsAreRowFields;
35
38
 
36
39
  export interface MetricsBufferOptions {
37
40
  db: Database;
@@ -33,6 +33,15 @@ export interface RequestPlace {
33
33
  /** Place one request, or `null` when it cannot. Must not throw — but a throw is caught and read as `null`. */
34
34
  export type RequestLocator = (request: Request) => RequestPlace | null;
35
35
 
36
+ /**
37
+ * The place columns, in insert order — the ONE declaration every reader and migration uses
38
+ * (`metricsBuffer` writes them, an app's reader allows them, an app's migration adds them).
39
+ * Exported through `cursedbelt-server/telemetry` since 4.29.0 (task 2141): until then it was
40
+ * private to `metricsBuffer.ts`, so flix, station and desk each declared their own copy, and a
41
+ * fourth column added here would have been written by the buffer and dropped by all three.
42
+ */
43
+ export const PLACE_COLUMNS = ['country', 'region', 'city'] as const;
44
+
36
45
  /** Each place field is capped so three of them can never crowd the 5120-byte Analytics Engine blob budget. */
37
46
  export const PLACE_FIELD_MAX_CHARS = 96;
38
47
 
@@ -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
+ });