cursedbelt-server 4.27.0 → 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 (58) 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/notifications/d1.d.ts +80 -0
  14. package/dist/server/notifications/d1.js +226 -0
  15. package/dist/server/notifications/service.d.ts +2 -20
  16. package/dist/server/notifications/types.d.ts +27 -0
  17. package/dist/server/notifications/types.js +1 -0
  18. package/dist/server/sharing/contract.d.ts +94 -0
  19. package/dist/server/sharing/contract.js +3 -0
  20. package/dist/server/sharing/d1.d.ts +77 -0
  21. package/dist/server/sharing/d1.js +309 -0
  22. package/dist/server/sharing/index.d.ts +1 -0
  23. package/dist/server/sharing/migrations.d.ts +1 -4
  24. package/dist/server/sharing/migrations.js +7 -72
  25. package/dist/server/sharing/router.d.ts +8 -4
  26. package/dist/server/sharing/router.js +7 -5
  27. package/dist/server/sharing/schema.d.ts +19 -0
  28. package/dist/server/sharing/schema.js +88 -0
  29. package/dist/server/sharing/sql.js +1 -1
  30. package/dist/server/sharing/statements.d.ts +116 -0
  31. package/dist/server/sharing/statements.js +139 -0
  32. package/dist/server/sharing/store.d.ts +3 -28
  33. package/dist/server/sharing/store.js +32 -105
  34. package/docs/activity.md +7 -0
  35. package/docs/notifications.md +9 -0
  36. package/package.json +19 -1
  37. package/src/server/activity/d1.spec.ts +169 -0
  38. package/src/server/activity/d1.ts +172 -0
  39. package/src/server/activity/migrations.ts +6 -40
  40. package/src/server/activity/query.ts +283 -0
  41. package/src/server/activity/schema.ts +45 -0
  42. package/src/server/activity/shareSource.ts +2 -2
  43. package/src/server/activity/store.ts +45 -257
  44. package/src/server/notifications/d1.spec.ts +179 -0
  45. package/src/server/notifications/d1.ts +334 -0
  46. package/src/server/notifications/service.ts +7 -23
  47. package/src/server/notifications/types.ts +29 -0
  48. package/src/server/sharing/contract.ts +109 -0
  49. package/src/server/sharing/d1.spec.ts +242 -0
  50. package/src/server/sharing/d1.ts +430 -0
  51. package/src/server/sharing/index.ts +3 -0
  52. package/src/server/sharing/migrations.ts +12 -85
  53. package/src/server/sharing/router.ts +21 -11
  54. package/src/server/sharing/schema.ts +90 -0
  55. package/src/server/sharing/sql.ts +1 -1
  56. package/src/server/sharing/statements.ts +213 -0
  57. package/src/server/sharing/store.ts +46 -221
  58. package/src/server/telemetryIsWorkerSafe.spec.ts +46 -0
@@ -1,31 +1,11 @@
1
1
  import { levelsMeeting, normalizeSubjectId, strongerAccess, } from "cursedbelt-core/sharing/model";
2
2
  import { runMigrations } from "../migration/runner.js";
3
- import { SHARE_EVENTS_TABLE, SHARE_GRANTS_TABLE, SHARE_GROUPS_TABLE, SHARE_GROUP_MEMBERS_TABLE, SHARE_MIGRATIONS, } from "./migrations.js";
3
+ import { SHARE_MIGRATIONS } from "./migrations.js";
4
4
  import { sharedResourceSql } from "./sql.js";
