cursedbelt-server 4.26.1 → 4.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/server/activity/d1.d.ts +70 -0
  2. package/dist/server/activity/d1.js +68 -0
  3. package/dist/server/activity/migrations.d.ts +1 -3
  4. package/dist/server/activity/migrations.js +7 -32
  5. package/dist/server/activity/query.d.ts +123 -0
  6. package/dist/server/activity/query.js +171 -0
  7. package/dist/server/activity/schema.d.ts +13 -0
  8. package/dist/server/activity/schema.js +43 -0
  9. package/dist/server/activity/shareSource.d.ts +1 -1
  10. package/dist/server/activity/shareSource.js +1 -1
  11. package/dist/server/activity/store.d.ts +4 -62
  12. package/dist/server/activity/store.js +14 -145
  13. package/dist/server/engagement/d1.d.ts +67 -0
  14. package/dist/server/engagement/d1.js +81 -0
  15. package/dist/server/engagement/manifestPlaces.d.ts +9 -0
  16. package/dist/server/engagement/manifestPlaces.js +17 -0
  17. package/dist/server/engagement/places.js +2 -18
  18. package/dist/server/notifications/d1.d.ts +80 -0
  19. package/dist/server/notifications/d1.js +226 -0
  20. package/dist/server/notifications/service.d.ts +2 -20
  21. package/dist/server/notifications/types.d.ts +27 -0
  22. package/dist/server/notifications/types.js +1 -0
  23. package/dist/server/sharing/contract.d.ts +94 -0
  24. package/dist/server/sharing/contract.js +3 -0
  25. package/dist/server/sharing/d1.d.ts +77 -0
  26. package/dist/server/sharing/d1.js +309 -0
  27. package/dist/server/sharing/index.d.ts +1 -0
  28. package/dist/server/sharing/migrations.d.ts +1 -4
  29. package/dist/server/sharing/migrations.js +7 -72
  30. package/dist/server/sharing/router.d.ts +8 -4
  31. package/dist/server/sharing/router.js +7 -5
  32. package/dist/server/sharing/schema.d.ts +19 -0
  33. package/dist/server/sharing/schema.js +88 -0
  34. package/dist/server/sharing/sql.js +1 -1
  35. package/dist/server/sharing/statements.d.ts +116 -0
  36. package/dist/server/sharing/statements.js +139 -0
  37. package/dist/server/sharing/store.d.ts +3 -28
  38. package/dist/server/sharing/store.js +32 -105
  39. package/docs/activity.md +7 -0
  40. package/docs/engagement.md +6 -0
  41. package/docs/notifications.md +9 -0
  42. package/package.json +25 -1
  43. package/src/server/activity/d1.spec.ts +169 -0
  44. package/src/server/activity/d1.ts +172 -0
  45. package/src/server/activity/migrations.ts +6 -40
  46. package/src/server/activity/query.ts +283 -0
  47. package/src/server/activity/schema.ts +45 -0
  48. package/src/server/activity/shareSource.ts +2 -2
  49. package/src/server/activity/store.ts +45 -257
  50. package/src/server/engagement/d1.spec.ts +102 -0
  51. package/src/server/engagement/d1.ts +141 -0
  52. package/src/server/engagement/manifestPlaces.ts +24 -0
  53. package/src/server/engagement/places.ts +2 -16
  54. package/src/server/notifications/d1.spec.ts +179 -0
  55. package/src/server/notifications/d1.ts +334 -0
  56. package/src/server/notifications/service.ts +7 -23
  57. package/src/server/notifications/types.ts +29 -0
  58. package/src/server/sharing/contract.ts +109 -0
  59. package/src/server/sharing/d1.spec.ts +242 -0
  60. package/src/server/sharing/d1.ts +430 -0
  61. package/src/server/sharing/index.ts +3 -0
  62. package/src/server/sharing/migrations.ts +12 -85
  63. package/src/server/sharing/router.ts +21 -11
  64. package/src/server/sharing/schema.ts +90 -0
  65. package/src/server/sharing/sql.ts +1 -1
  66. package/src/server/sharing/statements.ts +213 -0
  67. package/src/server/sharing/store.ts +46 -221
  68. package/src/server/telemetryIsWorkerSafe.spec.ts +64 -0
