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
@@ -32,97 +32,24 @@
32
32
  */
33
33
  import type { Database } from "bun:sqlite";
34
34
  import type { Migration } from "../migration/types.js";
35
+ import { SHARE_DDL } from "./schema.js";
35
36
 
36
- export const SHARE_GRANTS_TABLE = "share_grants";
37
- export const SHARE_GROUPS_TABLE = "share_groups";
38
- export const SHARE_GROUP_MEMBERS_TABLE = "share_group_members";
39
- export const SHARE_EVENTS_TABLE = "share_events";
37
+ // The names and the DDL live in `./schema.ts`, driver-free, so a D1 app builds its schema file
38
+ // from the SAME statements this migration runs (`cursedbelt-server/sharing/d1` re-exports
39
+ // them). Re-exported here so every existing import of this module keeps working.
40
+ export {
41
+ SHARE_DDL,
42
+ SHARE_EVENTS_TABLE,
43
+ SHARE_GRANTS_TABLE,
44
+ SHARE_GROUPS_TABLE,
45
+ SHARE_GROUP_MEMBERS_TABLE,
46
+ } from "./schema.js";
40
47
 
41
48
  export const SHARE_MIGRATIONS: readonly Migration[] = [
42
49
  {
43
50
  name: "0001_share_grants",
44
51
  up(db: Database): void {
45
- db.run(`
46
- CREATE TABLE IF NOT EXISTS ${SHARE_GRANTS_TABLE} (
47
- grant_id INTEGER PRIMARY KEY AUTOINCREMENT,
48
- app TEXT NOT NULL,
49
- kind TEXT NOT NULL,
50
- resource_id TEXT NOT NULL,
51
- subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
52
- subject_id TEXT NOT NULL,
53
- level TEXT NOT NULL CHECK (level IN ('read', 'write')),
54
- granted_by TEXT NOT NULL,
55
- granted_at TEXT NOT NULL,
56
- note TEXT NOT NULL DEFAULT '',
57
- -- One grant per subject per resource. This is what makes raising
58
- -- read to write an UPDATE the audit trail can describe, instead of
59
- -- two rows where revoking one leaves access in place.
60
- UNIQUE (app, kind, resource_id, subject_type, subject_id)
61
- )
62
- `);
63
- // The hot read is the resolver's: "does this subject hold a grant on this
64
- // row" — correlated per row of a list query, so it must be an index hit.
65
- db.run(
66
- `CREATE INDEX IF NOT EXISTS idx_share_grants_subject
67
- ON ${SHARE_GRANTS_TABLE} (subject_type, subject_id, app, kind, resource_id)`,
68
- );
69
- // …and the panel's: "who can see this record".
70
- db.run(
71
- `CREATE INDEX IF NOT EXISTS idx_share_grants_resource
72
- ON ${SHARE_GRANTS_TABLE} (app, kind, resource_id)`,
73
- );
74
-
75
- db.run(`
76
- CREATE TABLE IF NOT EXISTS ${SHARE_GROUPS_TABLE} (
77
- group_id TEXT PRIMARY KEY,
78
- name TEXT NOT NULL,
79
- created_by TEXT NOT NULL,
80
- created_at TEXT NOT NULL,
81
- note TEXT NOT NULL DEFAULT ''
82
- )
83
- `);
84
-
85
- db.run(`
86
- CREATE TABLE IF NOT EXISTS ${SHARE_GROUP_MEMBERS_TABLE} (
87
- group_id TEXT NOT NULL REFERENCES ${SHARE_GROUPS_TABLE} (group_id) ON DELETE CASCADE,
88
- subject_id TEXT NOT NULL,
89
- added_by TEXT NOT NULL,
90
- added_at TEXT NOT NULL,
91
- PRIMARY KEY (group_id, subject_id)
92
- )
93
- `);
94
- // "Which groups is this person in" — read by every shared-row query.
95
- db.run(
96
- `CREATE INDEX IF NOT EXISTS idx_share_group_members_subject
97
- ON ${SHARE_GROUP_MEMBERS_TABLE} (subject_id)`,
98
- );
99
-
100
- db.run(`
101
- CREATE TABLE IF NOT EXISTS ${SHARE_EVENTS_TABLE} (
102
- event_id INTEGER PRIMARY KEY AUTOINCREMENT,
103
- at TEXT NOT NULL,
104
- action TEXT NOT NULL,
105
- app TEXT,
106
- kind TEXT,
107
- resource_id TEXT,
108
- subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
109
- subject_id TEXT NOT NULL,
110
- level TEXT,
111
- previous_level TEXT,
112
- actor TEXT NOT NULL,
113
- note TEXT NOT NULL DEFAULT ''
114
- )
115
- `);
116
- // "Everything that ever happened to this record", newest first.
117
- db.run(
118
- `CREATE INDEX IF NOT EXISTS idx_share_events_resource
119
- ON ${SHARE_EVENTS_TABLE} (app, kind, resource_id, event_id DESC)`,
120
- );
121
- // "Everything this person was ever given" — the offboarding question.
122
- db.run(
123
- `CREATE INDEX IF NOT EXISTS idx_share_events_subject
124
- ON ${SHARE_EVENTS_TABLE} (subject_id, event_id DESC)`,
125
- );
52
+ for (const statement of SHARE_DDL) db.run(statement);
126
53
  },
127
54
  },
128
55
  ];
