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,70 @@
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 { D1LikeDatabase } from "../d1/types.js";
40
+ import { type ActivityFacets, type ActivityPage, type ActivityQuery, type ActivitySource } from "./query.js";
41
+ export { ACTIVITY_DDL, ACTIVITY_EVENTS_TABLE } from "./schema.js";
42
+ export { shareEventsSource, type ShareEventsSourceOptions } from "./shareSource.js";
43
+ export type { ActivityBindable, ActivityFacet, ActivityFacets, ActivityPage, ActivityQuery, ActivitySource, } from "./query.js";
44
+ export interface D1ActivityLogOptions {
45
+ /** The app's D1 handle — `createRemoteD1(env.DB)` on a Worker, `createLocalD1(sqlite)` in a test. */
46
+ db: D1LikeDatabase;
47
+ /** The satellite this log speaks for. Fixed at construction, as in the sync log. */
48
+ app: string;
49
+ /** Read-only contributors merged into the stream. */
50
+ sources?: readonly ActivitySource[];
51
+ /** Injectable clock, ISO-8601. */
52
+ now?: () => string;
53
+ }
54
+ /** `ActivityLog`, over an async database — every verb resolves. */
55
+ export interface AsyncActivityLog {
56
+ /** The app this log speaks for. */
57
+ readonly app: string;
58
+ /** Append one event. Resolves to it as it will be read back. */
59
+ record(input: ActivityEventInput): Promise<ActivityEvent>;
60
+ /** Newest first, bounded. */
61
+ query(query?: ActivityQuery): Promise<ActivityPage>;
62
+ /** How many events match — the unbounded count behind a bounded page. */
63
+ count(query?: ActivityQuery): Promise<number>;
64
+ /** What is in the result, per dimension, for a filter UI. */
65
+ facets(query?: ActivityQuery, limit?: number): Promise<ActivityFacets>;
66
+ /** Which contributors are live right now. */
67
+ sources(): Promise<string[]>;
68
+ }
69
+ /** The activity log over D1. Runs no DDL — see the header. */
70
+ export declare function createD1ActivityLog(options: D1ActivityLogOptions): AsyncActivityLog;
@@ -0,0 +1,68 @@
1
+ import { ACTIVITY_SQL, buildStream, facetLimit, MAX_FACET, pageLimit, prepareRecord, toStreamEvent, } from "./query.js";
2
+ export { ACTIVITY_DDL, ACTIVITY_EVENTS_TABLE } from "./schema.js";
3
+ export { shareEventsSource } from "./shareSource.js";
4
+ /** The activity log over D1. Runs no DDL — see the header. */
5
+ export function createD1ActivityLog(options) {
6
+ const { db, app } = options;
7
+ const now = options.now ?? (() => new Date().toISOString());
8
+ const registered = options.sources ?? [];
9
+ const liveSources = async () => {
10
+ const live = [];
11
+ for (const source of registered) {
12
+ if (!source.requiresTable ||
13
+ (await db.prepare(ACTIVITY_SQL.tableExists).bind(source.requiresTable).first()) !== null) {
14
+ live.push(source);
15
+ }
16
+ }
17
+ return live;
18
+ };
19
+ const stream = async (query) => buildStream(query, await liveSources());
20
+ return {
21
+ app,
22
+ async record(input) {
23
+ const { binds, event } = prepareRecord(app, input, now);
24
+ const result = await db
25
+ .prepare(ACTIVITY_SQL.insert)
26
+ .bind(...binds)
27
+ .run();
28
+ if (result.meta.last_row_id === null) {
29
+ throw new Error(`activity: the database reported no row id for ${input.action}`);
30
+ }
31
+ return { id: `log:${result.meta.last_row_id}`, ...event };
32
+ },
33
+ async query(query = {}) {
34
+ const limit = pageLimit(query.limit);
35
+ const { sql, params } = await stream(query);
36
+ const { results: rows } = await db
37
+ .prepare(ACTIVITY_SQL.page(sql))
38
+ .bind(...params, limit + 1)
39
+ .all();
40
+ const truncated = rows.length > limit;
41
+ return { events: rows.slice(0, limit).map(toStreamEvent), limit, truncated };
42
+ },
43
+ async count(query = {}) {
44
+ const { sql, params } = await stream(query);
45
+ const row = await db
46
+ .prepare(ACTIVITY_SQL.count(sql))
47
+ .bind(...params)
48
+ .first();
49
+ return row?.n ?? 0;
50
+ },
51
+ async facets(query = {}, limit = MAX_FACET) {
52
+ const { sql, params } = await stream(query);
53
+ const capped = facetLimit(limit);
54
+ const dimension = async (column) => (await db
55
+ .prepare(ACTIVITY_SQL.facet(sql, column))
56
+ .bind(...params, capped)
57
+ .all()).results.map((row) => ({ value: row.value, count: row.count }));
58
+ return {
59
+ actions: await dimension("action"),
60
+ actors: await dimension("actor"),
61
+ kinds: await dimension("kind"),
62
+ };
63
+ },
64
+ async sources() {
65
+ return ["log", ...(await liveSources()).map((source) => source.name)];
66
+ },
67
+ };
68
+ }
@@ -1,7 +1,5 @@
1
1
  import type { Migration } from "../migration/types.js";
