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
@@ -1,170 +1,39 @@
1
- import { isActivityAction, isActivityOutcome, } from "cursedbelt-core/activity/model";
2
1
  import { runMigrations } from "../migration/runner.js";
3
- import { ACTIVITY_EVENTS_TABLE, ACTIVITY_MIGRATIONS } from "./migrations.js";
4
- const DEFAULT_LIMIT = 100;
5
- const MAX_LIMIT = 1000;
6
- const MAX_FACET = 25;
7
- /** `%` and `_` are wildcards and `\` is the escape — a note containing any of
8
- * them must still search literally. */
9
- const likeContains = (value) => `%${value.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
10
- const toEvent = (row) => ({
11
- id: `${row.source}:${row.seq}`,
12
- source: row.source,
13
- at: row.at,
14
- app: row.app,
15
- actor: row.actor,
16
- action: row.action,
17
- resource: row.kind === null || row.resource_id === null
18
- ? null
19
- : { app: row.app, kind: row.kind, id: row.resource_id },
20
- outcome: isActivityOutcome(row.outcome) ? row.outcome : "ok",
21
- note: row.note ?? "",
22
- data: parseData(row.data),
23
- });
24
- /** A row whose `data` is unreadable must not take the page down with it — the
25
- * event still says who did what, which is the part that matters. */
26
- function parseData(raw) {
27
- if (!raw)
28
- return null;
29
- try {
30
- const parsed = JSON.parse(raw);
31
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
32
- ? parsed
33
- : { value: parsed };
34
- }
35
- catch {
36
- return { unparsed: raw };
37
- }
38
- }
2
+ import { ACTIVITY_MIGRATIONS } from "./migrations.js";
3
+ import { ACTIVITY_SQL, buildStream, facetLimit, MAX_FACET, pageLimit, prepareRecord, toStreamEvent, } from "./query.js";
39
4
  export function createActivityLog(options) {
40
5
  const { db, app } = options;
41
6
  const now = options.now ?? (() => new Date().toISOString());
42
7
  const registered = options.sources ?? [];
43
8
  runMigrations(db, ACTIVITY_MIGRATIONS);
44
- const insert = db.query(`INSERT INTO ${ACTIVITY_EVENTS_TABLE}
45
- (at, app, actor, action, kind, resource_id, outcome, note, data)
46
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
47
- const tableExists = (name) => !!db
48
- .query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?")
49
- .get(name);
9
+ const insert = db.query(ACTIVITY_SQL.insert);
10
+ const tableExists = (name) => !!db.query(ACTIVITY_SQL.tableExists).get(name);
50
11
  /** The contributors that can actually answer right now. */
51
12
  const liveSources = () => registered.filter((source) => !source.requiresTable || tableExists(source.requiresTable));
