cursedbelt-server 4.27.0 → 4.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/server/activity/d1.d.ts +70 -0
  2. package/dist/server/activity/d1.js +68 -0
  3. package/dist/server/activity/migrations.d.ts +1 -3
  4. package/dist/server/activity/migrations.js +7 -32
  5. package/dist/server/activity/query.d.ts +123 -0
  6. package/dist/server/activity/query.js +171 -0
  7. package/dist/server/activity/schema.d.ts +13 -0
  8. package/dist/server/activity/schema.js +43 -0
  9. package/dist/server/activity/shareSource.d.ts +1 -1
  10. package/dist/server/activity/shareSource.js +1 -1
  11. package/dist/server/activity/store.d.ts +4 -62
  12. package/dist/server/activity/store.js +14 -145
  13. package/dist/server/metrics/metricsBuffer.d.ts +0 -2
  14. package/dist/server/metrics/metricsBuffer.js +5 -2
  15. package/dist/server/metrics/requestPlace.d.ts +8 -0
  16. package/dist/server/metrics/requestPlace.js +8 -0
  17. package/dist/server/notifications/d1.d.ts +80 -0
  18. package/dist/server/notifications/d1.js +226 -0
  19. package/dist/server/notifications/service.d.ts +2 -20
  20. package/dist/server/notifications/types.d.ts +27 -0
  21. package/dist/server/notifications/types.js +1 -0
  22. package/dist/server/requestLog/requestMetrics.js +1 -1
  23. package/dist/server/sharing/contract.d.ts +94 -0
  24. package/dist/server/sharing/contract.js +3 -0
  25. package/dist/server/sharing/d1.d.ts +77 -0
  26. package/dist/server/sharing/d1.js +309 -0
  27. package/dist/server/sharing/index.d.ts +1 -0
  28. package/dist/server/sharing/migrations.d.ts +1 -4
  29. package/dist/server/sharing/migrations.js +7 -72
  30. package/dist/server/sharing/router.d.ts +8 -4
  31. package/dist/server/sharing/router.js +7 -5
  32. package/dist/server/sharing/schema.d.ts +19 -0
  33. package/dist/server/sharing/schema.js +88 -0
  34. package/dist/server/sharing/sql.js +1 -1
  35. package/dist/server/sharing/statements.d.ts +116 -0
  36. package/dist/server/sharing/statements.js +139 -0
  37. package/dist/server/sharing/store.d.ts +3 -28
  38. package/dist/server/sharing/store.js +32 -105
  39. package/dist/server/telemetry.d.ts +1 -1
  40. package/dist/server/telemetry.js +1 -1
  41. package/docs/activity.md +7 -0
  42. package/docs/notifications.md +9 -0
  43. package/package.json +19 -1
  44. package/src/server/activity/d1.spec.ts +169 -0
  45. package/src/server/activity/d1.ts +172 -0
  46. package/src/server/activity/migrations.ts +6 -40
  47. package/src/server/activity/query.ts +283 -0
  48. package/src/server/activity/schema.ts +45 -0
  49. package/src/server/activity/shareSource.ts +2 -2
  50. package/src/server/activity/store.ts +45 -257
  51. package/src/server/metrics/metricsBuffer.ts +5 -2
  52. package/src/server/metrics/requestPlace.ts +9 -0
  53. package/src/server/notifications/d1.spec.ts +179 -0
  54. package/src/server/notifications/d1.ts +334 -0
  55. package/src/server/notifications/service.ts +7 -23
  56. package/src/server/notifications/types.ts +29 -0
  57. package/src/server/requestLog/requestMetrics.ts +1 -1
  58. package/src/server/sharing/contract.ts +109 -0
  59. package/src/server/sharing/d1.spec.ts +242 -0
  60. package/src/server/sharing/d1.ts +430 -0
  61. package/src/server/sharing/index.ts +3 -0
  62. package/src/server/sharing/migrations.ts +12 -85
  63. package/src/server/sharing/router.ts +21 -11
  64. package/src/server/sharing/schema.ts +90 -0
  65. package/src/server/sharing/sql.ts +1 -1
  66. package/src/server/sharing/statements.ts +213 -0
  67. package/src/server/sharing/store.ts +46 -221
  68. package/src/server/telemetry.ts +1 -0
  69. package/src/server/telemetryIsWorkerSafe.spec.ts +54 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The store's contract with no driver attached: the input shapes both stores take, the