@@ -44,7 +44,7 @@
44
44
  */
45
45
  import type { Context, Hono } from 'hono';
46
46
  import type { ShareEvent, ShareGrant, ShareLevel } from 'cursedbelt-core/sharing/model';
47
- import type { ShareStore } from './store.js';
47
+ import type { AwaitableShareStore } from './contract.js';
48
48
 
49
49
  /** The `group:` prefix that lets a group subject travel in a path segment. Exported so a
50
50
  * client and a route cannot disagree about it. */
@@ -77,8 +77,12 @@ export interface ShareRouterOptions<V extends ShareRouterViewer> {
77
77
  base: string;
78
78
  /** The `kind` half of the store's `(app, kind, id)` triple. */
79
79
  kind: string;
80
- /** The store. A function when it is per-request (a multi-tenant app). */
81
- shares: ShareStore | ((c: Context) => ShareStore);
80
+ /**
81
+ * The store — the sync `ShareStore` or the D1 `AsyncShareStore` (`./sharing/d1`); every
82
+ * call is awaited, and awaiting a sync store's plain value changes nothing. A function
83
+ * when it is per-request (a multi-tenant app, or a Worker whose D1 handle is per request).
84
+ */
85
+ shares: AwaitableShareStore | ((c: Context) => AwaitableShareStore);
82
86
  /** Who is asking. Returning null refuses every route with 401. */
83
87
  viewerOf: (c: Context) => V | null;
84
88
  /**
@@ -89,7 +93,11 @@ export interface ShareRouterOptions<V extends ShareRouterViewer> {
89
93
  mustOwn: (c: Context, id: string, viewer: V) => Response | null | Promise<Response | null>;
90
94
  /** Extra fields folded into the READ payload beside `{grants, events}` — family's
91
95
  * `labels`, life's `groups`. */
92
- extra?: (c: Context, id: string, viewer: V) => Record<string, unknown>;
96
+ extra?: (
97
+ c: Context,
98
+ id: string,
99
+ viewer: V,
100
+ ) => Record<string, unknown> | Promise<Record<string, unknown>>;
93
101
  /** How many audit lines the read carries. Default 50. */
94
102
  trailLimit?: number;
95
103
  }