@@ -0,0 +1,283 @@
1
+ /**
2
+ * The activity stream's contract and SQL with no driver attached — ONE copy for both the
3
+ * `bun:sqlite` log (`./store.ts`) and the D1 log (`./d1.ts`).
4
+ *
5
+ * The two logs differ only in how they execute a statement. What an event looks like, how a
6
+ * source joins the union, what each filter means, how a page is bounded and how a facet is
7
+ * counted are all decided here, once — so "an action ending in a dot is a prefix" cannot be
8
+ * true on the Mac and false on a Worker. Driver-free on purpose: `./d1.ts` runs on `workerd`.
9
+ */
10
+ import {
11
+ type ActivityEvent,
12
+ type ActivityEventInput,
13
+ type ActivityOutcome,
14
+ isActivityAction,
15
+ isActivityOutcome,
16
+ } from "cursedbelt-core/activity/model";
17
+ import { ACTIVITY_EVENTS_TABLE } from "./schema.js";
18
+
19
+ /**
20
+ * A value a source may bind. The intersection of what `bun:sqlite` and D1 both accept, so a
21
+ * source written once contributes to either log.
22
+ */
23
+ export type ActivityBindable = string | number | bigint | boolean | null | Uint8Array;
24
+
25
+ /**
26
+ * A read-only stream contributor: a SELECT projecting some other table into the
27
+ * union's eleven columns, in this order and with these names —
28
+ *
29
+ * `source, seq, at, app, actor, action, kind, resource_id, outcome, note, data`
30
+ *
31
+ * `seq` is that table's own row id: it breaks ties within one `at`, and it is
32
+ * what makes the event id (`"<source>:<seq>"`) stable and unique across sources.
33
+ */
34
+ export interface ActivitySource {
35
+ /** Short, stable, unique within the log — it prefixes every id it produces. */
36
+ name: string;
37
+ /** The projection. See the column contract above. */
38
+ sql: string;
39
+ /** Bound in order, before any filter parameter. */
40
+ params?: readonly ActivityBindable[];
41
+ /**
42
+ * A table this projection needs. When it does not exist the source is skipped
43
+ * for that call rather than throwing — see property 3 in `./store.ts`'s header.
44
+ */
45
+ requiresTable?: string;
46
+ }
47
+
48
+ /** Everything the stream can be narrowed by. Every field is optional; the empty
49
+ * query is "the newest `limit` events". */
50
+ export interface ActivityQuery {
51
+ /** ISO-8601, inclusive. */
52
+ since?: string;
53
+ /** ISO-8601, inclusive. */
54
+ until?: string;
55
+ /** Case-insensitive exact match on the account. */
56
+ actor?: string;
57
+ /** Exact, or a PREFIX when it ends in a dot: `"share."` matches `share.grant`. */
58
+ action?: string;
59
+ kind?: string;
60
+ resourceId?: string;
61
+ outcome?: ActivityOutcome;
62
+ /** Restrict to one contributor (`"log"`, `"share"`). */
63
+ source?: string;
64
+ /** Substring, over actor + action + note + resource id. */
65
+ q?: string;
66
+ /** Default 100, hard cap 1000. */
67
+ limit?: number;
68
+ }
69
+
70
+ export interface ActivityPage {
71
+ events: ActivityEvent[];
72
+ /** The limit actually applied, after defaulting and capping. */
73
+ limit: number;
74
+ /** There is at least one more row older than the last one returned. */
75
+ truncated: boolean;
76
+ }
77
+
78
+ /** One dimension of "what is in this result", for the filter UI. */
79
+ export interface ActivityFacet {
80
+ value: string;
81
+ count: number;
82
+ }
83
+
84
+ export interface ActivityFacets {
85
+ actions: ActivityFacet[];
86
+ actors: ActivityFacet[];
87
+ kinds: ActivityFacet[];
88
+ }
89
+
90
+ export interface StreamRow {
91
+ source: string;
92
+ seq: number;
93
+ at: string;
94
+ app: string;
95
+ actor: string;
96
+ action: string;
97
+ kind: string | null;
98
+ resource_id: string | null;
99
+ outcome: ActivityOutcome;
100
+ note: string;
101
+ data: string | null;
102
+ }
103
+
104
+ const DEFAULT_LIMIT = 100;
105
+ const MAX_LIMIT = 1000;
106
+ export const MAX_FACET = 25;
107
+
108
+ /** `%` and `_` are wildcards and `\` is the escape — a note containing any of
109
+ * them must still search literally. */
110
+ const likeContains = (value: string): string => `%${value.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
111
+
112
+ export const toStreamEvent = (row: StreamRow): ActivityEvent => ({
113
+ id: `${row.source}:${row.seq}`,
114
+ source: row.source,
115
+ at: row.at,
116
+ app: row.app,
117
+ actor: row.actor,
118
+ action: row.action,
119
+ resource:
120
+ row.kind === null || row.resource_id === null ? null : { app: row.app, kind: row.kind, id: row.resource_id },
121
+ outcome: isActivityOutcome(row.outcome) ? row.outcome : "ok",
122
+ note: row.note ?? "",
123
+ data: parseData(row.data),
124
+ });
125
+
126
+ /** A row whose `data` is unreadable must not take the page down with it — the
127
+ * event still says who did what, which is the part that matters. */
128
+ function parseData(raw: string | null): Record<string, unknown> | null {
129
+ if (!raw) return null;
130
+ try {
131
+ const parsed = JSON.parse(raw) as unknown;
132
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
133
+ ? (parsed as Record<string, unknown>)
134
+ : { value: parsed };
135
+ } catch {
136
+ return { unparsed: raw };
137
+ }
138
+ }
139
+
140
+ export const ACTIVITY_SQL = {
141
+ /** Binds at, app, actor, action, kind, resource_id, outcome, note, data. */
142
+ insert: `INSERT INTO ${ACTIVITY_EVENTS_TABLE}
143
+ (at, app, actor, action, kind, resource_id, outcome, note, data)
144
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
145
+ tableExists: "SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?",
146
+ ownSelect: `SELECT 'log' AS source, event_id AS seq, at, app, actor, action,
147
+ kind, resource_id, outcome, note, data
148
+ FROM ${ACTIVITY_EVENTS_TABLE}`,
149
+ /** One row past the limit, so "there is more" is a fact rather than a guess made from
150
+ * `rows.length === limit`. Binds the stream's params, then `limit + 1`. */
151
+ page: (stream: string): string => `${stream} ORDER BY at DESC, seq DESC LIMIT ?`,
152
+ count: (stream: string): string => `SELECT COUNT(*) AS n FROM (${stream})`,
153
+ /** Binds the stream's params, then the facet cap. `column` is one of three literals. */
154
+ facet: (stream: string, column: "action" | "actor" | "kind"): string =>
155
+ `SELECT ${column} AS value, COUNT(*) AS count FROM (${stream})
156
+ WHERE ${column} IS NOT NULL AND ${column} <> ''
157
+ GROUP BY ${column} ORDER BY count DESC, value ASC LIMIT ?`,
158
+ } as const;
159
+
160
+ /** The limit a page actually applies: default 100, never above 1000 or below 1. */
161
+ export const pageLimit = (limit: number | undefined): number =>
162
+ Math.min(MAX_LIMIT, Math.max(1, Math.floor(limit ?? DEFAULT_LIMIT)));
163
+
164
+ /** The facet cap: default {@link MAX_FACET}, never above it or below 1. */
165
+ export const facetLimit = (limit: number): number => Math.min(MAX_FACET, Math.max(1, Math.floor(limit)));
166
+
167
+ /**
168
+ * The union of the log's own rows and every LIVE source, plus the WHERE the caller asked for,
169
+ * plus their parameters in bind order (sources first — they are inside the FROM).
170
+ */
171
+ export function buildStream(
172
+ query: ActivityQuery,
173
+ liveSources: readonly ActivitySource[],
174
+ ): { sql: string; params: ActivityBindable[] } {
175
+ const params: ActivityBindable[] = [];
176
+ const selects: string[] = [ACTIVITY_SQL.ownSelect];
177
+ for (const source of liveSources) {
178
+ if (query.source && query.source !== source.name) continue;
179
+ selects.push(source.sql);
180
+ params.push(...(source.params ?? []));
181
+ }
182
+ const where: string[] = [];
183
+ if (query.source) {
184
+ where.push("source = ?");
185
+ params.push(query.source);
186
+ }
187
+ if (query.since) {
188
+ where.push("at >= ?");
189
+ params.push(query.since);
190
+ }
191
+ if (query.until) {
192
+ where.push("at <= ?");
193
+ params.push(query.until);
194
+ }
195
+ if (query.actor) {
196
+ where.push("LOWER(actor) = LOWER(?)");
197
+ params.push(query.actor);
198
+ }
199
+ if (query.action) {
200
+ if (query.action.endsWith(".")) {
201
+ where.push("action LIKE ? ESCAPE '\\'");
202
+ params.push(`${query.action.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`);
203
+ } else {
204
+ where.push("action = ?");
205
+ params.push(query.action);
206
+ }
207
+ }
208
+ if (query.kind) {
209
+ where.push("kind = ?");
210
+ params.push(query.kind);
211
+ }
212
+ if (query.resourceId) {
213
+ where.push("resource_id = ?");
214
+ params.push(query.resourceId);
215
+ }
216
+ if (query.outcome) {
217
+ where.push("outcome = ?");
218
+ params.push(query.outcome);
219
+ }
220
+ if (query.q) {
221
+ where.push(
222
+ "(actor LIKE ? ESCAPE '\\' OR action LIKE ? ESCAPE '\\' OR note LIKE ? ESCAPE '\\' OR IFNULL(resource_id, '') LIKE ? ESCAPE '\\')",
223
+ );
224
+ const like = likeContains(query.q);
225
+ params.push(like, like, like, like);
226
+ }
227
+ // `query.source` naming a contributor that is not live leaves only the own
228
+ // select, filtered to a source it cannot be — an empty result, which is the
229
+ // honest answer rather than every row.
230
+ const sql = `SELECT * FROM (${selects.join("\n UNION ALL\n")}) AS stream${
231
+ where.length ? ` WHERE ${where.join(" AND ")}` : ""
232
+ }`;
233
+ return { sql, params };
234
+ }
235
+
236
+ /**
237
+ * Validate one event for the write and return the row to bind plus the event as it will be
238
+ * read back (minus its id, which only the insert can know). Throws on an action that cannot be
239
+ * filtered or an event that cannot say who — property 4 of `./store.ts`'s header.
240
+ */
241
+ export function prepareRecord(
242
+ app: string,
243
+ input: ActivityEventInput,
244
+ now: () => string,
245
+ ): { binds: [string, string, string, string, string | null, string | null, string, string, string | null]; event: Omit<ActivityEvent, "id"> } {
246
+ if (!isActivityAction(input.action)) {
247
+ throw new Error(
248
+ `activity: "${input.action}" is not an action — use lowercase subject.verb, e.g. "backup.delete"`,
249
+ );
250
+ }
251
+ const actor = input.actor?.trim();
252
+ if (!actor) {
253
+ // An event that cannot say who is a log line, not an audit trail.
254
+ throw new Error(`activity: ${input.action} has no actor`);
255
+ }
256
+ const at = input.at ?? now();
257
+ const outcome = input.outcome ?? "ok";
258
+ const data = input.data ? JSON.stringify(input.data) : null;
259
+ return {
260
+ binds: [
261
+ at,
262
+ app,
263
+ actor,
264
+ input.action,
265
+ input.resource?.kind ?? null,
266
+ input.resource?.id ?? null,
267
+ outcome,
268
+ input.note ?? "",
269
+ data,
270
+ ],
271
+ event: {
272
+ source: "log",
273
+ at,
274
+ app,
275
+ actor,
276
+ action: input.action,
277
+ resource: input.resource ? { app, ...input.resource } : null,
278
+ outcome,
279
+ note: input.note ?? "",
280
+ data: input.data ?? null,
281
+ },
282
+ };
283
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The activity table's name and DDL, with no driver attached — the ONE copy both the
3
+ * `bun:sqlite` migration (`./migrations.ts`) and a D1 app's `db/schema.sql` are built from.
4
+ *
5
+ * Import-free on purpose: `./d1.ts` runs on `workerd`, and a Worker app generates its schema
6
+ * file from these statements rather than running DDL at request time. Why the table is ONE
7
+ * append-only stream is `./migrations.ts`'s header.
8
+ */
9
+
10
+ /** The one table this primitive owns. Exported so an app can declare a retention
11
+ * target over it (see this module's `index.ts` for the recipe). */
12
+ export const ACTIVITY_EVENTS_TABLE = "activity_events";
13
+
14
+ /** Every statement of the `0001_activity_events` migration, in order. Idempotent. */
15
+ export const ACTIVITY_DDL: readonly string[] = [
16
+ `
17
+ CREATE TABLE IF NOT EXISTS ${ACTIVITY_EVENTS_TABLE} (
18
+ event_id INTEGER PRIMARY KEY AUTOINCREMENT,
19
+ at TEXT NOT NULL,
20
+ app TEXT NOT NULL,
21
+ actor TEXT NOT NULL,
22
+ action TEXT NOT NULL,
23
+ kind TEXT,
24
+ resource_id TEXT,
25
+ outcome TEXT NOT NULL DEFAULT 'ok'
26
+ CHECK (outcome IN ('ok', 'denied', 'error')),
27
+ note TEXT NOT NULL DEFAULT '',
28
+ data TEXT
29
+ )
30
+ `,
31
+ // The stream itself, newest first — the read every screen starts from.
32
+ `CREATE INDEX IF NOT EXISTS idx_activity_events_at
33
+ ON ${ACTIVITY_EVENTS_TABLE} (at DESC, event_id DESC)`,
34
+ // "Everything this person did" — the offboarding and the who-broke-it
35
+ // question, and the one a fleet-wide search runs first.
36
+ `CREATE INDEX IF NOT EXISTS idx_activity_events_actor
37
+ ON ${ACTIVITY_EVENTS_TABLE} (actor, event_id DESC)`,
38
+ // "Everything that ever happened to this record."
39
+ `CREATE INDEX IF NOT EXISTS idx_activity_events_resource
40
+ ON ${ACTIVITY_EVENTS_TABLE} (kind, resource_id, event_id DESC)`,
41
+ // "Everything backups ever did" — an action-prefix filter is a range
42
+ // scan on this index, which is why the vocabulary is dotted.
43
+ `CREATE INDEX IF NOT EXISTS idx_activity_events_action
44
+ ON ${ACTIVITY_EVENTS_TABLE} (action, event_id DESC)`,
45
+ ];
@@ -25,8 +25,8 @@
25
25
  * fleet-wide board merges on that column and an unlabeled row would be
26
26
  * unattributable.
27
27
  */
28
- import { SHARE_EVENTS_TABLE } from "../sharing/migrations.js";
29
- import type { ActivitySource } from "./store.js";
28
+ import { SHARE_EVENTS_TABLE } from "../sharing/schema.js";
29
+ import type { ActivitySource } from "./query.js";
30
30
 
31
31
  export interface ShareEventsSourceOptions {
32
32
  /** The satellite this log speaks for — the same value passed to
@@ -34,80 +34,36 @@
34
34
  * cannot be filtered, and filtering is the whole point of the column.
35
35
  */
36
36
  import type { Database, SQLQueryBindings } from "bun:sqlite";
37
- import {
38
- type ActivityEvent,
39
- type ActivityEventInput,
40
- type ActivityOutcome,
41
- isActivityAction,
42
- isActivityOutcome,
43
- } from "cursedbelt-core/activity/model";
37
+ import type { ActivityEvent, ActivityEventInput } from "cursedbelt-core/activity/model";
44
38
  import { runMigrations } from "../migration/runner.js";
45
- import { ACTIVITY_EVENTS_TABLE, ACTIVITY_MIGRATIONS } from "./migrations.js";
46
-
47
- /**
48
- * A read-only stream contributor: a SELECT projecting some other table into the
49
- * union's eleven columns, in this order and with these names —
50
- *
51
- * `source, seq, at, app, actor, action, kind, resource_id, outcome, note, data`
52
- *
53
- * `seq` is that table's own row id: it breaks ties within one `at`, and it is
54
- * what makes the event id (`"<source>:<seq>"`) stable and unique across sources.
55
- */
56
- export interface ActivitySource {
57
- /** Short, stable, unique within the log — it prefixes every id it produces. */
58
- name: string;
59
- /** The projection. See the column contract above. */
60
- sql: string;
61
- /** Bound in order, before any filter parameter. */
62
- params?: readonly SQLQueryBindings[];
63
- /**
64
- * A table this projection needs. When it does not exist the source is skipped
65
- * for that call rather than throwing — see property 3 in the header.
66
- */
67
- requiresTable?: string;
68
- }
69
-
70
- /** Everything the stream can be narrowed by. Every field is optional; the empty
71
- * query is "the newest `limit` events". */
72
- export interface ActivityQuery {
73
- /** ISO-8601, inclusive. */
74
- since?: string;
75
- /** ISO-8601, inclusive. */
76
- until?: string;
77
- /** Case-insensitive exact match on the account. */
78
- actor?: string;
79
- /** Exact, or a PREFIX when it ends in a dot: `"share."` matches `share.grant`. */
80
- action?: string;
81
- kind?: string;
82
- resourceId?: string;
83
- outcome?: ActivityOutcome;
84
- /** Restrict to one contributor (`"log"`, `"share"`). */
85
- source?: string;
86
- /** Substring, over actor + action + note + resource id. */
87
- q?: string;
88
- /** Default 100, hard cap 1000. */
89
- limit?: number;
90
- }
91
-
92
- export interface ActivityPage {
93
- events: ActivityEvent[];
94
- /** The limit actually applied, after defaulting and capping. */
95
- limit: number;
96
- /** There is at least one more row older than the last one returned. */
97
- truncated: boolean;
98
- }
99
-
100
- /** One dimension of "what is in this result", for the filter UI. */
101
- export interface ActivityFacet {
102
- value: string;
103
- count: number;
104
- }
105
-
106
- export interface ActivityFacets {
107
- actions: ActivityFacet[];
108
- actors: ActivityFacet[];
109
- kinds: ActivityFacet[];
110
- }
39
+ import { ACTIVITY_MIGRATIONS } from "./migrations.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
+ // The contract and the SQL have no driver in them, so `./d1.ts` (which runs on `workerd`)
57
+ // shares them without reaching this file. Re-exported so `cursedbelt-server/activity` is
58
+ // unchanged.
59
+ export type {
60
+ ActivityBindable,
61
+ ActivityFacet,
62
+ ActivityFacets,
63
+ ActivityPage,
64
+ ActivityQuery,
65
+ ActivitySource,
66
+ } from "./query.js";
111
67
 
112
68
  export interface ActivityLogOptions {
113
69
  db: Database;
@@ -136,59 +92,6 @@ export interface ActivityLog {
136
92
  sources(): string[];
137
93
  }
138
94
 
139
- interface StreamRow {
140
- source: string;
141
- seq: number;
142
- at: string;
143
- app: string;
144
- actor: string;
145
- action: string;
146
- kind: string | null;
147
- resource_id: string | null;
148
- outcome: ActivityOutcome;
149
- note: string;
150
- data: string | null;
151
- }
152
-
153
- const DEFAULT_LIMIT = 100;
154
- const MAX_LIMIT = 1000;
155
- const MAX_FACET = 25;
156
-
157
- /** `%` and `_` are wildcards and `\` is the escape — a note containing any of
158
- * them must still search literally. */
159
- const likeContains = (value: string): string =>
160
- `%${value.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
161
-
162
- const toEvent = (row: StreamRow): ActivityEvent => ({
163
- id: `${row.source}:${row.seq}`,
164
- source: row.source,
165
- at: row.at,
166
- app: row.app,
167
- actor: row.actor,
168
- action: row.action,
169
- resource:
170
- row.kind === null || row.resource_id === null
171
- ? null
172
- : { app: row.app, kind: row.kind, id: row.resource_id },
173
- outcome: isActivityOutcome(row.outcome) ? row.outcome : "ok",
174
- note: row.note ?? "",
175
- data: parseData(row.data),
176
- });
177
-
178
- /** A row whose `data` is unreadable must not take the page down with it — the
179
- * event still says who did what, which is the part that matters. */
180
- function parseData(raw: string | null): Record<string, unknown> | null {
181
- if (!raw) return null;
182
- try {
183
- const parsed = JSON.parse(raw) as unknown;
184
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
185
- ? (parsed as Record<string, unknown>)
186
- : { value: parsed };
187
- } catch {
188
- return { unparsed: raw };
189
- }
190
- }
191
-
192
95
  export function createActivityLog(options: ActivityLogOptions): ActivityLog {
193
96
  const { db, app } = options;
194
97
  const now = options.now ?? (() => new Date().toISOString());
@@ -196,146 +99,37 @@ export function createActivityLog(options: ActivityLogOptions): ActivityLog {
196
99
 
197
100
  runMigrations(db, ACTIVITY_MIGRATIONS);
198
101
 
199
- const insert = db.query(
200
- `INSERT INTO ${ACTIVITY_EVENTS_TABLE}
201
- (at, app, actor, action, kind, resource_id, outcome, note, data)
202
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
203
- );
102
+ const insert = db.query(ACTIVITY_SQL.insert);
204
103
 
205
- const tableExists = (name: string): boolean =>
206
- !!db
207
- .query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?")
208
- .get(name);
104
+ const tableExists = (name: string): boolean => !!db.query(ACTIVITY_SQL.tableExists).get(name);
209
105
 
210
106
  /** The contributors that can actually answer right now. */
211
107
  const liveSources = (): ActivitySource[] =>
212
108
  registered.filter((source) => !source.requiresTable || tableExists(source.requiresTable));
213
109
 
214
- const OWN_SELECT = `SELECT 'log' AS source, event_id AS seq, at, app, actor, action,
215
- kind, resource_id, outcome, note, data
216
- FROM ${ACTIVITY_EVENTS_TABLE}`;
217
-
218
- /** The union, plus the WHERE the caller asked for, plus their parameters in
219
- * bind order (sources first — they are inside the FROM). */
220
- const stream = (query: ActivityQuery): { sql: string; params: SQLQueryBindings[] } => {
221
- const params: SQLQueryBindings[] = [];
222
- const selects = [OWN_SELECT];
223
- for (const source of liveSources()) {
224
- if (query.source && query.source !== source.name) continue;
225
- selects.push(source.sql);
226
- params.push(...(source.params ?? []));
227
- }
228
- const where: string[] = [];
229
- if (query.source) {
230
- where.push("source = ?");
231
- params.push(query.source);
232
- }
233
- if (query.since) {
234
- where.push("at >= ?");
235
- params.push(query.since);
236
- }
237
- if (query.until) {
238
- where.push("at <= ?");
239
- params.push(query.until);
240
- }
241
- if (query.actor) {
242
- where.push("LOWER(actor) = LOWER(?)");
243
- params.push(query.actor);
244
- }
245
- if (query.action) {
246
- if (query.action.endsWith(".")) {
247
- where.push("action LIKE ? ESCAPE '\\'");
248
- params.push(`${query.action.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`);
249
- } else {
250
- where.push("action = ?");
251
- params.push(query.action);
252
- }
253
- }
254
- if (query.kind) {
255
- where.push("kind = ?");
256
- params.push(query.kind);
257
- }
258
- if (query.resourceId) {
259
- where.push("resource_id = ?");
260
- params.push(query.resourceId);
261
- }
262
- if (query.outcome) {
263
- where.push("outcome = ?");
264
- params.push(query.outcome);
265
- }
266
- if (query.q) {
267
- where.push(
268
- "(actor LIKE ? ESCAPE '\\' OR action LIKE ? ESCAPE '\\' OR note LIKE ? ESCAPE '\\' OR IFNULL(resource_id, '') LIKE ? ESCAPE '\\')",
269
- );
270
- const like = likeContains(query.q);
271
- params.push(like, like, like, like);
272
- }
273
- // `query.source` naming a contributor that is not live leaves only the own
274
- // select, filtered to a source it cannot be — an empty result, which is the
275
- // honest answer rather than every row.
276
- const sql = `SELECT * FROM (${selects.join("\n UNION ALL\n")}) AS stream${
277
- where.length ? ` WHERE ${where.join(" AND ")}` : ""
278
- }`;
279
- return { sql, params };
280
- };
110
+ const stream = (query: ActivityQuery): { sql: string; params: SQLQueryBindings[] } =>
111
+ buildStream(query, liveSources());
281
112
 
282
113
  return {
283
114
  app,
284
115
 
285
116
  record(input: ActivityEventInput): ActivityEvent {
286
- if (!isActivityAction(input.action)) {
287
- throw new Error(
288
- `activity: "${input.action}" is not an action — use lowercase subject.verb, e.g. "backup.delete"`,
289
- );
290
- }
291
- const actor = input.actor?.trim();
292
- if (!actor) {
293
- // An event that cannot say who is a log line, not an audit trail.
294
- throw new Error(`activity: ${input.action} has no actor`);
295
- }
296
- const at = input.at ?? now();
297
- const outcome = input.outcome ?? "ok";
298
- const data = input.data ? JSON.stringify(input.data) : null;
299
- const info = insert.run(
300
- at,
301
- app,
302
- actor,
303
- input.action,
304
- input.resource?.kind ?? null,
305
- input.resource?.id ?? null,
306
- outcome,
307
- input.note ?? "",
308
- data,
309
- );
310
- return {
311
- id: `log:${Number(info.lastInsertRowid)}`,
312
- source: "log",
313
- at,
314
- app,
315
- actor,
316
- action: input.action,
317
- resource: input.resource ? { app, ...input.resource } : null,
318
- outcome,
319
- note: input.note ?? "",
320
- data: input.data ?? null,
321
- };
117
+ const { binds, event } = prepareRecord(app, input, now);
118
+ const info = insert.run(...binds);
119
+ return { id: `log:${Number(info.lastInsertRowid)}`, ...event };
322
120
  },
323
121
 
324
122
  query(query: ActivityQuery = {}): ActivityPage {
325
- const limit = Math.min(MAX_LIMIT, Math.max(1, Math.floor(query.limit ?? DEFAULT_LIMIT)));
123
+ const limit = pageLimit(query.limit);
326
124
  const { sql, params } = stream(query);
327
- // One row past the limit, so "there is more" is a fact rather than a
328
- // guess made from `rows.length === limit`.
329
- const rows = db
330
- .query(`${sql} ORDER BY at DESC, seq DESC LIMIT ?`)
331
- .all(...params, limit + 1) as StreamRow[];
125
+ const rows = db.query(ACTIVITY_SQL.page(sql)).all(...params, limit + 1) as StreamRow[];
332
126
  const truncated = rows.length > limit;
333
- return { events: rows.slice(0, limit).map(toEvent), limit, truncated };
127
+ return { events: rows.slice(0, limit).map(toStreamEvent), limit, truncated };
334
128
  },
335
129
 
336
130
  count(query: ActivityQuery = {}): number {
337
131
  const { sql, params } = stream(query);
338
- const row = db.query(`SELECT COUNT(*) AS n FROM (${sql})`).get(...params) as {
132
+ const row = db.query(ACTIVITY_SQL.count(sql)).get(...params) as {
339
133
  n: number;
340
134
  } | null;
341
135
  return row?.n ?? 0;
@@ -343,17 +137,11 @@ export function createActivityLog(options: ActivityLogOptions): ActivityLog {
343
137
 
344
138
  facets(query: ActivityQuery = {}, limit = MAX_FACET): ActivityFacets {
345
139
  const { sql, params } = stream(query);
346
- const capped = Math.min(MAX_FACET, Math.max(1, Math.floor(limit)));
347
- const dimension = (column: string): ActivityFacet[] =>
348
- (
349
- db
350
- .query(
351
- `SELECT ${column} AS value, COUNT(*) AS count FROM (${sql})
352
- WHERE ${column} IS NOT NULL AND ${column} <> ''
353
- GROUP BY ${column} ORDER BY count DESC, value ASC LIMIT ?`,
354
- )
355
- .all(...params, capped) as { value: string; count: number }[]
356
- ).map((row) => ({ value: row.value, count: row.count }));
140
+ const capped = facetLimit(limit);
141
+ const dimension = (column: "action" | "actor" | "kind"): ActivityFacet[] =>
142
+ (db.query(ACTIVITY_SQL.facet(sql, column)).all(...params, capped) as { value: string; count: number }[]).map(
143
+ (row) => ({ value: row.value, count: row.count }),
144
+ );
357
145
  return {
358
146
  actions: dimension("action"),
359
147
  actors: dimension("actor"),