3
+ * {@link AsyncShareStore} a D1-backed store satisfies, and the two subject helpers.
4
+ *
5
+ * It lives apart from `./store.ts` so that `./d1.ts` — which runs on `workerd` — and its
6
+ * declaration file never reach a module that names `bun:sqlite`, not even as a type. The
7
+ * sync store re-exports everything here, so `cursedbelt-server/sharing` is unchanged.
8
+ */
9
+ import type { ShareAccess, ShareEvent, ShareGrant, ShareGroup, ShareGroupMember, ShareLevel, ShareSubject, ShareViewer } from "cursedbelt-core/sharing/model";
10
+ /** Shorthand for the common subject. `group(id)` is its sibling. */
11
+ export declare const user: (id: string) => ShareSubject;
12
+ export declare const group: (id: string) => ShareSubject;
13
+ /** A resource within the store's own app — the `app` half is supplied by the
14
+ * store, so a caller cannot mistakenly write a grant into another app's rows. */
15
+ export interface ShareTarget {
16
+ kind: string;
17
+ id: string;
18
+ }
19
+ export interface GrantInput extends ShareTarget {
20
+ subject: ShareSubject;
21
+ level: ShareLevel;
22
+ grantedBy: string;
23
+ note?: string;
24
+ }
25
+ export interface RevokeInput extends ShareTarget {
26
+ subject: ShareSubject;
27
+ revokedBy: string;
28
+ note?: string;
29
+ }
30
+ export interface ShareEventQuery {
31
+ kind?: string;
32
+ id?: string;
33
+ subject?: ShareSubject;
34
+ /** Newest first, capped. Default 200 — a panel shows a page, and an unbounded
35
+ * audit read on a busy resource is the query that times out in production. */
36
+ limit?: number;
37
+ }
38
+ /** What `sharedSql` takes — the store supplies `app`. */
39
+ export interface SharedSqlInput {
40
+ kind: string;
41
+ subjectId: string;
42
+ column: string;
43
+ atLeast?: ShareLevel;
44
+ }
45
+ /**
46
+ * The share store over an ASYNC database — `createD1ShareStore` in
47
+ * `cursedbelt-server/sharing/d1`.
48
+ *
49
+ * The same methods as the sync `ShareStore`, with the same names, arguments and meaning;
50
+ * every method that touches the database resolves instead of returning. `sharedSql` stays
51
+ * synchronous because it touches nothing: it emits the SAME fragment the sync store does, for
52
+ * an app to embed in its own D1 queries. `sharing/d1.spec.ts` checks the two stores expose
53
+ * exactly the same method names, so neither can grow a verb the other lacks.
54
+ */
55
+ export interface AsyncShareStore {
56
+ /** The app this store speaks for. */
57
+ readonly app: string;
58
+ grant(input: GrantInput): Promise<ShareGrant>;
59
+ revoke(input: RevokeInput): Promise<ShareLevel | null>;
60
+ revokeResource(target: ShareTarget, revokedBy: string): Promise<number>;
61
+ revokeSubject(subject: ShareSubject, revokedBy: string): Promise<number>;
62
+ listForResource(target: ShareTarget): Promise<ShareGrant[]>;
63
+ listForSubject(subject: ShareSubject): Promise<ShareGrant[]>;
64
+ accessFor(target: ShareTarget, viewer: ShareViewer): Promise<ShareAccess>;
65
+ resourceIdsFor(kind: string, viewer: ShareViewer, atLeast?: ShareLevel): Promise<string[]>;
66
+ sharedSql(input: SharedSqlInput): string;
67
+ createGroup(input: {
68
+ id: string;
69
+ name: string;
70
+ createdBy: string;
71
+ note?: string;
72
+ }): Promise<ShareGroup>;
73
+ renameGroup(id: string, name: string, actor: string): Promise<void>;
74
+ deleteGroup(id: string, actor: string): Promise<void>;
75
+ listGroups(): Promise<ShareGroup[]>;
76
+ addGroupMember(groupId: string, subjectId: string, actor: string): Promise<void>;
77
+ removeGroupMember(groupId: string, subjectId: string, actor: string): Promise<void>;
78
+ listGroupMembers(groupId: string): Promise<ShareGroupMember[]>;
79
+ groupIdsFor(subjectId: string): Promise<string[]>;
80
+ events(query?: ShareEventQuery): Promise<ShareEvent[]>;
81
+ }
82
+ /**
83
+ * A value that may or may not be a promise — what a consumer holding EITHER store gets back
84
+ * from a call, and what it can always `await`.
85
+ */
86
+ export type Awaitable<T> = T | Promise<T>;
87
+ /**
88
+ * Either store, as a consumer that works over both sees it: every method's result is
89
+ * {@link Awaitable}. `mountShareRoutes` takes this, and awaits every call — awaiting a
90
+ * synchronous value is a no-op, so the sync store's behaviour through it is unchanged.
91
+ */
92
+ export type AwaitableShareStore = {
93
+ readonly [K in keyof AsyncShareStore]: AsyncShareStore[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => Awaitable<R> : AsyncShareStore[K];
94
+ };
@@ -0,0 +1,3 @@
1
+ /** Shorthand for the common subject. `group(id)` is its sibling. */
2
+ export const user = (id) => ({ type: "user", id });
3
+ export const group = (id) => ({ type: "group", id });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `cursedbelt-server/sharing/d1` — the fleet's sharing model over D1, for an app a Worker serves.
3
+ *
4
+ * ── Why it exists ───────────────────────────────────────────────────────────
5
+ * `./store.ts` is synchronous `bun:sqlite`, and a Worker has neither. `apps/family`'s port to
6
+ * a Worker + D1 (task 393) needs the SAME grants, revokes, groups and audit trail, in the SAME
7
+ * four tables, so a database moved from the Mac to D1 keeps meaning what it meant. This is that
8
+ * store, beside the sync one — which stays exactly as it was, because `auth` and `station` are
9
+ * still Bun servers.
10
+ *
11
+ * ── What is the same, and what is not ─────────────────────────────────────
12
+ * Same methods, names, arguments, validation and results as `ShareStore`; every method that
13
+ * touches the database resolves instead of returning. `sharedSql` is still synchronous and
14
+ * emits the identical fragment, for an app to embed in its own D1 queries. The SQL is the
15
+ * sync store's own (`./statements.ts`), and `./d1.spec.ts` runs both stores through one
16
+ * scenario and compares every call's result and every table's rows.
17
+ *
18
+ * The four properties of `./store.ts`'s header hold, by different means:
19
+ * 1. **Every mutation writes its audit row in the same transaction.** D1 has no interactive
20
+ * transaction, so each mutation is ONE `db.batch([...])` — atomic on D1, a real
21
+ * transaction locally. Where the sync store reads the grant and then decides what to log,
22
+ * this store logs FROM the grant row inside that batch (`INSERT … SELECT … RETURNING`),
23
+ * so there is no window between the read and the write for another request to use.
24
+ * 2. **A revoked grant's history survives it** — the event insert runs before the delete.
25
+ * 3. **`revokeResource` is one call** — two statements, one batch, however many grants.
26
+ * 4. **The hook cannot break a write.** `onEvent` runs after the batch commits; a throw — or
27
+ * a rejected promise, which a Worker's hook may well return — is caught and warned.
28
+ *
29
+ * Differences a caller can observe, each deliberate:
30
+ * · A multi-grant revoke (`revokeResource`, `revokeSubject`, `deleteGroup`) writes its
31
+ * events oldest-grant-first (`ORDER BY grant_id`); the sync store writes them in whatever
32
+ * order its SELECT happened to return. Same events, a defined order.
33
+ * · `onEvent` may return a promise, and the store AWAITS it before resolving — a Worker that
34
+ * returned before the hook settled could have it cut off with the isolate.
35
+ * · Validation failures REJECT rather than throw synchronously (they are async methods).
36
+ * · 🔴 No DDL runs here, ever. The sync store migrates on construction; on D1 the schema is
37
+ * the app's `db/schema.sql`, built from {@link SHARE_DDL} (the exact statements the sync
38
+ * migration runs), and applied by `wrangler d1 migrations`. A request-time `CREATE TABLE`
39
+ * is a write per invocation against the 1,000-query budget, for nothing.
40
+ *
41
+ * 🔴 Worker-safe: nothing reachable from here at runtime imports `bun:*`
42
+ * (`../telemetryIsWorkerSafe.spec.ts` walks it). Do not import `./store.js` or
43
+ * `./migrations.js` here — `./schema.js`, `./statements.js` and `./contract.js` are the
44
+ * driver-free halves they were split into.
45
+ *
46
+ * ```ts
47
+ * import { createD1ShareStore } from "cursedbelt-server/sharing/d1";
48
+ *
49
+ * const shares = createD1ShareStore({ db: createRemoteD1(env.DB), app: "family", onEvent });
50
+ * await shares.grant({ kind: "person", id, subject: user(email), level: "read", grantedBy });
51
+ * const where = shares.sharedSql({ kind: "person", subjectId: viewer.email, column: "p.id" });
52
+ * ```
53
+ */
54
+ import { type ShareEvent } from "cursedbelt-core/sharing/model";
55
+ import type { D1LikeDatabase } from "../d1/types.js";
56
+ import type { AsyncShareStore } from "./contract.js";
57
+ export { group, user } from "./contract.js";
58
+ export type { AsyncShareStore, Awaitable, AwaitableShareStore, GrantInput, RevokeInput, SharedSqlInput, ShareEventQuery, ShareTarget, } from "./contract.js";
59
+ export { SHARE_DDL, SHARE_EVENTS_TABLE, SHARE_GRANTS_TABLE, SHARE_GROUPS_TABLE, SHARE_GROUP_MEMBERS_TABLE, } from "./schema.js";
60
+ export interface D1ShareStoreOptions {
61
+ /** The app's D1 handle — `createRemoteD1(env.DB)` on a Worker, `createLocalD1(sqlite)` in a test. */
62
+ db: D1LikeDatabase;
63
+ /** The satellite this store speaks for. Fixed at construction, as in the sync store. */
64
+ app: string;
65
+ /**
66
+ * Notified after every committed mutation, as in the sync store. May return a promise; the
67
+ * store awaits it, and neither a throw nor a rejection can undo the write.
68
+ */
69
+ onEvent?: (event: ShareEvent) => void | Promise<void>;
70
+ /** Injectable clock, for tests that assert ordering. ISO-8601 strings. */
71
+ now?: () => string;
72
+ }
73
+ /**
74
+ * The share store over D1. Runs no DDL — see the header; the tables come from
75
+ * {@link SHARE_DDL} in the app's schema.
76
+ */
77
+ export declare function createD1ShareStore(options: D1ShareStoreOptions): AsyncShareStore;
@@ -0,0 +1,309 @@
1
+ /**
2
+ * `cursedbelt-server/sharing/d1` — the fleet's sharing model over D1, for an app a Worker serves.
3
+ *
4
+ * ── Why it exists ───────────────────────────────────────────────────────────
5
+ * `./store.ts` is synchronous `bun:sqlite`, and a Worker has neither. `apps/family`'s port to
6
+ * a Worker + D1 (task 393) needs the SAME grants, revokes, groups and audit trail, in the SAME
7
+ * four tables, so a database moved from the Mac to D1 keeps meaning what it meant. This is that
8
+ * store, beside the sync one — which stays exactly as it was, because `auth` and `station` are
9
+ * still Bun servers.
10
+ *
11
+ * ── What is the same, and what is not ─────────────────────────────────────
12
+ * Same methods, names, arguments, validation and results as `ShareStore`; every method that
13
+ * touches the database resolves instead of returning. `sharedSql` is still synchronous and
14
+ * emits the identical fragment, for an app to embed in its own D1 queries. The SQL is the
15
+ * sync store's own (`./statements.ts`), and `./d1.spec.ts` runs both stores through one
16
+ * scenario and compares every call's result and every table's rows.
17
+ *
18
+ * The four properties of `./store.ts`'s header hold, by different means:
19
+ * 1. **Every mutation writes its audit row in the same transaction.** D1 has no interactive
20
+ * transaction, so each mutation is ONE `db.batch([...])` — atomic on D1, a real
21
+ * transaction locally. Where the sync store reads the grant and then decides what to log,
22
+ * this store logs FROM the grant row inside that batch (`INSERT … SELECT … RETURNING`),
23
+ * so there is no window between the read and the write for another request to use.
24
+ * 2. **A revoked grant's history survives it** — the event insert runs before the delete.
25
+ * 3. **`revokeResource` is one call** — two statements, one batch, however many grants.
26
+ * 4. **The hook cannot break a write.** `onEvent` runs after the batch commits; a throw — or
27
+ * a rejected promise, which a Worker's hook may well return — is caught and warned.
28
+ *
29
+ * Differences a caller can observe, each deliberate:
30
+ * · A multi-grant revoke (`revokeResource`, `revokeSubject`, `deleteGroup`) writes its
31
+ * events oldest-grant-first (`ORDER BY grant_id`); the sync store writes them in whatever
32
+ * order its SELECT happened to return. Same events, a defined order.
33
+ * · `onEvent` may return a promise, and the store AWAITS it before resolving — a Worker that
34
+ * returned before the hook settled could have it cut off with the isolate.
35
+ * · Validation failures REJECT rather than throw synchronously (they are async methods).
36
+ * · 🔴 No DDL runs here, ever. The sync store migrates on construction; on D1 the schema is
37
+ * the app's `db/schema.sql`, built from {@link SHARE_DDL} (the exact statements the sync
38
+ * migration runs), and applied by `wrangler d1 migrations`. A request-time `CREATE TABLE`
39
+ * is a write per invocation against the 1,000-query budget, for nothing.
40
+ *
41
+ * 🔴 Worker-safe: nothing reachable from here at runtime imports `bun:*`
42
+ * (`../telemetryIsWorkerSafe.spec.ts` walks it). Do not import `./store.js` or
43
+ * `./migrations.js` here — `./schema.js`, `./statements.js` and `./contract.js` are the
44
+ * driver-free halves they were split into.
45
+ *
46
+ * ```ts
47
+ * import { createD1ShareStore } from "cursedbelt-server/sharing/d1";
48
+ *
49
+ * const shares = createD1ShareStore({ db: createRemoteD1(env.DB), app: "family", onEvent });
50
+ * await shares.grant({ kind: "person", id, subject: user(email), level: "read", grantedBy });
51
+ * const where = shares.sharedSql({ kind: "person", subjectId: viewer.email, column: "p.id" });
52
+ * ```
53
+ */
54
+ import { levelsMeeting, normalizeSubjectId, strongerAccess, } from "cursedbelt-core/sharing/model";
55
+ import { sharedResourceSql } from "./sql.js";
56
+ import { eventLimit, eventsWhere, SHARE_SQL, toEvent, toGrant, } from "./statements.js";
57
+ export { group, user } from "./contract.js";
58
+ export { SHARE_DDL, SHARE_EVENTS_TABLE, SHARE_GRANTS_TABLE, SHARE_GROUPS_TABLE, SHARE_GROUP_MEMBERS_TABLE, } from "./schema.js";
59
+ /**
60
+ * The share store over D1. Runs no DDL — see the header; the tables come from
61
+ * {@link SHARE_DDL} in the app's schema.
62
+ */
63
+ export function createD1ShareStore(options) {
64
+ const { db, app } = options;
65
+ const clock = options.now ?? (() => new Date().toISOString());
66
+ if (!app.trim())
67
+ throw new Error("[cursedbelt-core/sharing] a share store needs an `app`");
68
+ const emit = async (events) => {
69
+ const hook = options.onEvent;
70
+ if (!hook)
71
+ return;
72
+ for (const event of events) {
73
+ try {
74
+ await hook(event);
75
+ }
76
+ catch (error) {
77
+ console.warn(`[cursedbelt-core/sharing] a share-event listener threw: ${String(error)}`);
78
+ }
79
+ }
80
+ };
81
+ const stmt = (sql, ...values) => db.prepare(sql).bind(...values);
82
+ const rows = async (sql, ...values) => (await stmt(sql, ...values).all()).results;
83
+ /** Run a mutation's statements as ONE atomic batch, and read back the events it wrote. */
84
+ const commit = async (statements) => {
85
+ const results = await db.batch(statements);
86
+ const events = results
87
+ .flatMap((result) => result.results)
88
+ .filter((row) => typeof row.event_id === "number")
89
+ .sort((a, b) => a.event_id - b.event_id)
90
+ .map(toEvent);
91
+ await emit(events);
92
+ return events;
93
+ };
94
+ /** A plain event insert, for the mutations whose audit row does not depend on a read. */
95
+ const eventInsert = (input) => stmt(`${SHARE_SQL.insertEvent} RETURNING *`, input.at, input.action, input.resource?.app ?? null, input.resource?.kind ?? null, input.resource?.id ?? null, input.subject.type, input.subject.id, input.level, null, input.actor, input.note ?? "");
96
+ const revokeEvents = (where, at, actor, note, ...keys) => stmt(SHARE_SQL.insertRevokeEvents(where), at, actor, note, ...keys);
97
+ const normalized = (subject) => ({
98
+ type: subject.type,
99
+ id: normalizeSubjectId(subject.type, subject.id),
100
+ });
101
+ return {
102
+ app,
103
+ async grant(input) {
104
+ const subject = normalized(input.subject);
105
+ if (!subject.id)
106
+ throw new Error("[cursedbelt-core/sharing] a grant needs a subject");
107
+ if (!input.id)
108
+ throw new Error("[cursedbelt-core/sharing] a grant needs a resource id");
109
+ const at = clock();
110
+ const note = input.note ?? "";
111
+ const key = [app, input.kind, input.id, subject.type, subject.id];
112
+ await commit([
113
+ // The event FIRST: it reads the grant as it stood, to tell a grant from a regrade.
114
+ stmt(SHARE_SQL.insertGrantEvent, at, input.level, ...key, input.level, input.level, input.grantedBy, note, ...key),
115
+ stmt(SHARE_SQL.upsertGrant, ...key, input.level, input.grantedBy, at, note),
116
+ ]);
117
+ return {
118
+ resource: { app, kind: input.kind, id: input.id },
119
+ subject,
120
+ level: input.level,
121
+ grantedBy: input.grantedBy,
122
+ grantedAt: at,
123
+ note,
124
+ };
125
+ },
126
+ async revoke(input) {
127
+ const subject = normalized(input.subject);
128
+ const at = clock();
129
+ const key = [app, input.kind, input.id, subject.type, subject.id];
130
+ const [event] = await commit([
131
+ revokeEvents(SHARE_SQL.oneGrant, at, input.revokedBy, input.note ?? "", ...key),
132
+ stmt(SHARE_SQL.deleteGrant, ...key),
133
+ ]);
134
+ // No grant, no event — "revoked nothing" is not logged, exactly as in the sync store.
135
+ return event?.level ?? null;
136
+ },
137
+ async revokeResource(target, revokedBy) {
138
+ const at = clock();
139
+ const key = [app, target.kind, target.id];
140
+ const events = await commit([
141
+ revokeEvents(SHARE_SQL.resourceGrants, at, revokedBy, "the record was deleted", ...key),
142
+ stmt(SHARE_SQL.deleteResourceGrants, ...key),
143
+ ]);
144
+ return events.length;
145
+ },
146
+ async revokeSubject(subject, revokedBy) {
147
+ const normal = normalized(subject);
148
+ const at = clock();
149
+ const key = [app, normal.type, normal.id];
150
+ const events = await commit([
151
+ revokeEvents(SHARE_SQL.subjectGrants, at, revokedBy, "every share with this subject was revoked", ...key),
152
+ stmt(SHARE_SQL.deleteSubjectGrants, ...key),
153
+ ]);
154
+ return events.length;
155
+ },
156
+ async listForResource(target) {
157
+ return (await rows(SHARE_SQL.listForResource, app, target.kind, target.id)).map(toGrant);
158
+ },
159
+ async listForSubject(subject) {
160
+ const normal = normalized(subject);
161
+ return (await rows(SHARE_SQL.listForSubject, app, normal.type, normal.id)).map(toGrant);
162
+ },
163
+ async accessFor(target, viewer) {
164
+ const subjectId = normalizeSubjectId("user", viewer.subjectId);
165
+ if (!subjectId)
166
+ return "none";
167
+ const found = await rows(SHARE_SQL.accessFor, app, target.kind, target.id, subjectId, subjectId);
168
+ let access = "none";
169
+ for (const row of found)
170
+ access = strongerAccess(access, row.level);
171
+ return access;
172
+ },
173
+ async resourceIdsFor(kind, viewer, atLeast = "read") {
174
+ const subjectId = normalizeSubjectId("user", viewer.subjectId);
175
+ if (!subjectId)
176
+ return [];
177
+ const levels = levelsMeeting(atLeast);
178
+ const found = await rows(SHARE_SQL.resourceIdsFor(levels.length), app, kind, ...levels, subjectId, subjectId);
179
+ return found.map((row) => row.resource_id);
180
+ },
181
+ sharedSql(input) {
182
+ return sharedResourceSql({ app, ...input });
183
+ },
184
+ // ── Groups ───────────────────────────────────────────────────────────────
185
+ async createGroup(input) {
186
+ const id = normalizeSubjectId("group", input.id);
187
+ if (!id)
188
+ throw new Error("[cursedbelt-core/sharing] a group needs an id");
189
+ const at = clock();
190
+ await commit([
191
+ stmt(SHARE_SQL.upsertGroup, id, input.name, input.createdBy, at, input.note ?? ""),
192
+ eventInsert({
193
+ action: "group.create",
194
+ resource: null,
195
+ subject: { type: "group", id },
196
+ level: null,
197
+ actor: input.createdBy,
198
+ note: input.name,
199
+ at,
200
+ }),
201
+ ]);
202
+ return { id, name: input.name, createdBy: input.createdBy, createdAt: at, note: input.note ?? "" };
203
+ },
204
+ async renameGroup(id, name, actor) {
205
+ const groupId = normalizeSubjectId("group", id);
206
+ const at = clock();
207
+ await commit([
208
+ stmt(SHARE_SQL.renameGroup, name, groupId),
209
+ eventInsert({
210
+ action: "group.rename",
211
+ resource: null,
212
+ subject: { type: "group", id: groupId },
213
+ level: null,
214
+ actor,
215
+ note: name,
216
+ at,
217
+ }),
218
+ ]);
219
+ },
220
+ /** Deletes the group AND every grant made to it, each with its own revoke event — see
221
+ * the sync store's `deleteGroup` for why that is not optional. */
222
+ async deleteGroup(id, actor) {
223
+ const groupId = normalizeSubjectId("group", id);
224
+ const at = clock();
225
+ await commit([
226
+ revokeEvents(SHARE_SQL.groupGrants, at, actor, "the group was deleted", app, groupId),
227
+ stmt(SHARE_SQL.deleteGroupGrants, app, groupId),
228
+ stmt(SHARE_SQL.deleteGroup, groupId),
229
+ eventInsert({
230
+ action: "group.delete",
231
+ resource: null,
232
+ subject: { type: "group", id: groupId },
233
+ level: null,
234
+ actor,
235
+ note: "",
236
+ at,
237
+ }),
238
+ ]);
239
+ },
240
+ async listGroups() {
241
+ return (await rows(SHARE_SQL.listGroups)).map((row) => ({
242
+ id: row.group_id,
243
+ name: row.name,
244
+ createdBy: row.created_by,
245
+ createdAt: row.created_at,
246
+ note: row.note,
247
+ }));
248
+ },
249
+ async addGroupMember(groupId, subjectId, actor) {
250
+ const id = normalizeSubjectId("group", groupId);
251
+ const member = normalizeSubjectId("user", subjectId);
252
+ if (!member)
253
+ throw new Error("[cursedbelt-core/sharing] a group member needs an address");
254
+ const at = clock();
255
+ await commit([
256
+ stmt(SHARE_SQL.insertMember, id, member, actor, at),
257
+ eventInsert({
258
+ action: "group.join",
259
+ resource: null,
260
+ subject: { type: "group", id },
261
+ level: null,
262
+ actor,
263
+ note: member,
264
+ at,
265
+ }),
266
+ ]);
267
+ },
268
+ async removeGroupMember(groupId, subjectId, actor) {
269
+ const id = normalizeSubjectId("group", groupId);
270
+ const member = normalizeSubjectId("user", subjectId);
271
+ const at = clock();
272
+ await commit([
273
+ stmt(SHARE_SQL.deleteMember, id, member),
274
+ eventInsert({
275
+ action: "group.leave",
276
+ resource: null,
277
+ subject: { type: "group", id },
278
+ level: null,
279
+ actor,
280
+ note: member,
281
+ at,
282
+ }),
283
+ ]);
284
+ },
285
+ async listGroupMembers(groupId) {
286
+ const found = await rows(SHARE_SQL.listMembers, normalizeSubjectId("group", groupId));
287
+ return found.map((row) => ({
288
+ groupId: row.group_id,
289
+ subjectId: row.subject_id,
290
+ addedBy: row.added_by,
291
+ addedAt: row.added_at,
292
+ }));
293
+ },
294
+ async groupIdsFor(subjectId) {
295
+ const member = normalizeSubjectId("user", subjectId);
296
+ if (!member)
297
+ return [];
298
+ return (await rows(SHARE_SQL.groupIdsFor, member)).map((row) => row.group_id);
299
+ },
300
+ // ── The audit trail ──────────────────────────────────────────────────────
301
+ async events(query = {}) {
302
+ const { where, params } = eventsWhere(app, {
303
+ ...query,
304
+ subject: query.subject ? normalized(query.subject) : undefined,
305
+ });
306
+ return (await rows(SHARE_SQL.events(where), ...params, eventLimit(query.limit))).map(toEvent);
307
+ },
308
+ };
309
+ }
@@ -13,6 +13,7 @@ export type { ShareRouterOptions, ShareRouterViewer, ShareRoutesPayload } from "
13
13
  export { sharedResourceSql, sqlColumn, sqlLiteral } from "./sql.js";