5
- /** Shorthand for the common subject. `group(id)` is its sibling. */
6
- export const user = (id) => ({ type: "user", id });
7
- export const group = (id) => ({ type: "group", id });
8
- const toGrant = (row) => ({
9
- resource: { app: row.app, kind: row.kind, id: row.resource_id },
10
- subject: { type: row.subject_type, id: row.subject_id },
11
- level: row.level,
12
- grantedBy: row.granted_by,
13
- grantedAt: row.granted_at,
14
- note: row.note,
15
- });
16
- const toEvent = (row) => ({
17
- id: row.event_id,
18
- at: row.at,
19
- action: row.action,
20
- resource: row.app === null || row.kind === null || row.resource_id === null
21
- ? null
22
- : { app: row.app, kind: row.kind, id: row.resource_id },
23
- subject: { type: row.subject_type, id: row.subject_id },
24
- level: row.level,
25
- previousLevel: row.previous_level,
26
- actor: row.actor,
27
- note: row.note,
28
- });
5
+ import { eventLimit, eventsWhere, SHARE_SQL, toEvent, toGrant, } from "./statements.js";
6
+ // The contract has no driver in it, so `./d1.ts` (which runs on `workerd`) shares it without
7
+ // reaching this file. Re-exported so `cursedbelt-server/sharing` is unchanged.
8
+ export { group, user } from "./contract.js";
29
9
  /**
30
10
  * Open (and migrate) the sharing tables in this database, returning the store.
31
11
  *
@@ -54,9 +34,7 @@ export function createShareStore(options) {
54
34
  }
55
35
  };
56
36
  const recordEvent = (input) => {
57
- db.query(`INSERT INTO ${SHARE_EVENTS_TABLE}
58
- (at, action, app, kind, resource_id, subject_type, subject_id, level, previous_level, actor, note)
59
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.at, input.action, input.resource?.app ?? null, input.resource?.kind ?? null, input.resource?.id ?? null, input.subject.type, input.subject.id, input.level, input.previousLevel ?? null, input.actor, input.note ?? "");
37
+ db.query(SHARE_SQL.insertEvent).run(input.at, input.action, input.resource?.app ?? null, input.resource?.kind ?? null, input.resource?.id ?? null, input.subject.type, input.subject.id, input.level, input.previousLevel ?? null, input.actor, input.note ?? "");
60
38
  const id = db.query("SELECT last_insert_rowid() AS id").get().id;
61
39
  return {
62
40
  id,
@@ -76,8 +54,7 @@ export function createShareStore(options) {
76
54
  });
77
55
  const readGrant = (target, subject) => {
78
56
  const row = db
79
- .query(`SELECT * FROM ${SHARE_GRANTS_TABLE}
80
- WHERE app = ? AND kind = ? AND resource_id = ? AND subject_type = ? AND subject_id = ?`)
57
+ .query(SHARE_SQL.selectGrant)
81
58
  .get(app, target.kind, target.id, subject.type, subject.id);
82
59
  return row ? toGrant(row) : null;
83
60
  };
@@ -102,12 +79,7 @@ export function createShareStore(options) {
102
79
  const events = [];
103
80
  const grant = db.transaction(() => {
104
81
  const existing = readGrant(input, subject);
105
- db.query(`INSERT INTO ${SHARE_GRANTS_TABLE}
106
- (app, kind, resource_id, subject_type, subject_id, level, granted_by, granted_at, note)
107
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
108
- ON CONFLICT (app, kind, resource_id, subject_type, subject_id)
109
- DO UPDATE SET level = excluded.level, granted_by = excluded.granted_by,
110
- granted_at = excluded.granted_at, note = excluded.note`).run(app, input.kind, input.id, subject.type, subject.id, input.level, input.grantedBy, at, input.note ?? "");
82
+ db.query(SHARE_SQL.upsertGrant).run(app, input.kind, input.id, subject.type, subject.id, input.level, input.grantedBy, at, input.note ?? "");
111
83
  const resource = { app, kind: input.kind, id: input.id };
112
84
  events.push(recordEvent({
113
85
  action: existing && existing.level !== input.level ? "regrade" : "grant",
@@ -142,8 +114,7 @@ export function createShareStore(options) {
142
114
  const existing = readGrant(input, subject);
143
115
  if (!existing)
144
116
  return null;
145
- db.query(`DELETE FROM ${SHARE_GRANTS_TABLE}
146
- WHERE app = ? AND kind = ? AND resource_id = ? AND subject_type = ? AND subject_id = ?`).run(app, input.kind, input.id, subject.type, subject.id);
117
+ db.query(SHARE_SQL.deleteGrant).run(app, input.kind, input.id, subject.type, subject.id);
147
118
  events.push(recordEvent({
148
119
  action: "revoke",
149
120
  resource: { app, kind: input.kind, id: input.id },
@@ -172,11 +143,11 @@ export function createShareStore(options) {
172
143
  const events = [];
173
144
  const count = db.transaction(() => {
174
145
  const rows = db
175
- .query(`SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE app = ? AND kind = ? AND resource_id = ?`)
146
+ .query(SHARE_SQL.selectResourceGrants)
176
147
  .all(app, target.kind, target.id);
177
148
  if (rows.length === 0)
178
149
  return 0;
179
- db.query(`DELETE FROM ${SHARE_GRANTS_TABLE} WHERE app = ? AND kind = ? AND resource_id = ?`).run(app, target.kind, target.id);
150
+ db.query(SHARE_SQL.deleteResourceGrants).run(app, target.kind, target.id);
180
151
  for (const row of rows) {
181
152
  events.push(recordEvent({
182
153
  action: "revoke",
@@ -201,11 +172,11 @@ export function createShareStore(options) {
201
172
  const events = [];
202
173
  const count = db.transaction(() => {
203
174
  const rows = db
204
- .query(`SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE app = ? AND subject_type = ? AND subject_id = ?`)
175
+ .query(SHARE_SQL.selectSubjectGrants)
205
176
  .all(app, normal.type, normal.id);
206
177
  if (rows.length === 0)
207
178
  return 0;
208
- db.query(`DELETE FROM ${SHARE_GRANTS_TABLE} WHERE app = ? AND subject_type = ? AND subject_id = ?`).run(app, normal.type, normal.id);
179
+ db.query(SHARE_SQL.deleteSubjectGrants).run(app, normal.type, normal.id);
209
180
  for (const row of rows) {
210
181
  events.push(recordEvent({
211
182
  action: "revoke",
@@ -224,25 +195,14 @@ export function createShareStore(options) {
224
195
  },
225
196
  /** Who can see this record, strongest first then oldest — the panel's read. */