52
- const OWN_SELECT = `SELECT 'log' AS source, event_id AS seq, at, app, actor, action,
53
- kind, resource_id, outcome, note, data
54
- FROM ${ACTIVITY_EVENTS_TABLE}`;
55
- /** The union, plus the WHERE the caller asked for, plus their parameters in
56
- * bind order (sources first — they are inside the FROM). */
57
- const stream = (query) => {
58
- const params = [];
59
- const selects = [OWN_SELECT];
60
- for (const source of liveSources()) {
61
- if (query.source && query.source !== source.name)
62
- continue;
63
- selects.push(source.sql);
64
- params.push(...(source.params ?? []));
65
- }
66
- const where = [];
67
- if (query.source) {
68
- where.push("source = ?");
69
- params.push(query.source);
70
- }
71
- if (query.since) {
72
- where.push("at >= ?");
73
- params.push(query.since);
74
- }
75
- if (query.until) {
76
- where.push("at <= ?");
77
- params.push(query.until);
78
- }
79
- if (query.actor) {
80
- where.push("LOWER(actor) = LOWER(?)");
81
- params.push(query.actor);
82
- }
83
- if (query.action) {
84
- if (query.action.endsWith(".")) {
85
- where.push("action LIKE ? ESCAPE '\\'");
86
- params.push(`${query.action.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`);
87
- }
88
- else {
89
- where.push("action = ?");
90
- params.push(query.action);
91
- }
92
- }
93
- if (query.kind) {
94
- where.push("kind = ?");
95
- params.push(query.kind);
96
- }
97
- if (query.resourceId) {
98
- where.push("resource_id = ?");
99
- params.push(query.resourceId);
100
- }
101
- if (query.outcome) {
102
- where.push("outcome = ?");
103
- params.push(query.outcome);
104
- }
105
- if (query.q) {
106
- where.push("(actor LIKE ? ESCAPE '\\' OR action LIKE ? ESCAPE '\\' OR note LIKE ? ESCAPE '\\' OR IFNULL(resource_id, '') LIKE ? ESCAPE '\\')");
107
- const like = likeContains(query.q);
108
- params.push(like, like, like, like);
109
- }
110
- // `query.source` naming a contributor that is not live leaves only the own
111
- // select, filtered to a source it cannot be — an empty result, which is the
112
- // honest answer rather than every row.
113
- const sql = `SELECT * FROM (${selects.join("\n UNION ALL\n")}) AS stream${where.length ? ` WHERE ${where.join(" AND ")}` : ""}`;
114
- return { sql, params };
115
- };
13
+ const stream = (query) => buildStream(query, liveSources());
116
14
  return {
117
15
  app,
118
16
  record(input) {
119
- if (!isActivityAction(input.action)) {
120
- throw new Error(`activity: "${input.action}" is not an action — use lowercase subject.verb, e.g. "backup.delete"`);
121
- }
122
- const actor = input.actor?.trim();
123
- if (!actor) {
124
- // An event that cannot say who is a log line, not an audit trail.
125
- throw new Error(`activity: ${input.action} has no actor`);
126
- }
127
- const at = input.at ?? now();
128
- const outcome = input.outcome ?? "ok";
129
- const data = input.data ? JSON.stringify(input.data) : null;
130
- const info = insert.run(at, app, actor, input.action, input.resource?.kind ?? null, input.resource?.id ?? null, outcome, input.note ?? "", data);
131
- return {
132
- id: `log:${Number(info.lastInsertRowid)}`,
133
- source: "log",
134
- at,
135
- app,
136
- actor,
137
- action: input.action,
138
- resource: input.resource ? { app, ...input.resource } : null,
139
- outcome,
140
- note: input.note ?? "",
141
- data: input.data ?? null,
142
- };
17
+ const { binds, event } = prepareRecord(app, input, now);
18
+ const info = insert.run(...binds);
19
+ return { id: `log:${Number(info.lastInsertRowid)}`, ...event };
143
20
  },
144
21
  query(query = {}) {
145
- const limit = Math.min(MAX_LIMIT, Math.max(1, Math.floor(query.limit ?? DEFAULT_LIMIT)));
22
+ const limit = pageLimit(query.limit);
146
23
  const { sql, params } = stream(query);
147
- // One row past the limit, so "there is more" is a fact rather than a
148
- // guess made from `rows.length === limit`.
149
- const rows = db
150
- .query(`${sql} ORDER BY at DESC, seq DESC LIMIT ?`)
151
- .all(...params, limit + 1);
24
+ const rows = db.query(ACTIVITY_SQL.page(sql)).all(...params, limit + 1);
152
25
  const truncated = rows.length > limit;
153
- return { events: rows.slice(0, limit).map(toEvent), limit, truncated };
26
+ return { events: rows.slice(0, limit).map(toStreamEvent), limit, truncated };
154
27
  },
155
28
  count(query = {}) {
156
29
  const { sql, params } = stream(query);
157
- const row = db.query(`SELECT COUNT(*) AS n FROM (${sql})`).get(...params);
30
+ const row = db.query(ACTIVITY_SQL.count(sql)).get(...params);
158
31
  return row?.n ?? 0;
159
32
  },
160
33
  facets(query = {}, limit = MAX_FACET) {
161
34
  const { sql, params } = stream(query);
162
- const capped = Math.min(MAX_FACET, Math.max(1, Math.floor(limit)));
163
- const dimension = (column) => db
164
- .query(`SELECT ${column} AS value, COUNT(*) AS count FROM (${sql})
165
- WHERE ${column} IS NOT NULL AND ${column} <> ''
166
- GROUP BY ${column} ORDER BY count DESC, value ASC LIMIT ?`)
167
- .all(...params, capped).map((row) => ({ value: row.value, count: row.count }));
35
+ const capped = facetLimit(limit);
36
+ const dimension = (column) => db.query(ACTIVITY_SQL.facet(sql, column)).all(...params, capped).map((row) => ({ value: row.value, count: row.count }));
168
37
  return {
169
38
  actions: dimension("action"),
170
39
  actors: dimension("actor"),
@@ -27,8 +27,6 @@ export interface MetricRow {
27
27
  region?: string | null;
28
28
  city?: string | null;
29
29
  }
30
- /** The optional place columns, in insert order. Each is written only when the table HAS it. */
31
- export declare const PLACE_COLUMNS: readonly ["country", "region", "city"];
32
30
  export interface MetricsBufferOptions {
33
31
  db: Database;
34
32
  /** Flush cadence in ms. Default: 1500. */
@@ -1,5 +1,8 @@
1
- /** The optional place columns, in insert order. Each is written only when the table HAS it. */
2
- export const PLACE_COLUMNS = ['country', 'region', 'city'];
1
+ import { PLACE_COLUMNS } from './requestPlace.js';
2
+ // Every place column is a `MetricRow` field — a column declared in `requestPlace.ts` that this
3
+ // row cannot carry fails to compile here rather than inserting `undefined` for ever.
4
+ const _placeColumnsAreRowFields = PLACE_COLUMNS;
5
+ void _placeColumnsAreRowFields;
3
6
  export function createMetricsBuffer(opts) {
4
7
  const { db, flushIntervalMs = 1500, maxSize = 5000 } = opts;
5
8
  let buffer = [];
@@ -30,6 +30,14 @@ export interface RequestPlace {
30
30
  }
31
31
  /** Place one request, or `null` when it cannot. Must not throw — but a throw is caught and read as `null`. */
32
32
  export type RequestLocator = (request: Request) => RequestPlace | null;
33
+ /**
34
+ * The place columns, in insert order — the ONE declaration every reader and migration uses
35
+ * (`metricsBuffer` writes them, an app's reader allows them, an app's migration adds them).
36
+ * Exported through `cursedbelt-server/telemetry` since 4.29.0 (task 2141): until then it was
37
+ * private to `metricsBuffer.ts`, so flix, station and desk each declared their own copy, and a
38
+ * fourth column added here would have been written by the buffer and dropped by all three.
39
+ */
40
+ export declare const PLACE_COLUMNS: readonly ["country", "region", "city"];
33
41
  /** Each place field is capped so three of them can never crowd the 5120-byte Analytics Engine blob budget. */
34
42
  export declare const PLACE_FIELD_MAX_CHARS = 96;
35
43
  /**
@@ -22,6 +22,14 @@
22
22
  *
23
23
  * Worker-safe: no `bun:` import (`telemetryIsWorkerSafe.spec.ts` walks this graph).
24
24
  */
25
+ /**
26
+ * The place columns, in insert order — the ONE declaration every reader and migration uses
27
+ * (`metricsBuffer` writes them, an app's reader allows them, an app's migration adds them).
28
+ * Exported through `cursedbelt-server/telemetry` since 4.29.0 (task 2141): until then it was
29
+ * private to `metricsBuffer.ts`, so flix, station and desk each declared their own copy, and a
30
+ * fourth column added here would have been written by the buffer and dropped by all three.
31
+ */
32
+ export const PLACE_COLUMNS = ['country', 'region', 'city'];
25
33
  /** Each place field is capped so three of them can never crowd the 5120-byte Analytics Engine blob budget. */
26
34
  export const PLACE_FIELD_MAX_CHARS = 96;
27
35
  /** Cloudflare's "no country could be determined" code — an unknown, not a place. */
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `cursedbelt-server/notifications/d1` — the notification service over D1, for an app a Worker
3
+ * serves.
4
+ *
5
+ * ── Why it exists ───────────────────────────────────────────────────────────
6
+ * `createNotificationService` builds cwip's `createSqliteNotificationStore` over a synchronous
7
+ * `bun:sqlite` handle; a Worker has neither. `apps/family`'s port to a Worker + D1 (task 393)
8
+ * needs the SAME inbox — the same table, the same coalescing, the same routes — so this is
9
+ * cwip's `NotificationStore` contract implemented over D1, handed to cwip's own
10
+ * `createNotificationCenter`, and mounted by the same `mountNotificationRoutes`. The engine
11
+ * stays cwip's; only the storage changes.
12
+ *
13
+ * ── What is the same, and what is not ─────────────────────────────────────
14
+ * · The table is `createSqliteNotificationStore`'s, column for column and index for index
15
+ * ({@link notificationsDdl}; `./d1.spec.ts` compares the two schemas), so coalescing is
16
+ * still the partial unique index and never only a read.
17
+ * · Every store method runs cwip's statement, bound identically; `./d1.spec.ts` drives both
18
+ * stores through one scenario and compares every result and every row.
19
+ * · 🔴 `hub` is always `null` and `{base}/stream` is never mounted. Live delivery is an
20
+ * in-memory set of open streams, and a Worker isolate cannot share one with the isolate
21
+ * that produced the notification — a stream route that answered 200 and never emitted
22
+ * would be worse than the 404 the client already falls back from. The poll transport is
23
+ * the delivery story here, exactly as it is for `live: false` on the Mac.
24
+ * · 🔴 No DDL runs here, ever. The sync store migrates on construction; on D1 the table is
25
+ * the app's `db/schema.sql`, built from {@link notificationsDdl} (or from an in-memory run
26
+ * of `NOTIFICATION_MIGRATIONS` — the two converge, and the spec proves it).
27
+ *
28
+ * 🔴 Worker-safe: nothing reachable from here at runtime imports `bun:*`
29
+ * (`../telemetryIsWorkerSafe.spec.ts` walks it). Do not import `./service.js`,
30
+ * `./migrations.js` or `./index.js` here.
31
+ *
32
+ * ```ts
33
+ * import { createD1NotificationService } from "cursedbelt-server/notifications/d1";
34
+ *
35
+ * const notifications = createD1NotificationService({ db: createRemoteD1(env.DB) });
36
+ * notifications.mount(app, { authorize: (c) => sso.userOf(c)?.id ?? null });
37
+ * await notifications.center.notify(userId, { type: "share.grant", title: "Shared with you" });
38
+ * ```
39
+ */
40
+ import { type NotificationStore } from "cwip/notifications";
41
+ import type { D1LikeDatabase } from "../d1/types.js";
42
+ import type { NotificationService } from "./types.js";
43
+ export type { NotificationService, NotificationServiceMountOptions } from "./types.js";
44
+ /** The default table — the name `createSqliteNotificationStore` and `NOTIFICATION_MIGRATIONS` use. */
45
+ export declare const D1_NOTIFICATIONS_TABLE = "notifications";
46
+ /**
47
+ * The DDL a D1 schema needs for the notification table — the FINAL shape
48
+ * `createSqliteNotificationStore` migrates any database to (its later `source`/`severity`
49
+ * columns are in the CREATE, as they are in cwip's), with both indexes. Idempotent.
50
+ */
51
+ export declare function notificationsDdl(table?: string): string[];
52
+ /** {@link notificationsDdl} for the default table. */
53
+ export declare const NOTIFICATIONS_DDL: readonly string[];
54
+ export interface D1NotificationStoreOptions {
55
+ /** Table name override (identifier-validated). Default `notifications`. */
56
+ table?: string;
57
+ }
58
+ /**
59
+ * cwip's `NotificationStore` over D1 — the same statements as `createSqliteNotificationStore`,
60
+ * awaited. Every method is user-scoped, as the contract requires. Runs no DDL.
61
+ */
62
+ export declare function createD1NotificationStore(db: D1LikeDatabase, options?: D1NotificationStoreOptions): NotificationStore;
63
+ export interface CreateD1NotificationServiceOptions {
64
+ /** The app's D1 handle — `createRemoteD1(env.DB)` on a Worker, `createLocalD1(sqlite)` in a test. */
65
+ db: D1LikeDatabase;
66
+ /** Table name override (identifier-validated). */
67
+ table?: string;
68
+ /** Resolve a role audience (`notifyAudience({ role })`) to user ids. */
69
+ resolveRole?: (role: string) => Promise<string[]>;
70
+ /** Producer-path failures land here. Defaults to stderr. */
71
+ onError?: (error: unknown, context: string) => void;
72
+ /** Test seams, forwarded to the center. */
73
+ now?: () => number;
74
+ newId?: () => string;
75
+ }
76
+ /**
77
+ * The whole server half over D1 — `createNotificationService`'s shape, with `hub: null`.
78
+ * `mount` hangs the same routes on the app, minus `{base}/stream` (see the header).
79
+ */
80
+ export declare function createD1NotificationService(options: CreateD1NotificationServiceOptions): NotificationService;
@@ -0,0 +1,226 @@
1
+ /**
2
+ * `cursedbelt-server/notifications/d1` — the notification service over D1, for an app a Worker
3
+ * serves.
4
+ *
5
+ * ── Why it exists ───────────────────────────────────────────────────────────
6
+ * `createNotificationService` builds cwip's `createSqliteNotificationStore` over a synchronous
7
+ * `bun:sqlite` handle; a Worker has neither. `apps/family`'s port to a Worker + D1 (task 393)
8
+ * needs the SAME inbox — the same table, the same coalescing, the same routes — so this is
9
+ * cwip's `NotificationStore` contract implemented over D1, handed to cwip's own
10
+ * `createNotificationCenter`, and mounted by the same `mountNotificationRoutes`. The engine
11
+ * stays cwip's; only the storage changes.
12
+ *
13
+ * ── What is the same, and what is not ─────────────────────────────────────
14
+ * · The table is `createSqliteNotificationStore`'s, column for column and index for index
15
+ * ({@link notificationsDdl}; `./d1.spec.ts` compares the two schemas), so coalescing is
16
+ * still the partial unique index and never only a read.
17
+ * · Every store method runs cwip's statement, bound identically; `./d1.spec.ts` drives both
18
+ * stores through one scenario and compares every result and every row.
19
+ * · 🔴 `hub` is always `null` and `{base}/stream` is never mounted. Live delivery is an
20
+ * in-memory set of open streams, and a Worker isolate cannot share one with the isolate
21
+ * that produced the notification — a stream route that answered 200 and never emitted
22
+ * would be worse than the 404 the client already falls back from. The poll transport is
23
+ * the delivery story here, exactly as it is for `live: false` on the Mac.
24
+ * · 🔴 No DDL runs here, ever. The sync store migrates on construction; on D1 the table is
25
+ * the app's `db/schema.sql`, built from {@link notificationsDdl} (or from an in-memory run
26
+ * of `NOTIFICATION_MIGRATIONS` — the two converge, and the spec proves it).
27
+ *
28
+ * 🔴 Worker-safe: nothing reachable from here at runtime imports `bun:*`
29
+ * (`../telemetryIsWorkerSafe.spec.ts` walks it). Do not import `./service.js`,
30
+ * `./migrations.js` or `./index.js` here.
31
+ *
32
+ * ```ts
33
+ * import { createD1NotificationService } from "cursedbelt-server/notifications/d1";
34
+ *
35
+ * const notifications = createD1NotificationService({ db: createRemoteD1(env.DB) });
36
+ * notifications.mount(app, { authorize: (c) => sso.userOf(c)?.id ?? null });
37
+ * await notifications.center.notify(userId, { type: "share.grant", title: "Shared with you" });
38
+ * ```
39
+ */
40
+ import { createNotificationCenter, } from "cwip/notifications";
41
+ import { mountNotificationRoutes } from "./routes.js";
42
+ /** The default table — the name `createSqliteNotificationStore` and `NOTIFICATION_MIGRATIONS` use. */
43
+ export const D1_NOTIFICATIONS_TABLE = "notifications";
44
+ const DEFAULT_LIMIT = 50;
45
+ const assertIdentifier = (name) => {
46
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
47
+ throw new Error(`notifications: invalid table identifier: ${name}`);
48
+ }
49
+ };
50
+ /**
51
+ * The DDL a D1 schema needs for the notification table — the FINAL shape
52
+ * `createSqliteNotificationStore` migrates any database to (its later `source`/`severity`
53
+ * columns are in the CREATE, as they are in cwip's), with both indexes. Idempotent.
54
+ */
55
+ export function notificationsDdl(table = D1_NOTIFICATIONS_TABLE) {
56
+ assertIdentifier(table);
57
+ return [
58
+ `CREATE TABLE IF NOT EXISTS ${table} (
59
+ id TEXT PRIMARY KEY,
60
+ user_id TEXT NOT NULL,
61
+ created_at INTEGER NOT NULL,
62
+ updated_at INTEGER NOT NULL,
63
+ type TEXT NOT NULL,
64
+ source TEXT,
65
+ severity TEXT NOT NULL DEFAULT 'info',
66
+ title TEXT NOT NULL,
67
+ body TEXT,
68
+ link TEXT,
69
+ resource_type TEXT,
70
+ resource_id TEXT,
71
+ actor_id TEXT,
72
+ actor_name TEXT,
73
+ group_key TEXT,
74
+ count INTEGER NOT NULL DEFAULT 1,
75
+ read INTEGER NOT NULL DEFAULT 0,
76
+ done INTEGER NOT NULL DEFAULT 0
77
+ )`,
78
+ `CREATE INDEX IF NOT EXISTS idx_${table}_user ON ${table}(user_id, done, read, created_at)`,
79
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_${table}_group ON ${table}(user_id, group_key)
80
+ WHERE group_key IS NOT NULL AND done = 0`,
81
+ ];
82
+ }
83
+ /** {@link notificationsDdl} for the default table. */
84
+ export const NOTIFICATIONS_DDL = notificationsDdl();
85
+ /** cwip's `rowToNotification`, verbatim in effect — an absent column is `undefined`, never `null`. */
86
+ const rowToNotification = (row) => ({
87
+ id: row.id,
88
+ userId: row.user_id,
89
+ createdAt: row.created_at,
90
+ updatedAt: row.updated_at,
91
+ type: row.type,
92
+ source: row.source ?? undefined,
93
+ severity: row.severity ?? "info",
94
+ title: row.title,
95
+ body: row.body ?? undefined,
96
+ link: row.link ?? undefined,
97
+ resourceType: row.resource_type ?? undefined,
98
+ resourceId: row.resource_id ?? undefined,
99
+ actorId: row.actor_id ?? undefined,
100
+ actorName: row.actor_name ?? undefined,
101
+ groupKey: row.group_key ?? undefined,
102
+ count: row.count,
103
+ read: !!row.read,
104
+ done: !!row.done,
105
+ });
106
+ /**
107
+ * cwip's `NotificationStore` over D1 — the same statements as `createSqliteNotificationStore`,
108
+ * awaited. Every method is user-scoped, as the contract requires. Runs no DDL.
109
+ */
110
+ export function createD1NotificationStore(db, options = {}) {
111
+ const table = options.table ?? D1_NOTIFICATIONS_TABLE;
112
+ assertIdentifier(table);
113
+ const insertSql = `
114
+ INSERT INTO ${table} (id, user_id, created_at, updated_at, type, source, severity, title,
115
+ body, link, resource_type, resource_id, actor_id, actor_name, group_key, count, read, done)
116
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
117
+ `;
118
+ const updateSql = `
119
+ UPDATE ${table} SET updated_at = ?, type = ?, source = ?, severity = ?, title = ?, body = ?,
120
+ link = ?, resource_type = ?, resource_id = ?, actor_id = ?, actor_name = ?, group_key = ?,
121
+ count = ?, read = ?, done = ?
122
+ WHERE id = ? AND user_id = ?
123
+ `;
124
+ /** 🔴 Every optional becomes `null` and every boolean `0/1` HERE — D1 refuses an
125
+ * `undefined` bind outright, where `bun:sqlite` would have bound NULL without a word. */
126
+ const bindDraftColumns = (n) => [
127
+ n.type,
128
+ n.source ?? null,
129
+ n.severity,
130
+ n.title,
131
+ n.body ?? null,
132
+ n.link ?? null,
133
+ n.resourceType ?? null,
134
+ n.resourceId ?? null,
135
+ n.actorId ?? null,
136
+ n.actorName ?? null,
137
+ n.groupKey ?? null,
138
+ n.count,
139
+ n.read ? 1 : 0,
140
+ n.done ? 1 : 0,
141
+ ];
142
+ const one = async (sql, ...values) => {
143
+ const row = await db
144
+ .prepare(sql)
145
+ .bind(...values)
146
+ .first();
147
+ return row ? rowToNotification(row) : null;
148
+ };
149
+ const changes = async (sql, ...values) => (await db
150
+ .prepare(sql)
151
+ .bind(...values)
152
+ .run()).meta.changes;
153
+ return {
154
+ insert: async (n) => {
155
+ await db
156
+ .prepare(insertSql)
157
+ .bind(n.id, n.userId, n.createdAt, n.updatedAt, ...bindDraftColumns(n))
158
+ .run();
159
+ },
160
+ update: async (n) => {
161
+ await db
162
+ .prepare(updateSql)
163
+ .bind(n.updatedAt, ...bindDraftColumns(n), n.id, n.userId)
164
+ .run();
165
+ },
166
+ findOpenByGroup: (userId, groupKey) => one(`SELECT * FROM ${table} WHERE user_id = ? AND group_key = ? AND done = 0 LIMIT 1`, userId, groupKey),
167
+ get: (userId, id) => one(`SELECT * FROM ${table} WHERE id = ? AND user_id = ?`, id, userId),
168
+ list: async ({ userId, filter = "all", limit = DEFAULT_LIMIT, offset = 0, }) => {
169
+ const where = filter === "unread"
170
+ ? "user_id = ? AND done = 0 AND read = 0"
171
+ : filter === "done"
172
+ ? "user_id = ? AND done = 1"
173
+ : "user_id = ? AND done = 0";
174
+ // `rowid DESC` breaks a same-millisecond tie by insertion order, as cwip's store does —
175
+ // its comment has the measurement. D1 tables are rowid tables, so it means the same there.
176
+ const { results } = await db
177
+ .prepare(`SELECT * FROM ${table} WHERE ${where} ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`)
178
+ .bind(userId, limit + 1, offset)
179
+ .all();
180
+ return { items: results.slice(0, limit).map(rowToNotification), hasMore: results.length > limit };
181
+ },
182
+ unreadCount: async (userId) => (await db
183
+ .prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE user_id = ? AND done = 0 AND read = 0`)
184
+ .bind(userId)
185
+ .first("n")) ?? 0,
186
+ markRead: (userId, id, read) => changes(`UPDATE ${table} SET read = ? WHERE id = ? AND user_id = ? AND read != ?`, read ? 1 : 0, id, userId, read ? 1 : 0),
187
+ markAllRead: (userId) => changes(`UPDATE ${table} SET read = 1 WHERE user_id = ? AND done = 0 AND read = 0`, userId),
188
+ setDone: (userId, id, done) => changes(`UPDATE ${table} SET done = ?, read = CASE WHEN ? = 1 THEN 1 ELSE read END
189
+ WHERE id = ? AND user_id = ? AND done != ?`, done ? 1 : 0, done ? 1 : 0, id, userId, done ? 1 : 0),
190
+ doneAll: (userId) => changes(`UPDATE ${table} SET done = 1, read = 1 WHERE user_id = ? AND done = 0`, userId),
191
+ remove: (userId, id) => changes(`DELETE FROM ${table} WHERE id = ? AND user_id = ?`, id, userId),
192
+ pruneDone: (olderThanMs) => changes(`DELETE FROM ${table} WHERE done = 1 AND updated_at < ?`, olderThanMs),
193
+ };
194
+ }
195
+ /**
196
+ * The whole server half over D1 — `createNotificationService`'s shape, with `hub: null`.
197
+ * `mount` hangs the same routes on the app, minus `{base}/stream` (see the header).
198
+ */
199
+ export function createD1NotificationService(options) {
200
+ const onError = options.onError ??
201
+ ((error, context) => console.error(`[notifications] ${context}:`, error instanceof Error ? error.message : error));
202
+ const store = createD1NotificationStore(options.db, { table: options.table });
203
+ const center = createNotificationCenter({
204
+ store,
205
+ resolveRole: options.resolveRole,
206
+ now: options.now,
207
+ newId: options.newId,
208
+ onError,
209
+ });
210
+ return {
211
+ center,
212
+ store,
213
+ hub: null,
214
+ mount(app, mountOptions) {
215
+ mountNotificationRoutes(app, {
216
+ center,
217
+ authorize: mountOptions.authorize,
218
+ basePath: mountOptions.basePath,
219
+ maxLimit: mountOptions.maxLimit,
220
+ });
221
+ },
222
+ close() {
223
+ // Nothing to close: there are no live streams on D1.
224
+ },
225
+ };
226
+ }
@@ -13,9 +13,8 @@
13
13
  * import and no wiring to get wrong.
14
14
  */
15
15
  import type { Database } from "bun:sqlite";
16
- import { type NotificationCenter, type NotificationStore } from "cwip/notifications";
17
- import type { Context, Hono } from "hono";
18
- import { type NotificationHub } from "./hub.js";
16
+ import type { NotificationService } from "./types.js";
17
+ export type { NotificationService, NotificationServiceMountOptions } from "./types.js";
19
18
  export interface CreateNotificationServiceOptions {
20
19
  /** The app's own database — the table lands beside its rows. */
21
20
  db: Database;
@@ -38,21 +37,4 @@ export interface CreateNotificationServiceOptions {
38
37
  now?: () => number;
39
38
  newId?: () => string;
40
39
  }
41
- export interface NotificationServiceMountOptions {
42
- /** Resolve the caller to a user id, or null to refuse. */
43
- authorize: (c: Context) => string | null | Promise<string | null>;
44
- /** Path prefix. Default `/api/notifications`. */
45
- basePath?: string;
46
- /** Largest page a caller may ask for. Default 200. */
47
- maxLimit?: number;
48
- }
49
- export interface NotificationService {
50
- readonly center: NotificationCenter;
51
- readonly store: NotificationStore;
52
- /** Null when `live: false` — there is then nothing to stream. */
53
- readonly hub: NotificationHub | null;
54
- mount(app: Hono, options: NotificationServiceMountOptions): void;
55
- /** Drop live subscribers (shutdown, tests). Safe to call with no hub. */
56
- close(): void;
57
- }
58
40
  export declare function createNotificationService(options: CreateNotificationServiceOptions): NotificationService;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The notification service's shape with no driver attached — what `createNotificationService`
3
+ * (`bun:sqlite`) and `createD1NotificationService` (D1, `./d1.ts`) both return. It lives apart
4
+ * from `./service.ts` so the D1 half, which runs on `workerd`, never reaches a module that
5
+ * names `bun:sqlite`, not even as a type.
6
+ */
7
+ import type { NotificationCenter, NotificationStore } from "cwip/notifications";
8
+ import type { Context, Hono } from "hono";
9
+ import type { NotificationHub } from "./hub.js";
10
+ export interface NotificationServiceMountOptions {
11
+ /** Resolve the caller to a user id, or null to refuse. */
12
+ authorize: (c: Context) => string | null | Promise<string | null>;
13
+ /** Path prefix. Default `/api/notifications`. */
14
+ basePath?: string;
15
+ /** Largest page a caller may ask for. Default 200. */
16
+ maxLimit?: number;
17
+ }
18
+ export interface NotificationService {
19
+ readonly center: NotificationCenter;
20
+ readonly store: NotificationStore;
21
+ /** Null when `live: false`, and always on D1 (`./d1.ts`) — there is then nothing to stream,
22
+ * and the client's poll transport is the whole delivery story. */
23
+ readonly hub: NotificationHub | null;
24
+ mount(app: Hono, options: NotificationServiceMountOptions): void;
25
+ /** Drop live subscribers (shutdown, tests). Safe to call with no hub. */
26
+ close(): void;
27
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -62,7 +62,7 @@ import { Database } from "bun:sqlite";
62
62
  import { closeSync, existsSync, mkdirSync, openSync, readSync, renameSync, statSync } from "node:fs";
63
63
  import { join } from "node:path";
64
64
  import { applyCcPragmas } from "cwip/sqlite";
65
- import { PLACE_COLUMNS } from "../metrics/metricsBuffer.js";
65
+ import { PLACE_COLUMNS } from "../metrics/requestPlace.js";
66
66
  import { createTelemetrySink } from "../metrics/telemetrySink.js";
67
67
  import { requestLogger } from "../middleware/requestLogger.js";
68
68
  import { runMigrations } from "../migration/runner.js";