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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.26.1",
3
+ "version": "4.28.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -47,6 +47,12 @@
47
47
  "source": "./src/server/activity/index.ts",
48
48
  "import": "./dist/server/activity/index.js"
49
49
  },
50
+ "./activity/d1": {
51
+ "types": "./dist/server/activity/d1.d.ts",
52
+ "bun": "./src/server/activity/d1.ts",
53
+ "source": "./src/server/activity/d1.ts",
54
+ "import": "./dist/server/activity/d1.js"
55
+ },
50
56
  "./context": {
51
57
  "types": "./dist/server/context.d.ts",
52
58
  "bun": "./src/server/context.ts",
@@ -125,6 +131,12 @@
125
131
  "source": "./src/server/engagement/index.ts",
126
132
  "import": "./dist/server/_bunOnly.js"
127
133
  },
134
+ "./engagement/d1": {
135
+ "types": "./dist/server/engagement/d1.d.ts",
136
+ "bun": "./src/server/engagement/d1.ts",
137
+ "source": "./src/server/engagement/d1.ts",
138
+ "import": "./dist/server/engagement/d1.js"
139
+ },
128
140
  "./errors": {
129
141
  "types": "./dist/server/errors.d.ts",
130
142
  "bun": "./src/server/errors.ts",
@@ -240,6 +252,12 @@
240
252
  "source": "./src/server/notifications/index.ts",
241
253
  "import": "./dist/server/notifications/index.js"
242
254
  },
255
+ "./notifications/d1": {
256
+ "types": "./dist/server/notifications/d1.d.ts",
257
+ "bun": "./src/server/notifications/d1.ts",
258
+ "source": "./src/server/notifications/d1.ts",
259
+ "import": "./dist/server/notifications/d1.js"
260
+ },
243
261
  "./resume": {
244
262
  "types": "./dist/server/resume/index.d.ts",
245
263
  "bun": "./src/server/resume/index.ts",
@@ -276,6 +294,12 @@
276
294
  "source": "./src/server/sharing/index.ts",
277
295
  "import": "./dist/server/sharing/index.js"
278
296
  },