226
197
  listForResource(target) {
227
- const rows = db
228
- .query(`SELECT * FROM ${SHARE_GRANTS_TABLE}
229
- WHERE app = ? AND kind = ? AND resource_id = ?
230
- -- Strongest first, then oldest. Spelled as a CASE rather than an
231
- -- ORDER BY on level, which happens to work only because 'write'
232
- -- sorts after 'read' and would silently mis-order a third level.
233
- ORDER BY CASE level WHEN 'write' THEN 0 ELSE 1 END, granted_at ASC`)
234
- .all(app, target.kind, target.id);
198
+ const rows = db.query(SHARE_SQL.listForResource).all(app, target.kind, target.id);
235
199
  return rows.map(toGrant);
236
200
  },
237
201
  /** Everything shared with one subject — "shared with you". Group grants are
238
202
  * NOT expanded here; use {@link resourceIdsFor} for the resolved answer. */
239
203
  listForSubject(subject) {
240
204
  const normal = normalized(subject);
241
- const rows = db
242
- .query(`SELECT * FROM ${SHARE_GRANTS_TABLE}
243
- WHERE app = ? AND subject_type = ? AND subject_id = ?
244
- ORDER BY granted_at DESC`)
245
- .all(app, normal.type, normal.id);
205
+ const rows = db.query(SHARE_SQL.listForSubject).all(app, normal.type, normal.id);
246
206
  return rows.map(toGrant);
247
207
  },