14
14
  export type { SharedResourceSqlOptions } from "./sql.js";
15
15
  export { createShareStore, group, user } from "./store.js";
16
+ export type { AsyncShareStore, AwaitableShareStore } from "./contract.js";
16
17
  export type { GrantInput, RevokeInput, ShareEventQuery, ShareStore, ShareStoreOptions, ShareTarget, } from "./store.js";
17
18
  export { accessMeets, describeAccess, describeShareEvent, isShareAccess, isShareLevel, isShareSubjectType, levelsMeeting, normalizeSubject, normalizeSubjectId, resolveAccess, resourceKey, sameResource, sameSubject, SHARE_ACTIONS, strongerAccess, } from "cursedbelt-core/sharing/model";
18
19
  export type { ShareAccess, ShareAction, ShareEvent, ShareGrant, ShareGroup, ShareGroupMember, ShareLevel, ShareResource, ShareSubject, ShareSubjectType, ShareViewer, } from "cursedbelt-core/sharing/model";
@@ -1,6 +1,3 @@
1
1
  import type { Migration } from "../migration/types.js";
2
- export declare const SHARE_GRANTS_TABLE = "share_grants";
3
- export declare const SHARE_GROUPS_TABLE = "share_groups";
4
- export declare const SHARE_GROUP_MEMBERS_TABLE = "share_group_members";
5
- export declare const SHARE_EVENTS_TABLE = "share_events";
2
+ export { SHARE_DDL, SHARE_EVENTS_TABLE, SHARE_GRANTS_TABLE, SHARE_GROUPS_TABLE, SHARE_GROUP_MEMBERS_TABLE, } from "./schema.js";
6
3
  export declare const SHARE_MIGRATIONS: readonly Migration[];