297
+ "./sharing/d1": {
298
+ "types": "./dist/server/sharing/d1.d.ts",
299
+ "bun": "./src/server/sharing/d1.ts",
300
+ "source": "./src/server/sharing/d1.ts",
301
+ "import": "./dist/server/sharing/d1.js"
302
+ },
279
303
  "./sqlite": {
280
304
  "types": "./dist/server/sqlite/index.d.ts",
281
305
  "bun": "./src/server/sqlite/index.ts",
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The D1 activity log is the sync log, asynchronously — the same events, pages, counts, facets
3
+ * and sources for the same calls, over the same table (task 393, `apps/family`).
4
+ *
5
+ * The parity scenario drives both logs — each with a share store writing `share_events` into
6
+ * the same database, merged in through `shareEventsSource` — with one script and one clock, and
7
+ * compares every result and then the table. It runs over the local driver and over the fake D1
8
+ * binding, which refuses the binds real D1 refuses.
9
+ */
10
+ import { Database } from "bun:sqlite";
11
+ import { describe, expect, test } from "bun:test";
12
+ import { createFakeD1Binding } from "../d1/fakeD1.js";
13
+ import { createLocalD1 } from "../d1/local.js";
14
+ import { createRemoteD1 } from "../d1/remote.js";
15
+ import type { D1LikeDatabase } from "../d1/types.js";
16
+ import { runMigrations } from "../migration/runner.js";
17
+ import { createD1ShareStore, SHARE_DDL, user } from "../sharing/d1.js";
18
+ import { createShareStore } from "../sharing/store.js";
19
+ import { ACTIVITY_DDL, type AsyncActivityLog, createD1ActivityLog, shareEventsSource } from "./d1.js";
20
+ import { ACTIVITY_MIGRATIONS } from "./migrations.js";
21
+ import { type ActivityLog, createActivityLog } from "./store.js";
22
+
23
+ const APP = "family";
24
+
25
+ /** One minute per call, from a fixed start, identical for both logs. */
26
+ const clock = () => {
27
+ let at = Date.parse("2026-08-11T00:00:00.000Z");
28
+ return () => {
29
+ const value = new Date(at).toISOString();
30
+ at += 60_000;
31
+ return value;
32
+ };
33
+ };
34
+
35
+ const DRIVERS: Record<string, (sqlite: Database) => D1LikeDatabase> = {
36
+ local: (sqlite) => createLocalD1(sqlite),
37
+ "remote (fake D1 binding)": (sqlite) => createRemoteD1(createFakeD1Binding(sqlite)),
38
+ };
39
+ const local = DRIVERS.local as (sqlite: Database) => D1LikeDatabase;
40
+
41
+ const withDdl = (...ddl: (readonly string[])[]): Database => {
42
+ const sqlite = new Database(":memory:");
43
+ for (const statements of ddl) for (const statement of statements) sqlite.run(statement);
44
+ return sqlite;
45
+ };
46
+
47
+ type Grant = (email: string) => unknown;
48
+
49
+ const SCENARIO: ReadonlyArray<(log: ActivityLog | AsyncActivityLog, grant: Grant) => unknown> = [
50
+ (log) => log.sources(),
51
+ (log) =>
52
+ log.record({
53
+ actor: " Curt@Example.com ",
54
+ action: "person.create",
55
+ resource: { kind: "person", id: "a" },
56
+ note: "100% done",
57
+ data: { bytes: 4096 },
58
+ }),
59
+ (_log, grant) => grant("aunt@example.com"),
60
+ (log) => log.record({ actor: "other@example.com", action: "person.delete", resource: { kind: "person", id: "b" } }),
61
+ (log) => log.record({ actor: "curt@example.com", action: "backup.create", outcome: "error", note: "nothing like it" }),
62
+ (log) => log.record({ actor: "curt@example.com", action: "backup.create", at: "2026-08-10T23:59:00.000Z" }),
63
+ (_log, grant) => grant("cousin@example.com"),
64
+ (log) => log.query(),
65
+ (log) => log.query({ limit: 2 }),
66
+ (log) => log.query({ limit: 10_000 }),
67
+ (log) => log.query({ actor: "CURT@example.com" }),
68
+ (log) => log.query({ action: "person." }),
69
+ (log) => log.query({ action: "share." }),
70
+ (log) => log.query({ action: "person.create" }),
71
+ (log) => log.query({ kind: "person", resourceId: "b" }),
72
+ (log) => log.query({ outcome: "error" }),
73
+ (log) => log.query({ since: "2026-08-11T00:02:00.000Z" }),
74
+ (log) => log.query({ until: "2026-08-11T00:00:30.000Z" }),
75
+ (log) => log.query({ q: "100%" }),
76
+ (log) => log.query({ q: "%d" }),
77
+ (log) => log.query({ source: "share" }),
78
+ (log) => log.query({ source: "log", limit: 1 }),
79
+ (log) => log.query({ source: "nothing" }),
80
+ (log) => log.count(),
81
+ (log) => log.count({ source: "share" }),
82
+ (log) => log.facets(),
83
+ (log) => log.facets({ actor: "other@example.com" }, 1),
84
+ (log) => log.sources(),
85
+ ];
86
+
87
+ describe("cursedbelt-server/activity/d1", () => {
88
+ for (const [name, driver] of Object.entries(DRIVERS)) {
89
+ test(`🔴 parity over the ${name} driver: every page, count, facet and row match the sync log`, async () => {
90
+ const syncDb = new Database(":memory:");
91
+ const syncClock = clock();
92
+ const syncLog = createActivityLog({
93
+ db: syncDb,
94
+ app: APP,
95
+ now: syncClock,
96
+ sources: [shareEventsSource({ app: APP })],
97
+ });
98
+ const syncShares = createShareStore({ db: syncDb, app: APP, now: syncClock });
99
+
100
+ const d1Sqlite = withDdl(SHARE_DDL, ACTIVITY_DDL);
101
+ const d1 = driver(d1Sqlite);
102
+ const d1Clock = clock();
103
+ const d1Log = createD1ActivityLog({ db: d1, app: APP, now: d1Clock, sources: [shareEventsSource({ app: APP })] });
104
+ const d1Shares = createD1ShareStore({ db: d1, app: APP, now: d1Clock });
105
+
106
+ const grantVia =
107
+ (shares: { grant: (input: Parameters<typeof syncShares.grant>[0]) => unknown }): Grant =>
108
+ (email) =>
109
+ shares.grant({ kind: "person", id: "a", subject: user(email), level: "read", grantedBy: "curt@example.com" });
110
+
111
+ for (const [index, step] of SCENARIO.entries()) {
112
+ const expected = step(syncLog, grantVia(syncShares));
113
+ const actual = await step(d1Log, grantVia(d1Shares));
114
+ expect({ step: index, result: actual }).toEqual({ step: index, result: expected });
115
+ }
116
+ const table = (db: Database) => db.query("SELECT * FROM activity_events ORDER BY event_id").all();
117
+ expect(table(d1Sqlite)).toEqual(table(syncDb));
118
+ // Not vacuous: the merged stream really carried both contributors.
119
+ const merged = (await d1Log.query()).events.map((e) => e.source);
120
+ expect(new Set(merged)).toEqual(new Set(["log", "share"]));
121
+ });
122
+ }
123
+
124
+ test("ACTIVITY_DDL builds exactly the table and indexes the sync migration does", () => {
125
+ const shape = (db: Database) =>
126
+ db
127
+ .query("SELECT type, name, sql FROM sqlite_master WHERE name LIKE '%activity%' AND sql IS NOT NULL ORDER BY name")
128
+ .all();
129
+ const migrated = new Database(":memory:");
130
+ runMigrations(migrated, ACTIVITY_MIGRATIONS);
131
+ expect(shape(withDdl(ACTIVITY_DDL))).toEqual(shape(migrated));
132
+ expect(shape(migrated).length).toBe(5);
133
+ });
134
+
135
+ test("🔴 refuses an event that cannot be filtered or attributed, and writes nothing", async () => {
136
+ const sqlite = withDdl(ACTIVITY_DDL);
137
+ const log = createD1ActivityLog({ db: local(sqlite), app: "admin" });
138
+ await expect(log.record({ actor: "curt@example.com", action: "Backup Deleted" })).rejects.toThrow(/not an action/);
139
+ await expect(log.record({ actor: "curt@example.com", action: "backup" })).rejects.toThrow(/not an action/);
140
+ await expect(log.record({ actor: " ", action: "backup.delete" })).rejects.toThrow(/no actor/);
141
+ expect(await log.count()).toBe(0);
142
+ });
143
+
144
+ test("is append only — no verb here can remove an event", () => {
145
+ const log = createD1ActivityLog({ db: local(withDdl(ACTIVITY_DDL)), app: "admin" });
146
+ expect(Object.keys(log).some((verb) => /delete|remove|purge|clear|prune|truncate/i.test(verb))).toBe(false);
147
+ expect(Object.keys(log).sort()).toEqual(
148
+ Object.keys(createActivityLog({ db: new Database(":memory:"), app: "admin" })).sort(),
149
+ );
150
+ });
151
+
152
+ test("a source whose table does not exist is empty, not a crash — and joins once it does", async () => {
153
+ const sqlite = withDdl(ACTIVITY_DDL);
154
+ const log = createD1ActivityLog({ db: local(sqlite), app: "notes", sources: [shareEventsSource({ app: "notes" })] });
155
+ await log.record({ actor: "curt@example.com", action: "note.create" });
156
+ expect(await log.sources()).toEqual(["log"]);
157
+ expect((await log.query()).events).toHaveLength(1);
158
+ expect((await log.facets()).actions).toHaveLength(1);
159
+ for (const statement of SHARE_DDL) sqlite.run(statement);
160
+ expect(await log.sources()).toEqual(["log", "share"]);
161
+ });
162
+
163
+ test("🔴 runs no DDL: on a database without the table it creates nothing and fails loudly", async () => {
164
+ const sqlite = new Database(":memory:");
165
+ const log = createD1ActivityLog({ db: local(sqlite), app: "notes" });
166
+ expect(sqlite.query("SELECT COUNT(*) AS n FROM sqlite_master").get()).toEqual({ n: 0 });
167
+ await expect(log.record({ actor: "curt@example.com", action: "note.create" })).rejects.toThrow(/no such table/);
168
+ });
169
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * `cursedbelt-server/activity/d1` — the fleet's append-only activity stream over D1, for an app
3
+ * a Worker serves.
4
+ *
5
+ * ── Why it exists ───────────────────────────────────────────────────────────
6
+ * `./store.ts` is synchronous `bun:sqlite`; a Worker has neither. `apps/family`'s port to a
7
+ * Worker + D1 (task 393) needs the SAME stream — one append-only `activity_events` table plus
8
+ * read-only sources such as `share_events`, queried as one list — so this is that log, beside
9
+ * the sync one, which stays exactly as it was (`auth` and `roms` are still Bun servers).
10
+ *
11
+ * ── What is the same, and what is not ─────────────────────────────────────
12
+ * The same five verbs as `ActivityLog` — `record`, `query`, `count`, `facets`, `sources` —
13
+ * with the same validation, table, columns, filters, paging, ordering and source merge; each
14
+ * resolves instead of returning. The contract and every statement are the sync log's own
15
+ * (`./query.ts`), and `./d1.spec.ts` runs both logs through one scenario and compares them.
16
+ *
17
+ * · Validation failures REJECT rather than throw synchronously (they are async methods).
18
+ * · `sources()` is async too: "is this source's table there" is a query.
19
+ * · 🔴 No DDL runs here, ever. The sync log migrates on construction; on D1 the table comes
20
+ * from the app's `db/schema.sql`, built from {@link ACTIVITY_DDL} — the exact statements
21
+ * the sync migration runs.
22
+ *
23
+ * A page costs one query per registered source (the table-exists probe, which is what keeps
24
+ * "a missing source table is an empty source" true) plus one for the page itself.
25
+ *
26
+ * 🔴 Worker-safe: nothing reachable from here at runtime imports `bun:*`
27
+ * (`../telemetryIsWorkerSafe.spec.ts` walks it). Do not import `./store.js` or
28
+ * `./migrations.js` here.
29
+ *
30
+ * ```ts
31
+ * import { createD1ActivityLog, shareEventsSource } from "cursedbelt-server/activity/d1";
32
+ *
33
+ * const activity = createD1ActivityLog({ db, app: "family", sources: [shareEventsSource({ app: "family" })] });
34
+ * await activity.record({ actor: viewer.email, action: "person.delete", resource: { kind: "person", id } });
35
+ * const page = await activity.query({ action: "share.", limit: 50 });
36
+ * ```
37
+ */
38
+ import type { ActivityEvent, ActivityEventInput } from "cursedbelt-core/activity/model";
39
+ import type { D1LikeBindable, D1LikeDatabase } from "../d1/types.js";
40
+ import {
41
+ ACTIVITY_SQL,
42
+ type ActivityFacet,
43
+ type ActivityFacets,
44
+ type ActivityPage,
45
+ type ActivityQuery,
46
+ type ActivitySource,
47
+ buildStream,
48
+ facetLimit,
49
+ MAX_FACET,
50
+ pageLimit,
51
+ prepareRecord,
52
+ type StreamRow,
53
+ toStreamEvent,
54
+ } from "./query.js";
55
+
56
+ export { ACTIVITY_DDL, ACTIVITY_EVENTS_TABLE } from "./schema.js";
57
+ export { shareEventsSource, type ShareEventsSourceOptions } from "./shareSource.js";
58
+ export type {
59
+ ActivityBindable,
60
+ ActivityFacet,
61
+ ActivityFacets,
62
+ ActivityPage,
63
+ ActivityQuery,
64
+ ActivitySource,
65
+ } from "./query.js";
66
+
67
+ export interface D1ActivityLogOptions {
68
+ /** The app's D1 handle — `createRemoteD1(env.DB)` on a Worker, `createLocalD1(sqlite)` in a test. */
69
+ db: D1LikeDatabase;
70
+ /** The satellite this log speaks for. Fixed at construction, as in the sync log. */
71
+ app: string;
72
+ /** Read-only contributors merged into the stream. */
73
+ sources?: readonly ActivitySource[];
74
+ /** Injectable clock, ISO-8601. */
75
+ now?: () => string;
76
+ }
77
+
78
+ /** `ActivityLog`, over an async database — every verb resolves. */
79
+ export interface AsyncActivityLog {
80
+ /** The app this log speaks for. */
81
+ readonly app: string;
82
+ /** Append one event. Resolves to it as it will be read back. */
83
+ record(input: ActivityEventInput): Promise<ActivityEvent>;
84
+ /** Newest first, bounded. */
85
+ query(query?: ActivityQuery): Promise<ActivityPage>;
86
+ /** How many events match — the unbounded count behind a bounded page. */
87
+ count(query?: ActivityQuery): Promise<number>;
88
+ /** What is in the result, per dimension, for a filter UI. */
89
+ facets(query?: ActivityQuery, limit?: number): Promise<ActivityFacets>;
90
+ /** Which contributors are live right now. */
91
+ sources(): Promise<string[]>;
92
+ }
93
+
94
+ /** The activity log over D1. Runs no DDL — see the header. */
95
+ export function createD1ActivityLog(options: D1ActivityLogOptions): AsyncActivityLog {
96
+ const { db, app } = options;
97
+ const now = options.now ?? (() => new Date().toISOString());
98
+ const registered = options.sources ?? [];
99
+
100
+ const liveSources = async (): Promise<ActivitySource[]> => {
101
+ const live: ActivitySource[] = [];
102
+ for (const source of registered) {
103
+ if (
104
+ !source.requiresTable ||
105
+ (await db.prepare(ACTIVITY_SQL.tableExists).bind(source.requiresTable).first()) !== null
106
+ ) {
107
+ live.push(source);
108
+ }
109
+ }
110
+ return live;
111
+ };
112
+
113
+ const stream = async (query: ActivityQuery): Promise<{ sql: string; params: D1LikeBindable[] }> =>
114
+ buildStream(query, await liveSources());
115
+
116
+ return {
117
+ app,
118
+
119
+ async record(input: ActivityEventInput): Promise<ActivityEvent> {
120
+ const { binds, event } = prepareRecord(app, input, now);
121
+ const result = await db
122
+ .prepare(ACTIVITY_SQL.insert)
123
+ .bind(...binds)
124
+ .run();
125
+ if (result.meta.last_row_id === null) {
126
+ throw new Error(`activity: the database reported no row id for ${input.action}`);
127
+ }
128
+ return { id: `log:${result.meta.last_row_id}`, ...event };
129
+ },
130
+
131
+ async query(query: ActivityQuery = {}): Promise<ActivityPage> {
132
+ const limit = pageLimit(query.limit);
133
+ const { sql, params } = await stream(query);
134
+ const { results: rows } = await db
135
+ .prepare(ACTIVITY_SQL.page(sql))
136
+ .bind(...params, limit + 1)
137
+ .all<StreamRow>();
138
+ const truncated = rows.length > limit;
139
+ return { events: rows.slice(0, limit).map(toStreamEvent), limit, truncated };
140
+ },
141
+
142
+ async count(query: ActivityQuery = {}): Promise<number> {
143
+ const { sql, params } = await stream(query);
144
+ const row = await db
145
+ .prepare(ACTIVITY_SQL.count(sql))
146
+ .bind(...params)
147
+ .first<{ n: number }>();
148
+ return row?.n ?? 0;
149
+ },
150
+
151
+ async facets(query: ActivityQuery = {}, limit = MAX_FACET): Promise<ActivityFacets> {
152
+ const { sql, params } = await stream(query);
153
+ const capped = facetLimit(limit);
154
+ const dimension = async (column: "action" | "actor" | "kind"): Promise<ActivityFacet[]> =>
155
+ (
156
+ await db
157
+ .prepare(ACTIVITY_SQL.facet(sql, column))
158
+ .bind(...params, capped)
159
+ .all<{ value: string; count: number }>()
160
+ ).results.map((row) => ({ value: row.value, count: row.count }));
161
+ return {
162
+ actions: await dimension("action"),
163
+ actors: await dimension("actor"),
164
+ kinds: await dimension("kind"),
165
+ };
166
+ },
167
+
168
+ async sources(): Promise<string[]> {
169
+ return ["log", ...(await liveSources()).map((source) => source.name)];
170
+ },
171
+ };
172
+ }
@@ -1,9 +1,11 @@
1
1
  import type { Database } from "bun:sqlite";
2
2
  import type { Migration } from "../migration/types.js";
3
+ import { ACTIVITY_DDL } from "./schema.js";
3
4
 
4
- /** The one table this primitive owns. Exported so an app can declare a retention
5
- * target over it (see this module's `index.ts` for the recipe). */
6
- export const ACTIVITY_EVENTS_TABLE = "activity_events";
5
+ // The name and the DDL live in `./schema.ts`, driver-free, so a D1 app builds its schema file
6
+ // from the SAME statements this migration runs (`cursedbelt-server/activity/d1` re-exports
7
+ // them). Re-exported here so every existing import of this module keeps working.
8
+ export { ACTIVITY_DDL, ACTIVITY_EVENTS_TABLE } from "./schema.js";
7
9
 
8
10
  /**
9
11
  * ONE append-only table per app, not a shadow table per business table.
@@ -25,43 +27,7 @@ export const ACTIVITY_MIGRATIONS: readonly Migration[] = [
25
27
  {
26
28
  name: "0001_activity_events",
27
29
  up(db: Database): void {
28
- db.run(`
29
- CREATE TABLE IF NOT EXISTS ${ACTIVITY_EVENTS_TABLE} (
30
- event_id INTEGER PRIMARY KEY AUTOINCREMENT,
31
- at TEXT NOT NULL,
32
- app TEXT NOT NULL,
33
- actor TEXT NOT NULL,
34
- action TEXT NOT NULL,
35
- kind TEXT,
36
- resource_id TEXT,
37
- outcome TEXT NOT NULL DEFAULT 'ok'
38
- CHECK (outcome IN ('ok', 'denied', 'error')),
39
- note TEXT NOT NULL DEFAULT '',
40
- data TEXT
41
- )
42
- `);
43
- // The stream itself, newest first — the read every screen starts from.
44
- db.run(
45
- `CREATE INDEX IF NOT EXISTS idx_activity_events_at
46
- ON ${ACTIVITY_EVENTS_TABLE} (at DESC, event_id DESC)`,
47
- );
48
- // "Everything this person did" — the offboarding and the who-broke-it
49
- // question, and the one a fleet-wide search runs first.
50
- db.run(
51
- `CREATE INDEX IF NOT EXISTS idx_activity_events_actor
52
- ON ${ACTIVITY_EVENTS_TABLE} (actor, event_id DESC)`,
53
- );
54
- // "Everything that ever happened to this record."
55
- db.run(
56
- `CREATE INDEX IF NOT EXISTS idx_activity_events_resource
57
- ON ${ACTIVITY_EVENTS_TABLE} (kind, resource_id, event_id DESC)`,
58
- );
59
- // "Everything backups ever did" — an action-prefix filter is a range
60
- // scan on this index, which is why the vocabulary is dotted.
61
- db.run(
62
- `CREATE INDEX IF NOT EXISTS idx_activity_events_action
63
- ON ${ACTIVITY_EVENTS_TABLE} (action, event_id DESC)`,
64
- );
30
+ for (const statement of ACTIVITY_DDL) db.run(statement);
65
31
  },
66
32
  },
67
33
  ];