248
208
  /**
@@ -257,11 +217,7 @@ export function createShareStore(options) {
257
217
  if (!subjectId)
258
218
  return "none";
259
219
  const rows = db
260
- .query(`SELECT level FROM ${SHARE_GRANTS_TABLE}
261
- WHERE app = ? AND kind = ? AND resource_id = ?
262
- AND ( (subject_type = 'user' AND subject_id = ?)
263
- OR (subject_type = 'group' AND subject_id IN
264
- (SELECT group_id FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE subject_id = ?)) )`)
220
+ .query(SHARE_SQL.accessFor)
265
221
  .all(app, target.kind, target.id, subjectId, subjectId);
266
222
  let access = "none";
267
223
  for (const row of rows)
@@ -276,12 +232,7 @@ export function createShareStore(options) {
276
232
  return [];
277
233
  const levels = levelsMeeting(atLeast);
278
234
  const rows = db
279
- .query(`SELECT DISTINCT resource_id FROM ${SHARE_GRANTS_TABLE}
280
- WHERE app = ? AND kind = ? AND level IN (${levels.map(() => "?").join(", ")})
281
- AND ( (subject_type = 'user' AND subject_id = ?)
282
- OR (subject_type = 'group' AND subject_id IN
283
- (SELECT group_id FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE subject_id = ?)) )
284
- ORDER BY resource_id`)
235
+ .query(SHARE_SQL.resourceIdsFor(levels.length))
285
236
  .all(app, kind, ...levels, subjectId, subjectId);
286
237
  return rows.map((row) => row.resource_id);
287
238
  },
@@ -298,9 +249,7 @@ export function createShareStore(options) {
298
249
  const at = clock();
299
250
  const events = [];
300
251
  const created = db.transaction(() => {
301
- db.query(`INSERT INTO ${SHARE_GROUPS_TABLE} (group_id, name, created_by, created_at, note)
302
- VALUES (?, ?, ?, ?, ?)
303
- ON CONFLICT (group_id) DO UPDATE SET name = excluded.name, note = excluded.note`).run(id, input.name, input.createdBy, at, input.note ?? "");
252
+ db.query(SHARE_SQL.upsertGroup).run(id, input.name, input.createdBy, at, input.note ?? "");
304
253
  events.push(recordEvent({
305
254
  action: "group.create",
306
255
  resource: null,
@@ -326,7 +275,7 @@ export function createShareStore(options) {
326
275
  const at = clock();
327
276
  const events = [];
328
277
  db.transaction(() => {
329
- db.query(`UPDATE ${SHARE_GROUPS_TABLE} SET name = ? WHERE group_id = ?`).run(name, groupId);
278
+ db.query(SHARE_SQL.renameGroup).run(name, groupId);
330
279
  events.push(recordEvent({
331
280
  action: "group.rename",
332
281
  resource: null,
@@ -353,10 +302,8 @@ export function createShareStore(options) {
353
302
  const at = clock();
354
303
  const events = [];
355
304
  db.transaction(() => {
356
- const grants = db
357
- .query(`SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE app = ? AND subject_type = 'group' AND subject_id = ?`)
358
- .all(app, groupId);
359
- db.query(`DELETE FROM ${SHARE_GRANTS_TABLE} WHERE app = ? AND subject_type = 'group' AND subject_id = ?`).run(app, groupId);
305
+ const grants = db.query(SHARE_SQL.selectGroupGrants).all(app, groupId);
306
+ db.query(SHARE_SQL.deleteGroupGrants).run(app, groupId);
360
307
  for (const row of grants) {
361
308
  events.push(recordEvent({
362
309
  action: "revoke",
@@ -368,7 +315,7 @@ export function createShareStore(options) {
368
315
  at,
369
316
  }));
370
317
  }
371
- db.query(`DELETE FROM ${SHARE_GROUPS_TABLE} WHERE group_id = ?`).run(groupId);
318
+ db.query(SHARE_SQL.deleteGroup).run(groupId);
372
319
  events.push(recordEvent({
373
320
  action: "group.delete",
374
321
  resource: null,
@@ -382,9 +329,7 @@ export function createShareStore(options) {
382
329
  emit(events);
383
330
  },
384
331
  listGroups() {
385
- const rows = db
386
- .query(`SELECT * FROM ${SHARE_GROUPS_TABLE} ORDER BY name`)
387
- .all();
332
+ const rows = db.query(SHARE_SQL.listGroups).all();
388
333
  return rows.map((row) => ({
389
334
  id: row.group_id,
390
335
  name: row.name,
@@ -401,8 +346,7 @@ export function createShareStore(options) {
401
346
  const at = clock();
402
347
  const events = [];
403
348
  db.transaction(() => {
404
- db.query(`INSERT INTO ${SHARE_GROUP_MEMBERS_TABLE} (group_id, subject_id, added_by, added_at)
405
- VALUES (?, ?, ?, ?) ON CONFLICT (group_id, subject_id) DO NOTHING`).run(id, member, actor, at);
349
+ db.query(SHARE_SQL.insertMember).run(id, member, actor, at);
406
350
  events.push(recordEvent({
407
351
  action: "group.join",
408
352
  resource: null,
@@ -421,7 +365,7 @@ export function createShareStore(options) {
421
365
  const at = clock();
422
366
  const events = [];
423
367
  db.transaction(() => {
424
- db.query(`DELETE FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE group_id = ? AND subject_id = ?`).run(id, member);
368
+ db.query(SHARE_SQL.deleteMember).run(id, member);
425
369
  events.push(recordEvent({
426
370
  action: "group.leave",
427
371
  resource: null,
@@ -436,7 +380,7 @@ export function createShareStore(options) {
436
380
  },
437
381
  listGroupMembers(groupId) {
438
382
  const rows = db
439
- .query(`SELECT * FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE group_id = ? ORDER BY subject_id`)
383
+ .query(SHARE_SQL.listMembers)
440
384
  .all(normalizeSubjectId("group", groupId));
441
385
  return rows.map((row) => ({
442
386
  groupId: row.group_id,
@@ -450,34 +394,17 @@ export function createShareStore(options) {
450
394
  const member = normalizeSubjectId("user", subjectId);
451
395
  if (!member)
452
396
  return [];
453
- const rows = db
454
- .query(`SELECT group_id FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE subject_id = ? ORDER BY group_id`)
455
- .all(member);
397
+ const rows = db.query(SHARE_SQL.groupIdsFor).all(member);
456
398
  return rows.map((row) => row.group_id);
457
399
  },
458
400
  // ── The audit trail ──────────────────────────────────────────────────────
459
401
  /** The trail, newest first. Filter by resource, by subject, or neither. */