@@ -1,79 +1,14 @@
1
- export const SHARE_GRANTS_TABLE = "share_grants";
2
- export const SHARE_GROUPS_TABLE = "share_groups";
3
- export const SHARE_GROUP_MEMBERS_TABLE = "share_group_members";
4
- export const SHARE_EVENTS_TABLE = "share_events";
1
+ import { SHARE_DDL } from "./schema.js";
2
+ // The names and the DDL live in `./schema.ts`, driver-free, so a D1 app builds its schema file
3
+ // from the SAME statements this migration runs (`cursedbelt-server/sharing/d1` re-exports
4
+ // them). Re-exported here so every existing import of this module keeps working.
5
+ export { SHARE_DDL, SHARE_EVENTS_TABLE, SHARE_GRANTS_TABLE, SHARE_GROUPS_TABLE, SHARE_GROUP_MEMBERS_TABLE, } from "./schema.js";
5
6
  export const SHARE_MIGRATIONS = [
6
7
  {
7
8
  name: "0001_share_grants",
8
9
  up(db) {
9
- db.run(`
10
- CREATE TABLE IF NOT EXISTS ${SHARE_GRANTS_TABLE} (
11
- grant_id INTEGER PRIMARY KEY AUTOINCREMENT,
12
- app TEXT NOT NULL,
13
- kind TEXT NOT NULL,
14
- resource_id TEXT NOT NULL,
15
- subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
16
- subject_id TEXT NOT NULL,
17
- level TEXT NOT NULL CHECK (level IN ('read', 'write')),
18
- granted_by TEXT NOT NULL,
19
- granted_at TEXT NOT NULL,
20
- note TEXT NOT NULL DEFAULT '',
21
- -- One grant per subject per resource. This is what makes raising
22
- -- read to write an UPDATE the audit trail can describe, instead of
23
- -- two rows where revoking one leaves access in place.
24
- UNIQUE (app, kind, resource_id, subject_type, subject_id)
25
- )
26
- `);
27
- // The hot read is the resolver's: "does this subject hold a grant on this
28
- // row" — correlated per row of a list query, so it must be an index hit.
29
- db.run(`CREATE INDEX IF NOT EXISTS idx_share_grants_subject
30
- ON ${SHARE_GRANTS_TABLE} (subject_type, subject_id, app, kind, resource_id)`);
31
- // …and the panel's: "who can see this record".
32
- db.run(`CREATE INDEX IF NOT EXISTS idx_share_grants_resource
33
- ON ${SHARE_GRANTS_TABLE} (app, kind, resource_id)`);
34
- db.run(`
35
- CREATE TABLE IF NOT EXISTS ${SHARE_GROUPS_TABLE} (
36
- group_id TEXT PRIMARY KEY,
37
- name TEXT NOT NULL,
38
- created_by TEXT NOT NULL,
39
- created_at TEXT NOT NULL,
40
- note TEXT NOT NULL DEFAULT ''
41
- )
42
- `);
43
- db.run(`
44
- CREATE TABLE IF NOT EXISTS ${SHARE_GROUP_MEMBERS_TABLE} (
45
- group_id TEXT NOT NULL REFERENCES ${SHARE_GROUPS_TABLE} (group_id) ON DELETE CASCADE,
46
- subject_id TEXT NOT NULL,
47
- added_by TEXT NOT NULL,
48
- added_at TEXT NOT NULL,
49
- PRIMARY KEY (group_id, subject_id)
50
- )
51
- `);
52
- // "Which groups is this person in" — read by every shared-row query.
53
- db.run(`CREATE INDEX IF NOT EXISTS idx_share_group_members_subject
54
- ON ${SHARE_GROUP_MEMBERS_TABLE} (subject_id)`);
55
- db.run(`
56
- CREATE TABLE IF NOT EXISTS ${SHARE_EVENTS_TABLE} (
57
- event_id INTEGER PRIMARY KEY AUTOINCREMENT,
58
- at TEXT NOT NULL,
59
- action TEXT NOT NULL,
60
- app TEXT,
61
- kind TEXT,
62
- resource_id TEXT,
63
- subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
64
- subject_id TEXT NOT NULL,
65
- level TEXT,
66
- previous_level TEXT,
67
- actor TEXT NOT NULL,
68
- note TEXT NOT NULL DEFAULT ''
69
- )
70
- `);
71
- // "Everything that ever happened to this record", newest first.
72
- db.run(`CREATE INDEX IF NOT EXISTS idx_share_events_resource
73
- ON ${SHARE_EVENTS_TABLE} (app, kind, resource_id, event_id DESC)`);
74
- // "Everything this person was ever given" — the offboarding question.
75
- db.run(`CREATE INDEX IF NOT EXISTS idx_share_events_subject
76
- ON ${SHARE_EVENTS_TABLE} (subject_id, event_id DESC)`);
10
+ for (const statement of SHARE_DDL)
11
+ db.run(statement);
77
12
  },
78
13
  },
79
14
  ];
