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,116 @@
1
+ /**
2
+ * Every SQL statement the share stores run, and the row → shape mappers — ONE copy for both
3
+ * the `bun:sqlite` store (`./store.ts`) and the D1 store (`./d1.ts`).
4
+ *
5
+ * Two stores with two copies of "strongest first, then oldest" is two answers the day
6
+ * somebody edits one of them. So the stores differ only in HOW they execute (a synchronous
7
+ * transaction vs one atomic `batch()`), never in WHAT they ask. Driver-free on purpose: the
8
+ * D1 store runs on `workerd`.
9
+ */
10
+ import type { ShareAction, ShareEvent, ShareGrant, ShareLevel } from "cursedbelt-core/sharing/model";
11
+ export interface GrantRow {
12
+ app: string;
13
+ kind: string;
14
+ resource_id: string;
15
+ subject_type: "user" | "group";
16
+ subject_id: string;
17
+ level: ShareLevel;
18
+ granted_by: string;
19
+ granted_at: string;
20
+ note: string;
21
+ }
22
+ export interface EventRow {
23
+ event_id: number;
24
+ at: string;
25
+ action: ShareAction;
26
+ app: string | null;
27
+ kind: string | null;
28
+ resource_id: string | null;
29
+ subject_type: "user" | "group";
30
+ subject_id: string;
31
+ level: ShareLevel | null;
32
+ previous_level: ShareLevel | null;
33
+ actor: string;
34
+ note: string;
35
+ }
36
+ export interface GroupRow {
37
+ group_id: string;
38
+ name: string;
39
+ created_by: string;
40
+ created_at: string;
41
+ note: string;
42
+ }
43
+ export interface GroupMemberRow {
44
+ group_id: string;
45
+ subject_id: string;
46
+ added_by: string;
47
+ added_at: string;
48
+ }
49
+ export declare const toGrant: (row: GrantRow) => ShareGrant;
50
+ export declare const toEvent: (row: EventRow) => ShareEvent;
51
+ export declare const SHARE_SQL: {
52
+ /** Binds the eleven {@link EVENT_COLUMNS}. */
53
+ readonly insertEvent: "INSERT INTO share_events\n\t\t\t (at, action, app, kind, resource_id, subject_type, subject_id, level, previous_level, actor, note)\n\t\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
54
+ readonly selectGrant: "SELECT * FROM share_grants WHERE app = ? AND kind = ? AND resource_id = ? AND subject_type = ? AND subject_id = ?";
55
+ /** Binds app, kind, resource_id, subject_type, subject_id, level, granted_by, granted_at, note. */
56
+ readonly upsertGrant: "INSERT INTO share_grants\n\t\t\t\t (app, kind, resource_id, subject_type, subject_id, level, granted_by, granted_at, note)\n\t\t\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t\t\t ON CONFLICT (app, kind, resource_id, subject_type, subject_id)\n\t\t\t\t DO UPDATE SET level = excluded.level, granted_by = excluded.granted_by,\n\t\t\t\t granted_at = excluded.granted_at, note = excluded.note";
57
+ readonly deleteGrant: "DELETE FROM share_grants WHERE app = ? AND kind = ? AND resource_id = ? AND subject_type = ? AND subject_id = ?";
58
+ readonly selectResourceGrants: "SELECT * FROM share_grants WHERE app = ? AND kind = ? AND resource_id = ?";
59
+ readonly deleteResourceGrants: "DELETE FROM share_grants WHERE app = ? AND kind = ? AND resource_id = ?";
60
+ readonly selectSubjectGrants: "SELECT * FROM share_grants WHERE app = ? AND subject_type = ? AND subject_id = ?";
61
+ readonly deleteSubjectGrants: "DELETE FROM share_grants WHERE app = ? AND subject_type = ? AND subject_id = ?";
62
+ readonly selectGroupGrants: "SELECT * FROM share_grants WHERE app = ? AND subject_type = 'group' AND subject_id = ?";
63
+ readonly deleteGroupGrants: "DELETE FROM share_grants WHERE app = ? AND subject_type = 'group' AND subject_id = ?";
64
+ readonly listForResource: "SELECT * FROM share_grants\n\t\t\t\t\t WHERE app = ? AND kind = ? AND resource_id = ?\n\t\t\t\t\t -- Strongest first, then oldest. Spelled as a CASE rather than an\n\t\t\t\t\t -- ORDER BY on level, which happens to work only because 'write'\n\t\t\t\t\t -- sorts after 'read' and would silently mis-order a third level.\n\t\t\t\t\t ORDER BY CASE level WHEN 'write' THEN 0 ELSE 1 END, granted_at ASC";
65
+ readonly listForSubject: "SELECT * FROM share_grants\n\t\t\t\t\t WHERE app = ? AND subject_type = ? AND subject_id = ?\n\t\t\t\t\t ORDER BY granted_at DESC";
66
+ /** Binds app, kind, resource_id, then the viewer twice. */
67
+ readonly accessFor: "SELECT level FROM share_grants\n\t\t\t\t\t WHERE app = ? AND kind = ? AND resource_id = ?\n\t\t\t\t\t AND ( (subject_type = 'user' AND subject_id = ?)\n\t\t OR (subject_type = 'group' AND subject_id IN\n\t\t (SELECT group_id FROM share_group_members WHERE subject_id = ?)) )";
68
+ /** Binds app, kind, each of `levelCount` levels, then the viewer twice. */
69
+ readonly resourceIdsFor: (levelCount: number) => string;
70
+ readonly upsertGroup: "INSERT INTO share_groups (group_id, name, created_by, created_at, note)\n\t\t\t\t\t VALUES (?, ?, ?, ?, ?)\n\t\t\t\t\t ON CONFLICT (group_id) DO UPDATE SET name = excluded.name, note = excluded.note";
71
+ readonly renameGroup: "UPDATE share_groups SET name = ? WHERE group_id = ?";
72
+ readonly deleteGroup: "DELETE FROM share_groups WHERE group_id = ?";
73
+ readonly listGroups: "SELECT * FROM share_groups ORDER BY name";
74
+ readonly insertMember: "INSERT INTO share_group_members (group_id, subject_id, added_by, added_at)\n\t\t\t\t\t VALUES (?, ?, ?, ?) ON CONFLICT (group_id, subject_id) DO NOTHING";
75
+ readonly deleteMember: "DELETE FROM share_group_members WHERE group_id = ? AND subject_id = ?";
76
+ readonly listMembers: "SELECT * FROM share_group_members WHERE group_id = ? ORDER BY subject_id";
77
+ readonly groupIdsFor: "SELECT group_id FROM share_group_members WHERE subject_id = ? ORDER BY group_id";
78
+ /** The trail, newest first, under a WHERE built by {@link eventsWhere}; binds its params then the limit. */
79
+ readonly events: (where: string) => string;
80
+ /**
81
+ * The grant's event, read from the grant as it stands BEFORE the upsert that follows it in
82
+ * the same batch: `regrade` (with the old level) when the level changes, `grant` otherwise.
83
+ * Binds at, level, app, kind, resource_id, subject_type, subject_id, level, level, actor,
84
+ * note, then the five {@link ONE_GRANT} keys.
85
+ */
86
+ readonly insertGrantEvent: `INSERT INTO share_events (at, action, app, kind, resource_id, subject_type, subject_id, level, previous_level, actor, note)
87
+ \t\t\t SELECT ?, CASE WHEN g.level IS NOT NULL AND g.level <> ? THEN 'regrade' ELSE 'grant' END,
88
+ \t\t\t ?, ?, ?, ?, ?, ?,
89
+ \t\t\t CASE WHEN g.level <> ? THEN g.level ELSE NULL END, ?, ?
90
+ \t\t\t FROM (SELECT 1) AS one
91
+ \t\t\t LEFT JOIN share_grants AS g ON g.${string}
92
+ \t\t\t RETURNING *`;
93
+ /**
94
+ * One `revoke` event per grant matching `where`, oldest grant first — written BEFORE the
95
+ * delete that follows it in the same batch. Binds at, actor, note, then `where`'s params.
96
+ */
97
+ readonly insertRevokeEvents: (where: string) => string;
98
+ readonly oneGrant: "app = ? AND kind = ? AND resource_id = ? AND subject_type = ? AND subject_id = ?";
99
+ readonly resourceGrants: "app = ? AND kind = ? AND resource_id = ?";
100
+ readonly subjectGrants: "app = ? AND subject_type = ? AND subject_id = ?";
101
+ readonly groupGrants: "app = ? AND subject_type = 'group' AND subject_id = ?";
102
+ };
103
+ /** The trail's WHERE and its params, bound to one app — the filter both stores apply. */
104
+ export declare function eventsWhere(app: string, query: {
105
+ kind?: string;
106
+ id?: string;
107
+ subject?: {
108
+ type: string;
109
+ id: string;
110
+ };
111
+ }): {
112
+ where: string;
113
+ params: (string | number)[];
114
+ };
115
+ /** Newest first, capped: default 200, never more than 1000, never less than 1. */
116
+ export declare const eventLimit: (limit: number | undefined) => number;
@@ -0,0 +1,139 @@
1
+ import { SHARE_EVENTS_TABLE, SHARE_GRANTS_TABLE, SHARE_GROUPS_TABLE, SHARE_GROUP_MEMBERS_TABLE } from "./schema.js";
2
+ export const toGrant = (row) => ({
3
+ resource: { app: row.app, kind: row.kind, id: row.resource_id },
4
+ subject: { type: row.subject_type, id: row.subject_id },
5
+ level: row.level,
6
+ grantedBy: row.granted_by,
7
+ grantedAt: row.granted_at,
8
+ note: row.note,
9
+ });
10
+ export const toEvent = (row) => ({
11
+ id: row.event_id,
12
+ at: row.at,
13
+ action: row.action,
14
+ resource: row.app === null || row.kind === null || row.resource_id === null
15
+ ? null
16
+ : { app: row.app, kind: row.kind, id: row.resource_id },
17
+ subject: { type: row.subject_type, id: row.subject_id },
18
+ level: row.level,
19
+ previousLevel: row.previous_level,
20
+ actor: row.actor,
21
+ note: row.note,
22
+ });
23
+ /** The audit trail's columns, in the order every event insert binds them. */
24
+ const EVENT_COLUMNS = "(at, action, app, kind, resource_id, subject_type, subject_id, level, previous_level, actor, note)";
25
+ /** One grant row's WHERE — the five columns of its UNIQUE key. */
26
+ const ONE_GRANT = "app = ? AND kind = ? AND resource_id = ? AND subject_type = ? AND subject_id = ?";
27
+ const RESOURCE_GRANTS = "app = ? AND kind = ? AND resource_id = ?";
28
+ const SUBJECT_GRANTS = "app = ? AND subject_type = ? AND subject_id = ?";
29
+ const GROUP_GRANTS = "app = ? AND subject_type = 'group' AND subject_id = ?";
30
+ /** "This viewer, directly or through any group they are in" — binds `subjectId` twice. */
31
+ const VIEWER_MATCH = `( (subject_type = 'user' AND subject_id = ?)
32
+ OR (subject_type = 'group' AND subject_id IN
33
+ (SELECT group_id FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE subject_id = ?)) )`;
34
+ export const SHARE_SQL = {
35
+ /** Binds the eleven {@link EVENT_COLUMNS}. */
36
+ insertEvent: `INSERT INTO ${SHARE_EVENTS_TABLE}
37
+ ${EVENT_COLUMNS}
38
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
39
+ selectGrant: `SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE ${ONE_GRANT}`,
40
+ /** Binds app, kind, resource_id, subject_type, subject_id, level, granted_by, granted_at, note. */
41
+ upsertGrant: `INSERT INTO ${SHARE_GRANTS_TABLE}
42
+ (app, kind, resource_id, subject_type, subject_id, level, granted_by, granted_at, note)
43
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
44
+ ON CONFLICT (app, kind, resource_id, subject_type, subject_id)
45
+ DO UPDATE SET level = excluded.level, granted_by = excluded.granted_by,
46
+ granted_at = excluded.granted_at, note = excluded.note`,
47
+ deleteGrant: `DELETE FROM ${SHARE_GRANTS_TABLE} WHERE ${ONE_GRANT}`,
48
+ selectResourceGrants: `SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE ${RESOURCE_GRANTS}`,
49
+ deleteResourceGrants: `DELETE FROM ${SHARE_GRANTS_TABLE} WHERE ${RESOURCE_GRANTS}`,
50
+ selectSubjectGrants: `SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE ${SUBJECT_GRANTS}`,
51
+ deleteSubjectGrants: `DELETE FROM ${SHARE_GRANTS_TABLE} WHERE ${SUBJECT_GRANTS}`,
52
+ selectGroupGrants: `SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE ${GROUP_GRANTS}`,
53
+ deleteGroupGrants: `DELETE FROM ${SHARE_GRANTS_TABLE} WHERE ${GROUP_GRANTS}`,
54
+ listForResource: `SELECT * FROM ${SHARE_GRANTS_TABLE}
55
+ WHERE ${RESOURCE_GRANTS}
56
+ -- Strongest first, then oldest. Spelled as a CASE rather than an
57
+ -- ORDER BY on level, which happens to work only because 'write'
58
+ -- sorts after 'read' and would silently mis-order a third level.
59
+ ORDER BY CASE level WHEN 'write' THEN 0 ELSE 1 END, granted_at ASC`,
60
+ listForSubject: `SELECT * FROM ${SHARE_GRANTS_TABLE}
61
+ WHERE ${SUBJECT_GRANTS}
62
+ ORDER BY granted_at DESC`,
63
+ /** Binds app, kind, resource_id, then the viewer twice. */
64
+ accessFor: `SELECT level FROM ${SHARE_GRANTS_TABLE}
65
+ WHERE ${RESOURCE_GRANTS}
66
+ AND ${VIEWER_MATCH}`,
67
+ /** Binds app, kind, each of `levelCount` levels, then the viewer twice. */
68
+ resourceIdsFor: (levelCount) => `SELECT DISTINCT resource_id FROM ${SHARE_GRANTS_TABLE}
69
+ WHERE app = ? AND kind = ? AND level IN (${Array.from({ length: levelCount }, () => "?").join(", ")})
70
+ AND ${VIEWER_MATCH}
71
+ ORDER BY resource_id`,
72
+ upsertGroup: `INSERT INTO ${SHARE_GROUPS_TABLE} (group_id, name, created_by, created_at, note)
73
+ VALUES (?, ?, ?, ?, ?)
74
+ ON CONFLICT (group_id) DO UPDATE SET name = excluded.name, note = excluded.note`,
75
+ renameGroup: `UPDATE ${SHARE_GROUPS_TABLE} SET name = ? WHERE group_id = ?`,
76
+ deleteGroup: `DELETE FROM ${SHARE_GROUPS_TABLE} WHERE group_id = ?`,
77
+ listGroups: `SELECT * FROM ${SHARE_GROUPS_TABLE} ORDER BY name`,
78
+ insertMember: `INSERT INTO ${SHARE_GROUP_MEMBERS_TABLE} (group_id, subject_id, added_by, added_at)
79
+ VALUES (?, ?, ?, ?) ON CONFLICT (group_id, subject_id) DO NOTHING`,
80
+ deleteMember: `DELETE FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE group_id = ? AND subject_id = ?`,
81
+ listMembers: `SELECT * FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE group_id = ? ORDER BY subject_id`,
82
+ groupIdsFor: `SELECT group_id FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE subject_id = ? ORDER BY group_id`,
83
+ /** The trail, newest first, under a WHERE built by {@link eventsWhere}; binds its params then the limit. */
84
+ events: (where) => `SELECT * FROM ${SHARE_EVENTS_TABLE} WHERE ${where} ORDER BY event_id DESC LIMIT ?`,
85
+ // ── The D1 store's atomic forms ────────────────────────────────────────────────────────
86
+ // D1 has no interactive transaction, so "read the grant, then decide what to log" cannot
87
+ // be two round trips without a window between them. These write the audit row FROM the
88
+ // grant row inside one `batch()`, so the decision and the write are one atomic step. They
89
+ // are listed here beside the sync forms they must agree with — `d1.spec.ts` runs both
90
+ // stores through one scenario and compares every table.
91
+ /**
92
+ * The grant's event, read from the grant as it stands BEFORE the upsert that follows it in
93
+ * the same batch: `regrade` (with the old level) when the level changes, `grant` otherwise.
94
+ * Binds at, level, app, kind, resource_id, subject_type, subject_id, level, level, actor,
95
+ * note, then the five {@link ONE_GRANT} keys.
96
+ */
97
+ insertGrantEvent: `INSERT INTO ${SHARE_EVENTS_TABLE} ${EVENT_COLUMNS}
98
+ SELECT ?, CASE WHEN g.level IS NOT NULL AND g.level <> ? THEN 'regrade' ELSE 'grant' END,
99
+ ?, ?, ?, ?, ?, ?,
100
+ CASE WHEN g.level <> ? THEN g.level ELSE NULL END, ?, ?
101
+ FROM (SELECT 1) AS one
102
+ LEFT JOIN ${SHARE_GRANTS_TABLE} AS g ON g.${ONE_GRANT.replaceAll(" AND ", " AND g.")}
103
+ RETURNING *`,
104
+ /**
105
+ * One `revoke` event per grant matching `where`, oldest grant first — written BEFORE the
106
+ * delete that follows it in the same batch. Binds at, actor, note, then `where`'s params.
107
+ */
108
+ insertRevokeEvents: (where) => `INSERT INTO ${SHARE_EVENTS_TABLE} ${EVENT_COLUMNS}
109
+ SELECT ?, 'revoke', app, kind, resource_id, subject_type, subject_id, level, NULL, ?, ?
110
+ FROM ${SHARE_GRANTS_TABLE} WHERE ${where} ORDER BY grant_id
111
+ RETURNING *`,
112
+ oneGrant: ONE_GRANT,
113
+ resourceGrants: RESOURCE_GRANTS,
114
+ subjectGrants: SUBJECT_GRANTS,
115
+ groupGrants: GROUP_GRANTS,
116
+ };
117
+ /** The trail's WHERE and its params, bound to one app — the filter both stores apply. */
118
+ export function eventsWhere(app, query) {
119
+ const clauses = ["app IS NULL OR app = ?"];
120
+ const params = [app];
121
+ if (query.kind !== undefined) {
122
+ clauses.push("kind = ?");
123
+ params.push(query.kind);
124
+ }
125
+ if (query.id !== undefined) {
126
+ clauses.push("resource_id = ?");
127
+ params.push(query.id);
128
+ }
129
+ if (query.subject) {
130
+ clauses.push("subject_type = ? AND subject_id = ?");
131
+ params.push(query.subject.type, query.subject.id);
132
+ }
133
+ return {
134
+ where: clauses.map((clause) => `(${clause})`).join(" AND "),
135
+ params,
136
+ };
137
+ }
138
+ /** Newest first, capped: default 200, never more than 1000, never less than 1. */
139
+ export const eventLimit = (limit) => Math.max(1, Math.min(limit ?? 200, 1000));
@@ -34,15 +34,9 @@
34
34
  */
35
35
  import type { Database } from "bun:sqlite";
36
36
  import { type ShareAccess, type ShareEvent, type ShareGrant, type ShareGroup, type ShareGroupMember, type ShareLevel, type ShareSubject, type ShareViewer } from "cursedbelt-core/sharing/model";
37
- /** Shorthand for the common subject. `group(id)` is its sibling. */
38
- export declare const user: (id: string) => ShareSubject;
39
- export declare const group: (id: string) => ShareSubject;
40
- /** A resource within the store's own app — the `app` half is supplied by the
41
- * store, so a caller cannot mistakenly write a grant into another app's rows. */
42
- export interface ShareTarget {
43
- kind: string;
44
- id: string;
45
- }
37
+ import type { GrantInput, RevokeInput, ShareEventQuery, ShareTarget } from "./contract.js";
38
+ export { group, user } from "./contract.js";
39
+ export type { GrantInput, RevokeInput, ShareEventQuery, ShareTarget } from "./contract.js";
46
40
  export interface ShareStoreOptions {
47
41
  db: Database;
48
42
  /** The satellite this store speaks for. Fixed at construction so `app` cannot
@@ -66,25 +60,6 @@ export interface ShareStoreOptions {
66
60
  /** Injectable clock, for tests that assert ordering. ISO-8601 strings. */
67
61
  now?: () => string;
68
62
  }
69
- export interface GrantInput extends ShareTarget {
70
- subject: ShareSubject;
71
- level: ShareLevel;
72
- grantedBy: string;
73
- note?: string;
74
- }
75
- export interface RevokeInput extends ShareTarget {
76
- subject: ShareSubject;
77
- revokedBy: string;
78
- note?: string;
79
- }
80
- export interface ShareEventQuery {
81
- kind?: string;
82
- id?: string;
83
- subject?: ShareSubject;
84
- /** Newest first, capped. Default 200 — a panel shows a page, and an unbounded
85
- * audit read on a busy resource is the query that times out in production. */
86
- limit?: number;
87
- }
88
63
  export type ShareStore = ReturnType<typeof createShareStore>;
89
64
  /**
90
65
  * Open (and migrate) the sharing tables in this database, returning the store.
@@ -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`.
@@ -44,4 +44,10 @@ manifest parses (`apps/roms/routes.manifest.test.ts` is the pattern).
44
44
  excluded app, `SATELLITE_ENGAGEMENT=0` or an unresolvable data directory each turn recording OFF
45
45
  and say why, and the app still boots.
46
46
 
47
+ **On a Worker**, the same three tables live in the app's own D1 instead:
48
+ `cursedbelt-server/engagement/d1` (since 4.27.0, task 2096) exports `ENGAGEMENT_DDL` for the
49
+ app's `db/schema.sql`, `createD1EngagementRecorder` to mount inside the gate, and
50
+ `placesFromManifest` for the bundled `routes.manifest.json`. It is Worker-safe and uses the same
51
+ `policy.ts`, so there is one privacy rule whichever host records. station reads either shape.
52
+
47
53
  Cite this page from an app as `cursedbelt-server/docs/engagement.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`.