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.
- package/dist/server/activity/d1.d.ts +70 -0
- package/dist/server/activity/d1.js +68 -0
- package/dist/server/activity/migrations.d.ts +1 -3
- package/dist/server/activity/migrations.js +7 -32
- package/dist/server/activity/query.d.ts +123 -0
- package/dist/server/activity/query.js +171 -0
- package/dist/server/activity/schema.d.ts +13 -0
- package/dist/server/activity/schema.js +43 -0
- package/dist/server/activity/shareSource.d.ts +1 -1
- package/dist/server/activity/shareSource.js +1 -1
- package/dist/server/activity/store.d.ts +4 -62
- package/dist/server/activity/store.js +14 -145
- package/dist/server/notifications/d1.d.ts +80 -0
- package/dist/server/notifications/d1.js +226 -0
- package/dist/server/notifications/service.d.ts +2 -20
- package/dist/server/notifications/types.d.ts +27 -0
- package/dist/server/notifications/types.js +1 -0
- package/dist/server/sharing/contract.d.ts +94 -0
- package/dist/server/sharing/contract.js +3 -0
- package/dist/server/sharing/d1.d.ts +77 -0
- package/dist/server/sharing/d1.js +309 -0
- package/dist/server/sharing/index.d.ts +1 -0
- package/dist/server/sharing/migrations.d.ts +1 -4
- package/dist/server/sharing/migrations.js +7 -72
- package/dist/server/sharing/router.d.ts +8 -4
- package/dist/server/sharing/router.js +7 -5
- package/dist/server/sharing/schema.d.ts +19 -0
- package/dist/server/sharing/schema.js +88 -0
- package/dist/server/sharing/sql.js +1 -1
- package/dist/server/sharing/statements.d.ts +116 -0
- package/dist/server/sharing/statements.js +139 -0
- package/dist/server/sharing/store.d.ts +3 -28
- package/dist/server/sharing/store.js +32 -105
- package/docs/activity.md +7 -0
- package/docs/notifications.md +9 -0
- package/package.json +19 -1
- package/src/server/activity/d1.spec.ts +169 -0
- package/src/server/activity/d1.ts +172 -0
- package/src/server/activity/migrations.ts +6 -40
- package/src/server/activity/query.ts +283 -0
- package/src/server/activity/schema.ts +45 -0
- package/src/server/activity/shareSource.ts +2 -2
- package/src/server/activity/store.ts +45 -257
- package/src/server/notifications/d1.spec.ts +179 -0
- package/src/server/notifications/d1.ts +334 -0
- package/src/server/notifications/service.ts +7 -23
- package/src/server/notifications/types.ts +29 -0
- package/src/server/sharing/contract.ts +109 -0
- package/src/server/sharing/d1.spec.ts +242 -0
- package/src/server/sharing/d1.ts +430 -0
- package/src/server/sharing/index.ts +3 -0
- package/src/server/sharing/migrations.ts +12 -85
- package/src/server/sharing/router.ts +21 -11
- package/src/server/sharing/schema.ts +90 -0
- package/src/server/sharing/sql.ts +1 -1
- package/src/server/sharing/statements.ts +213 -0
- package/src/server/sharing/store.ts +46 -221
- 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 {
|
|
3
|
+
import { SHARE_MIGRATIONS } from "./migrations.js";
|
|
4
4
|
import { sharedResourceSql } from "./sql.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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`.
|
package/docs/notifications.md
CHANGED
|
@@ -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.
|
|
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
|
+
});
|