@@ -44,7 +44,7 @@
44
44
  */
45
45
  import type { Context, Hono } from 'hono';
46
46
  import type { ShareEvent, ShareGrant } from 'cursedbelt-core/sharing/model';
47
- import type { ShareStore } from './store.js';
47
+ import type { AwaitableShareStore } from './contract.js';
48
48
  /** The `group:` prefix that lets a group subject travel in a path segment. Exported so a
49
49
  * client and a route cannot disagree about it. */
50
50
  export declare const GROUP_SUBJECT_PREFIX = "group:";
@@ -72,8 +72,12 @@ export interface ShareRouterOptions<V extends ShareRouterViewer> {
72
72
  base: string;
73
73
  /** The `kind` half of the store's `(app, kind, id)` triple. */
74
74
  kind: string;
75
- /** The store. A function when it is per-request (a multi-tenant app). */
76
- shares: ShareStore | ((c: Context) => ShareStore);
75
+ /**
76
+ * The store — the sync `ShareStore` or the D1 `AsyncShareStore` (`./sharing/d1`); every
77
+ * call is awaited, and awaiting a sync store's plain value changes nothing. A function
78
+ * when it is per-request (a multi-tenant app, or a Worker whose D1 handle is per request).
79
+ */
80
+ shares: AwaitableShareStore | ((c: Context) => AwaitableShareStore);
77
81
  /** Who is asking. Returning null refuses every route with 401. */
78
82
  viewerOf: (c: Context) => V | null;
79
83
  /**
@@ -84,7 +88,7 @@ export interface ShareRouterOptions<V extends ShareRouterViewer> {
84
88
  mustOwn: (c: Context, id: string, viewer: V) => Response | null | Promise<Response | null>;
85
89
  /** Extra fields folded into the READ payload beside `{grants, events}` — family's
86
90
  * `labels`, life's `groups`. */
87
- extra?: (c: Context, id: string, viewer: V) => Record<string, unknown>;
91
+ extra?: (c: Context, id: string, viewer: V) => Record<string, unknown> | Promise<Record<string, unknown>>;
88
92
  /** How many audit lines the read carries. Default 50. */
89
93
  trailLimit?: number;
90
94
  }