460
402
  events(query = {}) {
461
- const clauses = ["app IS NULL OR app = ?"];
462
- const params = [app];
463
- if (query.kind !== undefined) {
464
- clauses.push("kind = ?");
465
- params.push(query.kind);
466
- }
467
- if (query.id !== undefined) {
468
- clauses.push("resource_id = ?");
469
- params.push(query.id);
470
- }
471
- if (query.subject) {
472
- const normal = normalized(query.subject);
473
- clauses.push("subject_type = ? AND subject_id = ?");
474
- params.push(normal.type, normal.id);
475
- }
476
- const where = clauses.map((clause) => `(${clause})`).join(" AND ");
477
- const limit = Math.max(1, Math.min(query.limit ?? 200, 1000));
478
- const rows = db
479
- .query(`SELECT * FROM ${SHARE_EVENTS_TABLE} WHERE ${where} ORDER BY event_id DESC LIMIT ?`)
480
- .all(...params, limit);
403
+ const { where, params } = eventsWhere(app, {
404
+ ...query,
405
+ subject: query.subject ? normalized(query.subject) : undefined,
406
+ });
407
+ const rows = db.query(SHARE_SQL.events(where)).all(...params, eventLimit(query.limit));
481
408
  return rows.map(toEvent);
482
409
  },
483
410
  };
package/docs/activity.md CHANGED
@@ -40,4 +40,11 @@ a merge across apps stays labelled.
40
40
  `describeActivityEvent` / `describeActivityResource` produce the sentence a reader sees. An app
41
41
  that writes its own sentence for the same event creates a second wording that drifts.
42
42
 
43
+ ## 5. On a Worker, the same stream lives in D1
44
+
45
+ `cursedbelt-server/activity/d1` (since 4.28.0, task 393) is `createD1ActivityLog` — the same
46
+ `record`, `query`, `count`, `facets` and `sources`, each async, over the same `activity_events`
47
+ table and the same filter semantics (one shared `query.ts`). `shareEventsSource` is re-exported
48
+ there. It runs no DDL: `ACTIVITY_DDL` goes into the app's `db/schema.sql`. It is Worker-safe.
49
+
43
50
  Cite this page from an app as `cursedbelt-server/docs/activity.md`.
@@ -40,4 +40,13 @@ It is not filtered by the reader's scope at read time and cannot be. Put what is
40
40
  recipient in the title and body, and let the LINK carry them to the thing, where every normal