2
- /** The one table this primitive owns. Exported so an app can declare a retention
3
- * target over it (see this module's `index.ts` for the recipe). */
4
- export declare const ACTIVITY_EVENTS_TABLE = "activity_events";
2
+ export { ACTIVITY_DDL, ACTIVITY_EVENTS_TABLE } from "./schema.js";
5
3
  /**
6
4
  * ONE append-only table per app, not a shadow table per business table.
7
5
  *
@@ -1,6 +1,8 @@
1
- /** The one table this primitive owns. Exported so an app can declare a retention
2
- * target over it (see this module's `index.ts` for the recipe). */
3
- export const ACTIVITY_EVENTS_TABLE = "activity_events";
1
+ import { ACTIVITY_DDL } from "./schema.js";
2
+ // The name 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/activity/d1` re-exports
4
+ // them). Re-exported here so every existing import of this module keeps working.
5
+ export { ACTIVITY_DDL, ACTIVITY_EVENTS_TABLE } from "./schema.js";
4
6
  /**
5
7
  * ONE append-only table per app, not a shadow table per business table.
6
8
  *
@@ -21,35 +23,8 @@ export const ACTIVITY_MIGRATIONS = [
21
23
  {
22
24
  name: "0001_activity_events",
23
25
  up(db) {
24
- db.run(`
25
- CREATE TABLE IF NOT EXISTS ${ACTIVITY_EVENTS_TABLE} (
26
- event_id INTEGER PRIMARY KEY AUTOINCREMENT,
27
- at TEXT NOT NULL,
28
- app TEXT NOT NULL,
29
- actor TEXT NOT NULL,
30
- action TEXT NOT NULL,
31
- kind TEXT,
32
- resource_id TEXT,
33
- outcome TEXT NOT NULL DEFAULT 'ok'
34
- CHECK (outcome IN ('ok', 'denied', 'error')),
35
- note TEXT NOT NULL DEFAULT '',
36
- data TEXT
37
- )
38
- `);
39
- // The stream itself, newest first — the read every screen starts from.
40
- db.run(`CREATE INDEX IF NOT EXISTS idx_activity_events_at
41
- ON ${ACTIVITY_EVENTS_TABLE} (at DESC, event_id DESC)`);
42
- // "Everything this person did" — the offboarding and the who-broke-it
43
- // question, and the one a fleet-wide search runs first.
44
- db.run(`CREATE INDEX IF NOT EXISTS idx_activity_events_actor
45
- ON ${ACTIVITY_EVENTS_TABLE} (actor, event_id DESC)`);
46
- // "Everything that ever happened to this record."
47
- db.run(`CREATE INDEX IF NOT EXISTS idx_activity_events_resource
48
- ON ${ACTIVITY_EVENTS_TABLE} (kind, resource_id, event_id DESC)`);
49
- // "Everything backups ever did" — an action-prefix filter is a range
50
- // scan on this index, which is why the vocabulary is dotted.
51
- db.run(`CREATE INDEX IF NOT EXISTS idx_activity_events_action
52
- ON ${ACTIVITY_EVENTS_TABLE} (action, event_id DESC)`);
26
+ for (const statement of ACTIVITY_DDL)
27
+ db.run(statement);
53
28
  },
54
29
  },
55
30
  ];
@@ -0,0 +1,123 @@
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 { type ActivityEvent, type ActivityEventInput, type ActivityOutcome } from "cursedbelt-core/activity/model";
11
+ /**
12
+ * A value a source may bind. The intersection of what `bun:sqlite` and D1 both accept, so a
13
+ * source written once contributes to either log.
14
+ */
15
+ export type ActivityBindable = string | number | bigint | boolean | null | Uint8Array;
16
+ /**
17
+ * A read-only stream contributor: a SELECT projecting some other table into the
18
+ * union's eleven columns, in this order and with these names —
19
+ *
20
+ * `source, seq, at, app, actor, action, kind, resource_id, outcome, note, data`
21
+ *
22
+ * `seq` is that table's own row id: it breaks ties within one `at`, and it is
23
+ * what makes the event id (`"<source>:<seq>"`) stable and unique across sources.
24
+ */
25
+ export interface ActivitySource {
26
+ /** Short, stable, unique within the log — it prefixes every id it produces. */
27
+ name: string;
28
+ /** The projection. See the column contract above. */
29
+ sql: string;
30
+ /** Bound in order, before any filter parameter. */
31
+ params?: readonly ActivityBindable[];
32
+ /**
33
+ * A table this projection needs. When it does not exist the source is skipped
34
+ * for that call rather than throwing — see property 3 in `./store.ts`'s header.
35
+ */
36
+ requiresTable?: string;
37
+ }
38
+ /** Everything the stream can be narrowed by. Every field is optional; the empty
39
+ * query is "the newest `limit` events". */
40
+ export interface ActivityQuery {
41
+ /** ISO-8601, inclusive. */
42
+ since?: string;
43
+ /** ISO-8601, inclusive. */
44
+ until?: string;
45
+ /** Case-insensitive exact match on the account. */
46
+ actor?: string;
47
+ /** Exact, or a PREFIX when it ends in a dot: `"share."` matches `share.grant`. */
48
+ action?: string;
49
+ kind?: string;
50
+ resourceId?: string;
51
+ outcome?: ActivityOutcome;
52
+ /** Restrict to one contributor (`"log"`, `"share"`). */
53
+ source?: string;
54
+ /** Substring, over actor + action + note + resource id. */
55
+ q?: string;
56
+ /** Default 100, hard cap 1000. */
57
+ limit?: number;
58
+ }
59
+ export interface ActivityPage {
60
+ events: ActivityEvent[];
61
+ /** The limit actually applied, after defaulting and capping. */
62
+ limit: number;
63
+ /** There is at least one more row older than the last one returned. */
64
+ truncated: boolean;
65
+ }
66
+ /** One dimension of "what is in this result", for the filter UI. */
67
+ export interface ActivityFacet {
68
+ value: string;
69
+ count: number;
70
+ }
71
+ export interface ActivityFacets {
72
+ actions: ActivityFacet[];
73
+ actors: ActivityFacet[];
74
+ kinds: ActivityFacet[];
75
+ }
76
+ export interface StreamRow {
77
+ source: string;
78
+ seq: number;
79
+ at: string;
80
+ app: string;
81
+ actor: string;
82
+ action: string;
83
+ kind: string | null;
84
+ resource_id: string | null;
85
+ outcome: ActivityOutcome;
86
+ note: string;
87
+ data: string | null;
88
+ }
89
+ export declare const MAX_FACET = 25;
90
+ export declare const toStreamEvent: (row: StreamRow) => ActivityEvent;
91
+ export declare const ACTIVITY_SQL: {
92
+ /** Binds at, app, actor, action, kind, resource_id, outcome, note, data. */
93
+ readonly insert: "INSERT INTO activity_events\n\t\t (at, app, actor, action, kind, resource_id, outcome, note, data)\n\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
94
+ readonly tableExists: "SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?";
95
+ readonly ownSelect: "SELECT 'log' AS source, event_id AS seq, at, app, actor, action,\n\t kind, resource_id, outcome, note, data\n\t FROM activity_events";
96
+ /** One row past the limit, so "there is more" is a fact rather than a guess made from
97
+ * `rows.length === limit`. Binds the stream's params, then `limit + 1`. */
98
+ readonly page: (stream: string) => string;
99
+ readonly count: (stream: string) => string;
100
+ /** Binds the stream's params, then the facet cap. `column` is one of three literals. */
101
+ readonly facet: (stream: string, column: "action" | "actor" | "kind") => string;
102
+ };
103
+ /** The limit a page actually applies: default 100, never above 1000 or below 1. */
104
+ export declare const pageLimit: (limit: number | undefined) => number;
105
+ /** The facet cap: default {@link MAX_FACET}, never above it or below 1. */
106
+ export declare const facetLimit: (limit: number) => number;
107
+ /**
108
+ * The union of the log's own rows and every LIVE source, plus the WHERE the caller asked for,
109
+ * plus their parameters in bind order (sources first — they are inside the FROM).
110
+ */
111
+ export declare function buildStream(query: ActivityQuery, liveSources: readonly ActivitySource[]): {
112
+ sql: string;
113
+ params: ActivityBindable[];
114
+ };
115
+ /**
116
+ * Validate one event for the write and return the row to bind plus the event as it will be
117
+ * read back (minus its id, which only the insert can know). Throws on an action that cannot be
118
+ * filtered or an event that cannot say who — property 4 of `./store.ts`'s header.
119
+ */
120
+ export declare function prepareRecord(app: string, input: ActivityEventInput, now: () => string): {
121
+ binds: [string, string, string, string, string | null, string | null, string, string, string | null];
122
+ event: Omit<ActivityEvent, "id">;
123
+ };
@@ -0,0 +1,171 @@
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 { isActivityAction, isActivityOutcome, } from "cursedbelt-core/activity/model";
11
+ import { ACTIVITY_EVENTS_TABLE } from "./schema.js";
12
+ const DEFAULT_LIMIT = 100;
13
+ const MAX_LIMIT = 1000;
14
+ export const MAX_FACET = 25;
15
+ /** `%` and `_` are wildcards and `\` is the escape — a note containing any of
16
+ * them must still search literally. */
17
+ const likeContains = (value) => `%${value.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
18
+ export const toStreamEvent = (row) => ({
19
+ id: `${row.source}:${row.seq}`,
20
+ source: row.source,
21
+ at: row.at,
22
+ app: row.app,
23
+ actor: row.actor,
24
+ action: row.action,
25
+ resource: row.kind === null || row.resource_id === null ? null : { app: row.app, kind: row.kind, id: row.resource_id },
26
+ outcome: isActivityOutcome(row.outcome) ? row.outcome : "ok",
27
+ note: row.note ?? "",
28
+ data: parseData(row.data),
29
+ });
30
+ /** A row whose `data` is unreadable must not take the page down with it — the
31
+ * event still says who did what, which is the part that matters. */
32
+ function parseData(raw) {
33
+ if (!raw)
34
+ return null;
35
+ try {
36
+ const parsed = JSON.parse(raw);
37
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
38
+ ? parsed
39
+ : { value: parsed };
40
+ }
41
+ catch {
42
+ return { unparsed: raw };
43
+ }
44
+ }
45
+ export const ACTIVITY_SQL = {
46
+ /** Binds at, app, actor, action, kind, resource_id, outcome, note, data. */
47
+ insert: `INSERT INTO ${ACTIVITY_EVENTS_TABLE}
48
+ (at, app, actor, action, kind, resource_id, outcome, note, data)
49
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
50
+ tableExists: "SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?",
51
+ ownSelect: `SELECT 'log' AS source, event_id AS seq, at, app, actor, action,
52
+ kind, resource_id, outcome, note, data
53
+ FROM ${ACTIVITY_EVENTS_TABLE}`,
54
+ /** One row past the limit, so "there is more" is a fact rather than a guess made from
55
+ * `rows.length === limit`. Binds the stream's params, then `limit + 1`. */
56
+ page: (stream) => `${stream} ORDER BY at DESC, seq DESC LIMIT ?`,
57
+ count: (stream) => `SELECT COUNT(*) AS n FROM (${stream})`,
58
+ /** Binds the stream's params, then the facet cap. `column` is one of three literals. */
59
+ facet: (stream, column) => `SELECT ${column} AS value, COUNT(*) AS count FROM (${stream})
60
+ WHERE ${column} IS NOT NULL AND ${column} <> ''
61
+ GROUP BY ${column} ORDER BY count DESC, value ASC LIMIT ?`,
62
+ };
63
+ /** The limit a page actually applies: default 100, never above 1000 or below 1. */
64
+ export const pageLimit = (limit) => Math.min(MAX_LIMIT, Math.max(1, Math.floor(limit ?? DEFAULT_LIMIT)));
65
+ /** The facet cap: default {@link MAX_FACET}, never above it or below 1. */
66
+ export const facetLimit = (limit) => Math.min(MAX_FACET, Math.max(1, Math.floor(limit)));
67
+ /**
68
+ * The union of the log's own rows and every LIVE source, plus the WHERE the caller asked for,
69
+ * plus their parameters in bind order (sources first — they are inside the FROM).
70
+ */
71
+ export function buildStream(query, liveSources) {
72
+ const params = [];
73
+ const selects = [ACTIVITY_SQL.ownSelect];
74
+ for (const source of liveSources) {
75
+ if (query.source && query.source !== source.name)
76
+ continue;
77
+ selects.push(source.sql);
78
+ params.push(...(source.params ?? []));
79
+ }
80
+ const where = [];
81
+ if (query.source) {
82
+ where.push("source = ?");
83
+ params.push(query.source);
84
+ }
85
+ if (query.since) {
86
+ where.push("at >= ?");
87
+ params.push(query.since);
88
+ }
89
+ if (query.until) {
90
+ where.push("at <= ?");
91
+ params.push(query.until);
92
+ }
93
+ if (query.actor) {
94
+ where.push("LOWER(actor) = LOWER(?)");
95
+ params.push(query.actor);
96
+ }
97
+ if (query.action) {
98
+ if (query.action.endsWith(".")) {
99
+ where.push("action LIKE ? ESCAPE '\\'");
100
+ params.push(`${query.action.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`);
101
+ }
102
+ else {
103
+ where.push("action = ?");
104
+ params.push(query.action);
105
+ }
106
+ }
107
+ if (query.kind) {
108
+ where.push("kind = ?");
109
+ params.push(query.kind);
110
+ }
111
+ if (query.resourceId) {
112
+ where.push("resource_id = ?");
113
+ params.push(query.resourceId);
114
+ }
115
+ if (query.outcome) {
116
+ where.push("outcome = ?");
117
+ params.push(query.outcome);
118
+ }
119
+ if (query.q) {
120
+ where.push("(actor LIKE ? ESCAPE '\\' OR action LIKE ? ESCAPE '\\' OR note LIKE ? ESCAPE '\\' OR IFNULL(resource_id, '') LIKE ? ESCAPE '\\')");
121
+ const like = likeContains(query.q);
122
+ params.push(like, like, like, like);
123
+ }
124
+ // `query.source` naming a contributor that is not live leaves only the own
125
+ // select, filtered to a source it cannot be — an empty result, which is the
126
+ // honest answer rather than every row.
127
+ const sql = `SELECT * FROM (${selects.join("\n UNION ALL\n")}) AS stream${where.length ? ` WHERE ${where.join(" AND ")}` : ""}`;
128
+ return { sql, params };
129
+ }
130
+ /**
131
+ * Validate one event for the write and return the row to bind plus the event as it will be
132
+ * read back (minus its id, which only the insert can know). Throws on an action that cannot be
133
+ * filtered or an event that cannot say who — property 4 of `./store.ts`'s header.
134
+ */
135
+ export function prepareRecord(app, input, now) {
136
+ if (!isActivityAction(input.action)) {
137
+ throw new Error(`activity: "${input.action}" is not an action — use lowercase subject.verb, e.g. "backup.delete"`);
138
+ }
139
+ const actor = input.actor?.trim();
140
+ if (!actor) {
141
+ // An event that cannot say who is a log line, not an audit trail.
142
+ throw new Error(`activity: ${input.action} has no actor`);
143
+ }
144
+ const at = input.at ?? now();
145
+ const outcome = input.outcome ?? "ok";
146
+ const data = input.data ? JSON.stringify(input.data) : null;
147
+ return {
148
+ binds: [
149
+ at,
150
+ app,
151
+ actor,
152
+ input.action,
153
+ input.resource?.kind ?? null,
154
+ input.resource?.id ?? null,
155
+ outcome,
156
+ input.note ?? "",
157
+ data,
158
+ ],
159
+ event: {
160
+ source: "log",
161
+ at,
162
+ app,
163
+ actor,
164
+ action: input.action,
165
+ resource: input.resource ? { app, ...input.resource } : null,
166
+ outcome,
167
+ note: input.note ?? "",
168
+ data: input.data ?? null,
169
+ },
170
+ };
171
+ }
@@ -0,0 +1,13 @@
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
+ /** The one table this primitive owns. Exported so an app can declare a retention
10
+ * target over it (see this module's `index.ts` for the recipe). */
11
+ export declare const ACTIVITY_EVENTS_TABLE = "activity_events";
12
+ /** Every statement of the `0001_activity_events` migration, in order. Idempotent. */
13
+ export declare const ACTIVITY_DDL: readonly string[];
@@ -0,0 +1,43 @@
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
+ /** The one table this primitive owns. Exported so an app can declare a retention
10
+ * target over it (see this module's `index.ts` for the recipe). */
11
+ export const ACTIVITY_EVENTS_TABLE = "activity_events";
12
+ /** Every statement of the `0001_activity_events` migration, in order. Idempotent. */
13
+ export const ACTIVITY_DDL = [
14
+ `
15
+ CREATE TABLE IF NOT EXISTS ${ACTIVITY_EVENTS_TABLE} (
16
+ event_id INTEGER PRIMARY KEY AUTOINCREMENT,
17
+ at TEXT NOT NULL,
18
+ app TEXT NOT NULL,
19
+ actor TEXT NOT NULL,
20
+ action TEXT NOT NULL,
21
+ kind TEXT,
22
+ resource_id TEXT,
23
+ outcome TEXT NOT NULL DEFAULT 'ok'
24
+ CHECK (outcome IN ('ok', 'denied', 'error')),
25
+ note TEXT NOT NULL DEFAULT '',
26
+ data TEXT
27
+ )
28
+ `,
29
+ // The stream itself, newest first — the read every screen starts from.
30
+ `CREATE INDEX IF NOT EXISTS idx_activity_events_at
31
+ ON ${ACTIVITY_EVENTS_TABLE} (at DESC, event_id DESC)`,
32
+ // "Everything this person did" — the offboarding and the who-broke-it
33
+ // question, and the one a fleet-wide search runs first.
34
+ `CREATE INDEX IF NOT EXISTS idx_activity_events_actor
35
+ ON ${ACTIVITY_EVENTS_TABLE} (actor, event_id DESC)`,
36
+ // "Everything that ever happened to this record."
37
+ `CREATE INDEX IF NOT EXISTS idx_activity_events_resource
38
+ ON ${ACTIVITY_EVENTS_TABLE} (kind, resource_id, event_id DESC)`,
39
+ // "Everything backups ever did" — an action-prefix filter is a range
40
+ // scan on this index, which is why the vocabulary is dotted.
41
+ `CREATE INDEX IF NOT EXISTS idx_activity_events_action
42
+ ON ${ACTIVITY_EVENTS_TABLE} (action, event_id DESC)`,
43
+ ];
@@ -1,4 +1,4 @@
1
- import type { ActivitySource } from "./store.js";
1
+ import type { ActivitySource } from "./query.js";
2
2
  export interface ShareEventsSourceOptions {
3
3
  /** The satellite this log speaks for — the same value passed to
4
4
  * `createActivityLog`, used where a group event names no resource. */
@@ -25,7 +25,7 @@
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";
28
+ import { SHARE_EVENTS_TABLE } from "../sharing/schema.js";
29
29
  export function shareEventsSource(options) {
30
30
  const table = options.table ?? SHARE_EVENTS_TABLE;
31
31
  const name = options.name ?? "share";
@@ -33,68 +33,10 @@
33
33
  * 4. **`action` is validated at the write.** An action vocabulary that drifts
34
34
  * cannot be filtered, and filtering is the whole point of the column.
35
35
  */
36
- import type { Database, SQLQueryBindings } from "bun:sqlite";
37
- import { type ActivityEvent, type ActivityEventInput, type ActivityOutcome } from "cursedbelt-core/activity/model";
38
- /**
39
- * A read-only stream contributor: a SELECT projecting some other table into the
40
- * union's eleven columns, in this order and with these names —
41
- *
42
- * `source, seq, at, app, actor, action, kind, resource_id, outcome, note, data`
43
- *
44
- * `seq` is that table's own row id: it breaks ties within one `at`, and it is
45
- * what makes the event id (`"<source>:<seq>"`) stable and unique across sources.
46
- */
47
- export interface ActivitySource {
48
- /** Short, stable, unique within the log — it prefixes every id it produces. */
49
- name: string;
50
- /** The projection. See the column contract above. */
51
- sql: string;
52
- /** Bound in order, before any filter parameter. */
53
- params?: readonly SQLQueryBindings[];
54
- /**
55
- * A table this projection needs. When it does not exist the source is skipped
56
- * for that call rather than throwing — see property 3 in the header.
57
- */
58
- requiresTable?: string;
59
- }
60
- /** Everything the stream can be narrowed by. Every field is optional; the empty
61
- * query is "the newest `limit` events". */
62
- export interface ActivityQuery {
63
- /** ISO-8601, inclusive. */
64
- since?: string;
65
- /** ISO-8601, inclusive. */
66
- until?: string;
67
- /** Case-insensitive exact match on the account. */
68
- actor?: string;
69
- /** Exact, or a PREFIX when it ends in a dot: `"share."` matches `share.grant`. */
70
- action?: string;
71
- kind?: string;
72
- resourceId?: string;
73
- outcome?: ActivityOutcome;
74
- /** Restrict to one contributor (`"log"`, `"share"`). */
75
- source?: string;
76
- /** Substring, over actor + action + note + resource id. */
77
- q?: string;
78
- /** Default 100, hard cap 1000. */
79
- limit?: number;
80
- }
81
- export interface ActivityPage {
82
- events: ActivityEvent[];
83
- /** The limit actually applied, after defaulting and capping. */
84
- limit: number;
85
- /** There is at least one more row older than the last one returned. */
86
- truncated: boolean;
87
- }
88
- /** One dimension of "what is in this result", for the filter UI. */
89
- export interface ActivityFacet {
90
- value: string;
91
- count: number;
92
- }
93
- export interface ActivityFacets {
94
- actions: ActivityFacet[];
95
- actors: ActivityFacet[];
96
- kinds: ActivityFacet[];
97
- }
36
+ import type { Database } from "bun:sqlite";
37
+ import type { ActivityEvent, ActivityEventInput } from "cursedbelt-core/activity/model";
38
+ import { type ActivityFacets, type ActivityPage, type ActivityQuery, type ActivitySource } from "./query.js";
39
+ export type { ActivityBindable, ActivityFacet, ActivityFacets, ActivityPage, ActivityQuery, ActivitySource, } from "./query.js";
98
40
  export interface ActivityLogOptions {
99
41
  db: Database;
100
42
  /** The satellite this log speaks for. Fixed at construction, so a caller