@@ -51,9 +51,11 @@ app, options) {
51
51
  const payload = {
52
52
  // The store's own rows, straight through — the sharing panel consumes exactly
53
53
  // these, and a DTO in the middle is a place for a field to go missing.
54
- grants: shares.listForResource({ kind, id }),
55
- events: shares.events({ kind, id, limit: trailLimit }),
56
- ...(extra ? extra(c, id, viewer) : {}),
54
+ // Awaited in this order — grants, trail, extra — which is the order the sync store
55
+ // was always read in.
56
+ grants: await shares.listForResource({ kind, id }),
57
+ events: await shares.events({ kind, id, limit: trailLimit }),
58
+ ...(extra ? await extra(c, id, viewer) : {}),
57
59
  };
58
60
  return c.json(payload);
59
61
  });
@@ -69,7 +71,7 @@ app, options) {
69
71
  if (body.level !== undefined && !isLevel(body.level)) {
70
72
  return c.json({ error: "level must be 'read' or 'write'" }, 400);
71
73
  }
72
- const grant = shares.grant({
74
+ const grant = await shares.grant({
73
75
  kind,
74
76
  id,
75
77
  subject: { type: body.subject?.type === 'group' ? 'group' : 'user', id: subjectId },
@@ -87,7 +89,7 @@ app, options) {
87
89
  const raw = c.req.param('subject') ?? '';
88
90
  if (!raw.trim())
89
91
  return c.json({ error: 'which share should be removed?' }, 400);
90
- shares.revoke({ kind, id, subject: parseSubjectParam(raw), revokedBy: viewer.email });
92
+ await shares.revoke({ kind, id, subject: parseSubjectParam(raw), revokedBy: viewer.email });
91
93
  return c.json({ ok: true });
92
94
  });
93
95
  }