41
41
  read filter applies (`family`'s share notification does not name the relative for this reason).
42
42
 
43
+ ## 5. On a Worker, the inbox lives in D1 and there is no live stream
44
+
45
+ `cursedbelt-server/notifications/d1` (since 4.28.0, task 393) is `createD1NotificationService` —
46
+ the same `{ center, store, mount, close }`, with cwip's center over a D1 implementation of its
47
+ `NotificationStore` and the same table (`NOTIFICATIONS_DDL` / `notificationsDdl(table)` for the
48
+ app's `db/schema.sql`; nothing runs DDL at request time). `hub` is always `null` and
49
+ `{base}/stream` is not mounted: an isolate cannot share open streams, so polling (rule 1's
50
+ backstop) is the delivery. It is Worker-safe.
51
+
43
52
  Cite this page from an app as `cursedbelt-server/docs/notifications.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.27.0",
3
+ "version": "4.28.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -47,6 +47,12 @@
47
47
  "source": "./src/server/activity/index.ts",
48
48
  "import": "./dist/server/activity/index.js"
49
49
  },
50
+ "./activity/d1": {
51
+ "types": "./dist/server/activity/d1.d.ts",
52
+ "bun": "./src/server/activity/d1.ts",
53
+ "source": "./src/server/activity/d1.ts",
54
+ "import": "./dist/server/activity/d1.js"
55
+ },
50
56
  "./context": {
51
57
  "types": "./dist/server/context.d.ts",
52
58
  "bun": "./src/server/context.ts",
@@ -246,6 +252,12 @@
246
252
  "source": "./src/server/notifications/index.ts",
247
253
  "import": "./dist/server/notifications/index.js"
248
254
  },
255
+ "./notifications/d1": {
256
+ "types": "./dist/server/notifications/d1.d.ts",
257
+ "bun": "./src/server/notifications/d1.ts",
258
+ "source": "./src/server/notifications/d1.ts",
259
+ "import": "./dist/server/notifications/d1.js"
260
+ },
249
261
  "./resume": {
250
262
  "types": "./dist/server/resume/index.d.ts",
251
263
  "bun": "./src/server/resume/index.ts",
@@ -282,6 +294,12 @@
282
294
  "source": "./src/server/sharing/index.ts",
283
295
  "import": "./dist/server/sharing/index.js"
284
296
  },
297
+ "./sharing/d1": {
298
+ "types": "./dist/server/sharing/d1.d.ts",
299
+ "bun": "./src/server/sharing/d1.ts",
300
+ "source": "./src/server/sharing/d1.ts",
301
+ "import": "./dist/server/sharing/d1.js"
302
+ },
285
303
  "./sqlite": {
286
304
  "types": "./dist/server/sqlite/index.d.ts",
287
305
  "bun": "./src/server/sqlite/index.ts",
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The D1 activity log is the sync log, asynchronously — the same events, pages, counts, facets
3
+ * and sources for the same calls, over the same table (task 393, `apps/family`).
4
+ *
5
+ * The parity scenario drives both logs — each with a share store writing `share_events` into
6
+ * the same database, merged in through `shareEventsSource` — with one script and one clock, and
7
+ * compares every result and then the table. It runs over the local driver and over the fake D1
8
+ * binding, which refuses the binds real D1 refuses.
9
+ */
10
+ import { Database } from "bun:sqlite";
11
+ import { describe, expect, test } from "bun:test";
12
+ import { createFakeD1Binding } from "../d1/fakeD1.js";
13
+ import { createLocalD1 } from "../d1/local.js";
14
+ import { createRemoteD1 } from "../d1/remote.js";
15
+ import type { D1LikeDatabase } from "../d1/types.js";
16
+ import { runMigrations } from "../migration/runner.js";
17
+ import { createD1ShareStore, SHARE_DDL, user } from "../sharing/d1.js";
18
+ import { createShareStore } from "../sharing/store.js";
19
+ import { ACTIVITY_DDL, type AsyncActivityLog, createD1ActivityLog, shareEventsSource } from "./d1.js";
20
+ import { ACTIVITY_MIGRATIONS } from "./migrations.js";
21
+ import { type ActivityLog, createActivityLog } from "./store.js";
22
+
23
+ const APP = "family";
24
+
25
+ /** One minute per call, from a fixed start, identical for both logs. */
26
+ const clock = () => {
27
+ let at = Date.parse("2026-08-11T00:00:00.000Z");
28
+ return () => {
29
+ const value = new Date(at).toISOString();
30
+ at += 60_000;
31
+ return value;
32
+ };
33
+ };
34
+
35
+ const DRIVERS: Record<string, (sqlite: Database) => D1LikeDatabase> = {
36
+ local: (sqlite) => createLocalD1(sqlite),
37
+ "remote (fake D1 binding)": (sqlite) => createRemoteD1(createFakeD1Binding(sqlite)),
38
+ };
39
+ const local = DRIVERS.local as (sqlite: Database) => D1LikeDatabase;
40
+
41
+ const withDdl = (...ddl: (readonly string[])[]): Database => {
42
+ const sqlite = new Database(":memory:");
43
+ for (const statements of ddl) for (const statement of statements) sqlite.run(statement);
44
+ return sqlite;
45
+ };
46
+
47
+ type Grant = (email: string) => unknown;
48
+
49
+ const SCENARIO: ReadonlyArray<(log: ActivityLog | AsyncActivityLog, grant: Grant) => unknown> = [
50
+ (log) => log.sources(),
51
+ (log) =>
52
+ log.record({
53
+ actor: " Curt@Example.com ",
54
+ action: "person.create",
55
+ resource: { kind: "person", id: "a" },
56
+ note: "100% done",
57
+ data: { bytes: 4096 },
58
+ }),
59
+ (_log, grant) => grant("aunt@example.com"),
60
+ (log) => log.record({ actor: "other@example.com", action: "person.delete", resource: { kind: "person", id: "b" } }),
61
+ (log) => log.record({ actor: "curt@example.com", action: "backup.create", outcome: "error", note: "nothing like it" }),
62
+ (log) => log.record({ actor: "curt@example.com", action: "backup.create", at: "2026-08-10T23:59:00.000Z" }),
63
+ (_log, grant) => grant("cousin@example.com"),
64
+ (log) => log.query(),
65
+ (log) => log.query({ limit: 2 }),
66
+ (log) => log.query({ limit: 10_000 }),
67
+ (log) => log.query({ actor: "CURT@example.com" }),
68
+ (log) => log.query({ action: "person." }),
69
+ (log) => log.query({ action: "share." }),
70
+ (log) => log.query({ action: "person.create" }),
71
+ (log) => log.query({ kind: "person", resourceId: "b" }),
72
+ (log) => log.query({ outcome: "error" }),
73
+ (log) => log.query({ since: "2026-08-11T00:02:00.000Z" }),
74
+ (log) => log.query({ until: "2026-08-11T00:00:30.000Z" }),
75
+ (log) => log.query({ q: "100%" }),
76
+ (log) => log.query({ q: "%d" }),
77
+ (log) => log.query({ source: "share" }),
78
+ (log) => log.query({ source: "log", limit: 1 }),
79
+ (log) => log.query({ source: "nothing" }),
80
+ (log) => log.count(),
81
+ (log) => log.count({ source: "share" }),
82
+ (log) => log.facets(),
83
+ (log) => log.facets({ actor: "other@example.com" }, 1),
84
+ (log) => log.sources(),
85
+ ];
86
+
87
+ describe("cursedbelt-server/activity/d1", () => {
88
+ for (const [name, driver] of Object.entries(DRIVERS)) {
89
+ test(`🔴 parity over the ${name} driver: every page, count, facet and row match the sync log`, async () => {
90
+ const syncDb = new Database(":memory:");
91
+ const syncClock = clock();
92
+ const syncLog = createActivityLog({
93
+ db: syncDb,
94
+ app: APP,
95
+ now: syncClock,
96
+ sources: [shareEventsSource({ app: APP })],
97
+ });
98
+ const syncShares = createShareStore({ db: syncDb, app: APP, now: syncClock });
99
+
100
+ const d1Sqlite = withDdl(SHARE_DDL, ACTIVITY_DDL);
101
+ const d1 = driver(d1Sqlite);
102
+ const d1Clock = clock();
103
+ const d1Log = createD1ActivityLog({ db: d1, app: APP, now: d1Clock, sources: [shareEventsSource({ app: APP })] });
104
+ const d1Shares = createD1ShareStore({ db: d1, app: APP, now: d1Clock });
105
+
106
+ const grantVia =
107
+ (shares: { grant: (input: Parameters<typeof syncShares.grant>[0]) => unknown }): Grant =>
108
+ (email) =>
109
+ shares.grant({ kind: "person", id: "a", subject: user(email), level: "read", grantedBy: "curt@example.com" });
110
+
111
+ for (const [index, step] of SCENARIO.entries()) {
112
+ const expected = step(syncLog, grantVia(syncShares));
113
+ const actual = await step(d1Log, grantVia(d1Shares));
114
+ expect({ step: index, result: actual }).toEqual({ step: index, result: expected });
115
+ }
116
+ const table = (db: Database) => db.query("SELECT * FROM activity_events ORDER BY event_id").all();
117
+ expect(table(d1Sqlite)).toEqual(table(syncDb));
118
+ // Not vacuous: the merged stream really carried both contributors.
119
+ const merged = (await d1Log.query()).events.map((e) => e.source);
120
+ expect(new Set(merged)).toEqual(new Set(["log", "share"]));
121
+ });
122
+ }
123
+
124
+ test("ACTIVITY_DDL builds exactly the table and indexes the sync migration does", () => {
125
+ const shape = (db: Database) =>
126
+ db
127
+ .query("SELECT type, name, sql FROM sqlite_master WHERE name LIKE '%activity%' AND sql IS NOT NULL ORDER BY name")
128
+ .all();
129
+ const migrated = new Database(":memory:");
130
+ runMigrations(migrated, ACTIVITY_MIGRATIONS);
131
+ expect(shape(withDdl(ACTIVITY_DDL))).toEqual(shape(migrated));
132
+ expect(shape(migrated).length).toBe(5);
133
+ });
134
+
135
+ test("🔴 refuses an event that cannot be filtered or attributed, and writes nothing", async () => {
136
+ const sqlite = withDdl(ACTIVITY_DDL);
137
+ const log = createD1ActivityLog({ db: local(sqlite), app: "admin" });
138
+ await expect(log.record({ actor: "curt@example.com", action: "Backup Deleted" })).rejects.toThrow(/not an action/);
139
+ await expect(log.record({ actor: "curt@example.com", action: "backup" })).rejects.toThrow(/not an action/);
140
+ await expect(log.record({ actor: " ", action: "backup.delete" })).rejects.toThrow(/no actor/);
141
+ expect(await log.count()).toBe(0);
142
+ });
143
+
144
+ test("is append only — no verb here can remove an event", () => {
145
+ const log = createD1ActivityLog({ db: local(withDdl(ACTIVITY_DDL)), app: "admin" });
146
+ expect(Object.keys(log).some((verb) => /delete|remove|purge|clear|prune|truncate/i.test(verb))).toBe(false);
147
+ expect(Object.keys(log).sort()).toEqual(
148
+ Object.keys(createActivityLog({ db: new Database(":memory:"), app: "admin" })).sort(),
149
+ );
150
+ });
151
+
152
+ test("a source whose table does not exist is empty, not a crash — and joins once it does", async () => {
153
+ const sqlite = withDdl(ACTIVITY_DDL);
154
+ const log = createD1ActivityLog({ db: local(sqlite), app: "notes", sources: [shareEventsSource({ app: "notes" })] });
155
+ await log.record({ actor: "curt@example.com", action: "note.create" });
156
+ expect(await log.sources()).toEqual(["log"]);
157
+ expect((await log.query()).events).toHaveLength(1);
158
+ expect((await log.facets()).actions).toHaveLength(1);
159
+ for (const statement of SHARE_DDL) sqlite.run(statement);
160
+ expect(await log.sources()).toEqual(["log", "share"]);
161
+ });
162
+
163
+ test("🔴 runs no DDL: on a database without the table it creates nothing and fails loudly", async () => {
164
+ const sqlite = new Database(":memory:");
165
+ const log = createD1ActivityLog({ db: local(sqlite), app: "notes" });
166
+ expect(sqlite.query("SELECT COUNT(*) AS n FROM sqlite_master").get()).toEqual({ n: 0 });
167
+ await expect(log.record({ actor: "curt@example.com", action: "note.create" })).rejects.toThrow(/no such table/);
168
+ });
169
+ });