@@ -125,13 +133,13 @@ export function mountShareRoutes<V extends ShareRouterViewer>(
125
133
  options: ShareRouterOptions<V>,
126
134
  ): void {
127
135
  const { base, kind, viewerOf, mustOwn, extra, trailLimit = 50 } = options;
128
- const storeOf = (c: Context): ShareStore =>
136
+ const storeOf = (c: Context): AwaitableShareStore =>
129
137
  typeof options.shares === 'function' ? options.shares(c) : options.shares;
130
138
 
131
139
  /** The three-step preamble every route shares: identity, id, ownership. */
132
140
  async function enter(
133
141
  c: Context,
134
- ): Promise<{ refused: Response } | { id: string; viewer: V; shares: ShareStore }> {
142
+ ): Promise<{ refused: Response } | { id: string; viewer: V; shares: AwaitableShareStore }> {
135
143
  const viewer = viewerOf(c);
136
144
  if (!viewer) return { refused: c.json({ error: 'sign in first' }, 401) };
137
145
  const id = c.req.param('id') ?? '';
@@ -147,9 +155,11 @@ export function mountShareRoutes<V extends ShareRouterViewer>(
147
155
  const payload: ShareRoutesPayload & Record<string, unknown> = {
148
156
  // The store's own rows, straight through — the sharing panel consumes exactly
149
157
  // these, and a DTO in the middle is a place for a field to go missing.
150
- grants: shares.listForResource({ kind, id }),
151
- events: shares.events({ kind, id, limit: trailLimit }),
152
- ...(extra ? extra(c, id, viewer) : {}),
158
+ // Awaited in this order — grants, trail, extra — which is the order the sync store
159
+ // was always read in.
160
+ grants: await shares.listForResource({ kind, id }),
161
+ events: await shares.events({ kind, id, limit: trailLimit }),
162
+ ...(extra ? await extra(c, id, viewer) : {}),
153
163
  };
154
164
  return c.json(payload);
155
165
  });
@@ -171,7 +181,7 @@ export function mountShareRoutes<V extends ShareRouterViewer>(
171
181
  if (body.level !== undefined && !isLevel(body.level)) {
172
182
  return c.json({ error: "level must be 'read' or 'write'" }, 400);
173
183
  }
174
- const grant = shares.grant({
184
+ const grant = await shares.grant({
175
185
  kind,
176
186
  id,
177
187
  subject: { type: body.subject?.type === 'group' ? 'group' : 'user', id: subjectId },
@@ -188,7 +198,7 @@ export function mountShareRoutes<V extends ShareRouterViewer>(
188
198
  const { id, viewer, shares } = step;
189
199
  const raw = c.req.param('subject') ?? '';
190
200
  if (!raw.trim()) return c.json({ error: 'which share should be removed?' }, 400);
191
- shares.revoke({ kind, id, subject: parseSubjectParam(raw), revokedBy: viewer.email });
201
+ await shares.revoke({ kind, id, subject: parseSubjectParam(raw), revokedBy: viewer.email });
192
202
  return c.json({ ok: true });
193
203
  });
194
204
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The sharing tables' names and DDL, with no driver attached — the ONE copy both the
3
+ * `bun:sqlite` migration (`./migrations.ts`) and a D1 app's `db/schema.sql` are built from.
4
+ *
5
+ * It is import-free on purpose: `./d1.ts` runs on `workerd`, and a Worker app generates its
6
+ * schema file from these statements rather than running DDL at request time. The migration
7
+ * runs exactly this list, so the two can never describe different tables. Why each table and
8
+ * each index exists is `./migrations.ts`'s header.
9
+ */
10
+
11
+ export const SHARE_GRANTS_TABLE = "share_grants";
12
+ export const SHARE_GROUPS_TABLE = "share_groups";
13
+ export const SHARE_GROUP_MEMBERS_TABLE = "share_group_members";
14
+ export const SHARE_EVENTS_TABLE = "share_events";
15
+
16
+ /**
17
+ * Every statement of the `0001_share_grants` migration, in order. Idempotent (`IF NOT
18
+ * EXISTS` throughout), so a schema file built from it can be applied to a database that
19
+ * already has the tables.
20
+ */
21
+ export const SHARE_DDL: readonly string[] = [
22
+ `
23
+ CREATE TABLE IF NOT EXISTS ${SHARE_GRANTS_TABLE} (
24
+ grant_id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ app TEXT NOT NULL,
26
+ kind TEXT NOT NULL,
27
+ resource_id TEXT NOT NULL,
28
+ subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
29
+ subject_id TEXT NOT NULL,
30
+ level TEXT NOT NULL CHECK (level IN ('read', 'write')),
31
+ granted_by TEXT NOT NULL,
32
+ granted_at TEXT NOT NULL,
33
+ note TEXT NOT NULL DEFAULT '',
34
+ -- One grant per subject per resource. This is what makes raising
35
+ -- read to write an UPDATE the audit trail can describe, instead of
36
+ -- two rows where revoking one leaves access in place.
37
+ UNIQUE (app, kind, resource_id, subject_type, subject_id)
38
+ )
39
+ `,
40
+ // The hot read is the resolver's: "does this subject hold a grant on this
41
+ // row" — correlated per row of a list query, so it must be an index hit.
42
+ `CREATE INDEX IF NOT EXISTS idx_share_grants_subject
43
+ ON ${SHARE_GRANTS_TABLE} (subject_type, subject_id, app, kind, resource_id)`,
44
+ // …and the panel's: "who can see this record".
45
+ `CREATE INDEX IF NOT EXISTS idx_share_grants_resource
46
+ ON ${SHARE_GRANTS_TABLE} (app, kind, resource_id)`,
47
+ `
48
+ CREATE TABLE IF NOT EXISTS ${SHARE_GROUPS_TABLE} (
49
+ group_id TEXT PRIMARY KEY,
50
+ name TEXT NOT NULL,
51
+ created_by TEXT NOT NULL,
52
+ created_at TEXT NOT NULL,
53
+ note TEXT NOT NULL DEFAULT ''
54
+ )
55
+ `,
56
+ `
57
+ CREATE TABLE IF NOT EXISTS ${SHARE_GROUP_MEMBERS_TABLE} (
58
+ group_id TEXT NOT NULL REFERENCES ${SHARE_GROUPS_TABLE} (group_id) ON DELETE CASCADE,
59
+ subject_id TEXT NOT NULL,
60
+ added_by TEXT NOT NULL,
61
+ added_at TEXT NOT NULL,
62
+ PRIMARY KEY (group_id, subject_id)
63
+ )
64
+ `,
65
+ // "Which groups is this person in" — read by every shared-row query.
66
+ `CREATE INDEX IF NOT EXISTS idx_share_group_members_subject
67
+ ON ${SHARE_GROUP_MEMBERS_TABLE} (subject_id)`,
68
+ `
69
+ CREATE TABLE IF NOT EXISTS ${SHARE_EVENTS_TABLE} (
70
+ event_id INTEGER PRIMARY KEY AUTOINCREMENT,
71
+ at TEXT NOT NULL,
72
+ action TEXT NOT NULL,
73
+ app TEXT,
74
+ kind TEXT,
75
+ resource_id TEXT,
76
+ subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
77
+ subject_id TEXT NOT NULL,
78
+ level TEXT,
79
+ previous_level TEXT,
80
+ actor TEXT NOT NULL,
81
+ note TEXT NOT NULL DEFAULT ''
82
+ )
83
+ `,
84
+ // "Everything that ever happened to this record", newest first.
85
+ `CREATE INDEX IF NOT EXISTS idx_share_events_resource
86
+ ON ${SHARE_EVENTS_TABLE} (app, kind, resource_id, event_id DESC)`,
87
+ // "Everything this person was ever given" — the offboarding question.
88
+ `CREATE INDEX IF NOT EXISTS idx_share_events_subject
89
+ ON ${SHARE_EVENTS_TABLE} (subject_id, event_id DESC)`,
90
+ ];
@@ -26,7 +26,7 @@
26
26
  * tested with hostile input; a consumer never quotes anything itself.
27
27
  */
28
28
  import { levelsMeeting, normalizeSubjectId, type ShareLevel } from "cursedbelt-core/sharing/model";
29
- import { SHARE_GRANTS_TABLE, SHARE_GROUP_MEMBERS_TABLE } from "./migrations.js";
29
+ import { SHARE_GRANTS_TABLE, SHARE_GROUP_MEMBERS_TABLE } from "./schema.js";
30
30
 
31
31
  /**
32
32
  * Quote a string as a SQL literal.
@@ -0,0 +1,213 @@
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
+ import { SHARE_EVENTS_TABLE, SHARE_GRANTS_TABLE, SHARE_GROUPS_TABLE, SHARE_GROUP_MEMBERS_TABLE } from "./schema.js";
12
+
13
+ export interface GrantRow {
14
+ app: string;
15
+ kind: string;
16
+ resource_id: string;
17
+ subject_type: "user" | "group";
18
+ subject_id: string;
19
+ level: ShareLevel;
20
+ granted_by: string;
21
+ granted_at: string;
22
+ note: string;
23
+ }
24
+
25
+ export interface EventRow {
26
+ event_id: number;
27
+ at: string;
28
+ action: ShareAction;
29
+ app: string | null;
30
+ kind: string | null;
31
+ resource_id: string | null;
32
+ subject_type: "user" | "group";
33
+ subject_id: string;
34
+ level: ShareLevel | null;
35
+ previous_level: ShareLevel | null;
36
+ actor: string;
37
+ note: string;
38
+ }
39
+
40
+ export interface GroupRow {
41
+ group_id: string;
42
+ name: string;
43
+ created_by: string;
44
+ created_at: string;
45
+ note: string;
46
+ }
47
+
48
+ export interface GroupMemberRow {
49
+ group_id: string;
50
+ subject_id: string;
51
+ added_by: string;
52
+ added_at: string;
53
+ }
54
+
55
+ export const toGrant = (row: GrantRow): ShareGrant => ({
56
+ resource: { app: row.app, kind: row.kind, id: row.resource_id },
57
+ subject: { type: row.subject_type, id: row.subject_id },
58
+ level: row.level,
59
+ grantedBy: row.granted_by,
60
+ grantedAt: row.granted_at,
61
+ note: row.note,
62
+ });
63
+
64
+ export const toEvent = (row: EventRow): ShareEvent => ({
65
+ id: row.event_id,
66
+ at: row.at,
67
+ action: row.action,
68
+ resource:
69
+ row.app === null || row.kind === null || row.resource_id === null
70
+ ? null
71
+ : { app: row.app, kind: row.kind, id: row.resource_id },
72
+ subject: { type: row.subject_type, id: row.subject_id },
73
+ level: row.level,
74
+ previousLevel: row.previous_level,
75
+ actor: row.actor,
76
+ note: row.note,
77
+ });
78
+
79
+ /** The audit trail's columns, in the order every event insert binds them. */
80
+ const EVENT_COLUMNS =
81
+ "(at, action, app, kind, resource_id, subject_type, subject_id, level, previous_level, actor, note)";
82
+
83
+ /** One grant row's WHERE — the five columns of its UNIQUE key. */
84
+ const ONE_GRANT = "app = ? AND kind = ? AND resource_id = ? AND subject_type = ? AND subject_id = ?";
85
+ const RESOURCE_GRANTS = "app = ? AND kind = ? AND resource_id = ?";
86
+ const SUBJECT_GRANTS = "app = ? AND subject_type = ? AND subject_id = ?";
87
+ const GROUP_GRANTS = "app = ? AND subject_type = 'group' AND subject_id = ?";
88
+
89
+ /** "This viewer, directly or through any group they are in" — binds `subjectId` twice. */
90
+ const VIEWER_MATCH = `( (subject_type = 'user' AND subject_id = ?)
91
+ OR (subject_type = 'group' AND subject_id IN
92
+ (SELECT group_id FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE subject_id = ?)) )`;
93
+
94
+ export const SHARE_SQL = {
95
+ /** Binds the eleven {@link EVENT_COLUMNS}. */
96
+ insertEvent: `INSERT INTO ${SHARE_EVENTS_TABLE}
97
+ ${EVENT_COLUMNS}
98
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
99
+
100
+ selectGrant: `SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE ${ONE_GRANT}`,
101
+ /** Binds app, kind, resource_id, subject_type, subject_id, level, granted_by, granted_at, note. */
102
+ upsertGrant: `INSERT INTO ${SHARE_GRANTS_TABLE}
103
+ (app, kind, resource_id, subject_type, subject_id, level, granted_by, granted_at, note)
104
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
105
+ ON CONFLICT (app, kind, resource_id, subject_type, subject_id)
106
+ DO UPDATE SET level = excluded.level, granted_by = excluded.granted_by,
107
+ granted_at = excluded.granted_at, note = excluded.note`,
108
+ deleteGrant: `DELETE FROM ${SHARE_GRANTS_TABLE} WHERE ${ONE_GRANT}`,
109
+
110
+ selectResourceGrants: `SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE ${RESOURCE_GRANTS}`,
111
+ deleteResourceGrants: `DELETE FROM ${SHARE_GRANTS_TABLE} WHERE ${RESOURCE_GRANTS}`,
112
+ selectSubjectGrants: `SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE ${SUBJECT_GRANTS}`,
113
+ deleteSubjectGrants: `DELETE FROM ${SHARE_GRANTS_TABLE} WHERE ${SUBJECT_GRANTS}`,
114
+ selectGroupGrants: `SELECT * FROM ${SHARE_GRANTS_TABLE} WHERE ${GROUP_GRANTS}`,
115
+ deleteGroupGrants: `DELETE FROM ${SHARE_GRANTS_TABLE} WHERE ${GROUP_GRANTS}`,
116
+
117
+ listForResource: `SELECT * FROM ${SHARE_GRANTS_TABLE}
118
+ WHERE ${RESOURCE_GRANTS}
119
+ -- Strongest first, then oldest. Spelled as a CASE rather than an
120
+ -- ORDER BY on level, which happens to work only because 'write'
121
+ -- sorts after 'read' and would silently mis-order a third level.
122
+ ORDER BY CASE level WHEN 'write' THEN 0 ELSE 1 END, granted_at ASC`,
123
+ listForSubject: `SELECT * FROM ${SHARE_GRANTS_TABLE}
124
+ WHERE ${SUBJECT_GRANTS}
125
+ ORDER BY granted_at DESC`,
126
+ /** Binds app, kind, resource_id, then the viewer twice. */
127
+ accessFor: `SELECT level FROM ${SHARE_GRANTS_TABLE}
128
+ WHERE ${RESOURCE_GRANTS}
129
+ AND ${VIEWER_MATCH}`,
130
+ /** Binds app, kind, each of `levelCount` levels, then the viewer twice. */
131
+ resourceIdsFor: (levelCount: number): string => `SELECT DISTINCT resource_id FROM ${SHARE_GRANTS_TABLE}
132
+ WHERE app = ? AND kind = ? AND level IN (${Array.from({ length: levelCount }, () => "?").join(", ")})
133
+ AND ${VIEWER_MATCH}
134
+ ORDER BY resource_id`,
135
+
136
+ upsertGroup: `INSERT INTO ${SHARE_GROUPS_TABLE} (group_id, name, created_by, created_at, note)
137
+ VALUES (?, ?, ?, ?, ?)
138
+ ON CONFLICT (group_id) DO UPDATE SET name = excluded.name, note = excluded.note`,
139
+ renameGroup: `UPDATE ${SHARE_GROUPS_TABLE} SET name = ? WHERE group_id = ?`,
140
+ deleteGroup: `DELETE FROM ${SHARE_GROUPS_TABLE} WHERE group_id = ?`,
141
+ listGroups: `SELECT * FROM ${SHARE_GROUPS_TABLE} ORDER BY name`,
142
+ insertMember: `INSERT INTO ${SHARE_GROUP_MEMBERS_TABLE} (group_id, subject_id, added_by, added_at)
143
+ VALUES (?, ?, ?, ?) ON CONFLICT (group_id, subject_id) DO NOTHING`,
144
+ deleteMember: `DELETE FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE group_id = ? AND subject_id = ?`,
145
+ listMembers: `SELECT * FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE group_id = ? ORDER BY subject_id`,
146
+ groupIdsFor: `SELECT group_id FROM ${SHARE_GROUP_MEMBERS_TABLE} WHERE subject_id = ? ORDER BY group_id`,
147
+
148
+ /** The trail, newest first, under a WHERE built by {@link eventsWhere}; binds its params then the limit. */
149
+ events: (where: string): string =>
150
+ `SELECT * FROM ${SHARE_EVENTS_TABLE} WHERE ${where} ORDER BY event_id DESC LIMIT ?`,
151
+
152
+ // ── The D1 store's atomic forms ────────────────────────────────────────────────────────
153
+ // D1 has no interactive transaction, so "read the grant, then decide what to log" cannot
154
+ // be two round trips without a window between them. These write the audit row FROM the
155
+ // grant row inside one `batch()`, so the decision and the write are one atomic step. They
156
+ // are listed here beside the sync forms they must agree with — `d1.spec.ts` runs both
157
+ // stores through one scenario and compares every table.
158
+
159
+ /**
160
+ * The grant's event, read from the grant as it stands BEFORE the upsert that follows it in
161
+ * the same batch: `regrade` (with the old level) when the level changes, `grant` otherwise.
162
+ * Binds at, level, app, kind, resource_id, subject_type, subject_id, level, level, actor,
163
+ * note, then the five {@link ONE_GRANT} keys.
164
+ */
165
+ insertGrantEvent: `INSERT INTO ${SHARE_EVENTS_TABLE} ${EVENT_COLUMNS}
166
+ SELECT ?, CASE WHEN g.level IS NOT NULL AND g.level <> ? THEN 'regrade' ELSE 'grant' END,
167
+ ?, ?, ?, ?, ?, ?,
168
+ CASE WHEN g.level <> ? THEN g.level ELSE NULL END, ?, ?
169
+ FROM (SELECT 1) AS one
170
+ LEFT JOIN ${SHARE_GRANTS_TABLE} AS g ON g.${ONE_GRANT.replaceAll(" AND ", " AND g.")}
171
+ RETURNING *`,
172
+
173
+ /**
174
+ * One `revoke` event per grant matching `where`, oldest grant first — written BEFORE the
175
+ * delete that follows it in the same batch. Binds at, actor, note, then `where`'s params.
176
+ */
177
+ insertRevokeEvents: (where: string): string => `INSERT INTO ${SHARE_EVENTS_TABLE} ${EVENT_COLUMNS}
178
+ SELECT ?, 'revoke', app, kind, resource_id, subject_type, subject_id, level, NULL, ?, ?
179
+ FROM ${SHARE_GRANTS_TABLE} WHERE ${where} ORDER BY grant_id
180
+ RETURNING *`,
181
+ oneGrant: ONE_GRANT,
182
+ resourceGrants: RESOURCE_GRANTS,
183
+ subjectGrants: SUBJECT_GRANTS,
184
+ groupGrants: GROUP_GRANTS,
185
+ } as const;
186
+
187
+ /** The trail's WHERE and its params, bound to one app — the filter both stores apply. */
188
+ export function eventsWhere(
189
+ app: string,
190
+ query: { kind?: string; id?: string; subject?: { type: string; id: string } },
191
+ ): { where: string; params: (string | number)[] } {
192
+ const clauses = ["app IS NULL OR app = ?"];
193
+ const params: (string | number)[] = [app];
194
+ if (query.kind !== undefined) {
195
+ clauses.push("kind = ?");
196
+ params.push(query.kind);
197
+ }
198
+ if (query.id !== undefined) {
199
+ clauses.push("resource_id = ?");
200
+ params.push(query.id);
201
+ }
202
+ if (query.subject) {
203
+ clauses.push("subject_type = ? AND subject_id = ?");
204
+ params.push(query.subject.type, query.subject.id);
205
+ }
206
+ return {
207
+ where: clauses.map((clause) => `(${clause})`).join(" AND "),
208
+ params,
209
+ };
210
+ }
211
+
212
+ /** Newest first, capped: default 200, never more than 1000, never less than 1. */
213
+ export const eventLimit = (limit: number | undefined): number => Math.max(1, Math.min(limit ?? 200, 1000));