@happyvertical/smrt-core 0.37.7 → 0.37.9

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 (88) hide show
  1. package/AGENTS.md +25 -6
  2. package/dist/change-feed.d.ts +261 -0
  3. package/dist/change-feed.d.ts.map +1 -0
  4. package/dist/change-feed.js +482 -0
  5. package/dist/change-feed.js.map +1 -0
  6. package/dist/class.d.ts.map +1 -1
  7. package/dist/class.js +4 -2
  8. package/dist/class.js.map +1 -1
  9. package/dist/collection.js +2 -2
  10. package/dist/consumer-plugin/index.d.ts.map +1 -1
  11. package/dist/consumer-plugin/index.js +24 -19
  12. package/dist/consumer-plugin/index.js.map +1 -1
  13. package/dist/dispatch/bus.js +2 -2
  14. package/dist/dispatch/index.d.ts +1 -1
  15. package/dist/dispatch/index.d.ts.map +1 -1
  16. package/dist/dispatch/index.js +2 -2
  17. package/dist/dispatch/tenant-resolver.d.ts +23 -0
  18. package/dist/dispatch/tenant-resolver.d.ts.map +1 -1
  19. package/dist/dispatch/tenant-resolver.js +33 -1
  20. package/dist/dispatch/tenant-resolver.js.map +1 -1
  21. package/dist/generated-client-runtime.d.ts +26 -0
  22. package/dist/generated-client-runtime.d.ts.map +1 -0
  23. package/dist/generated-client-runtime.js +88 -0
  24. package/dist/generated-client-runtime.js.map +1 -0
  25. package/dist/generators/changes-route.d.ts +52 -0
  26. package/dist/generators/changes-route.d.ts.map +1 -0
  27. package/dist/generators/changes-route.js +111 -0
  28. package/dist/generators/changes-route.js.map +1 -0
  29. package/dist/generators/conditional-get.d.ts +12 -0
  30. package/dist/generators/conditional-get.d.ts.map +1 -1
  31. package/dist/generators/conditional-get.js +30 -2
  32. package/dist/generators/conditional-get.js.map +1 -1
  33. package/dist/generators/rest.d.ts +58 -0
  34. package/dist/generators/rest.d.ts.map +1 -1
  35. package/dist/generators/rest.js +114 -21
  36. package/dist/generators/rest.js.map +1 -1
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +6 -5
  40. package/dist/manifest/manifest-loader.d.ts +10 -1
  41. package/dist/manifest/manifest-loader.d.ts.map +1 -1
  42. package/dist/manifest/manifest-loader.js +18 -5
  43. package/dist/manifest/manifest-loader.js.map +1 -1
  44. package/dist/manifest/static-manifest.js +2 -2
  45. package/dist/manifest/static-manifest.js.map +1 -1
  46. package/dist/manifest/store.js +1 -1
  47. package/dist/manifest/test-manifest-stub.d.ts.map +1 -1
  48. package/dist/manifest/test-manifest-stub.js +880 -105
  49. package/dist/manifest/test-manifest-stub.js.map +1 -1
  50. package/dist/manifest.json +2 -2
  51. package/dist/object.js +2 -2
  52. package/dist/prebuild/index.d.ts.map +1 -1
  53. package/dist/prebuild/index.js +14 -7
  54. package/dist/prebuild/index.js.map +1 -1
  55. package/dist/registry/shared-state.d.ts +8 -1
  56. package/dist/registry/shared-state.d.ts.map +1 -1
  57. package/dist/registry/shared-state.js +11 -3
  58. package/dist/registry/shared-state.js.map +1 -1
  59. package/dist/smrt-knowledge.json +6 -6
  60. package/dist/system/schema.d.ts +24 -1
  61. package/dist/system/schema.d.ts.map +1 -1
  62. package/dist/system/schema.js +45 -3
  63. package/dist/system/schema.js.map +1 -1
  64. package/dist/testing/database.js +1 -1
  65. package/dist/utils/stack-frames.d.ts +50 -0
  66. package/dist/utils/stack-frames.d.ts.map +1 -0
  67. package/dist/utils/stack-frames.js +64 -0
  68. package/dist/utils/stack-frames.js.map +1 -0
  69. package/dist/vite-plugin/changes-route.d.ts +10 -0
  70. package/dist/vite-plugin/changes-route.d.ts.map +1 -0
  71. package/dist/vite-plugin/changes-route.js +174 -0
  72. package/dist/vite-plugin/changes-route.js.map +1 -0
  73. package/dist/vite-plugin/index.d.ts +7 -0
  74. package/dist/vite-plugin/index.d.ts.map +1 -1
  75. package/dist/vite-plugin/index.js +38 -20
  76. package/dist/vite-plugin/index.js.map +1 -1
  77. package/dist/vite-plugin/route-header.d.ts +10 -0
  78. package/dist/vite-plugin/route-header.d.ts.map +1 -0
  79. package/dist/vite-plugin/route-header.js +14 -0
  80. package/dist/vite-plugin/route-header.js.map +1 -0
  81. package/dist/vite-plugin/sveltekit-generator.d.ts +7 -0
  82. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  83. package/dist/vite-plugin/sveltekit-generator.js +46 -20
  84. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  85. package/dist/vite-plugin/sync-apply-route.d.ts.map +1 -1
  86. package/dist/vite-plugin/sync-apply-route.js +2 -2
  87. package/dist/vite-plugin/sync-apply-route.js.map +1 -1
  88. package/package.json +4 -4
@@ -0,0 +1,482 @@
1
+ import { resolveDbCacheKey } from "./collection-cache.js";
2
+ import { resolveDispatchTenantScope } from "./dispatch/tenant-resolver.js";
3
+ import { GlobalInterceptors } from "./interceptors.js";
4
+ import { detectEngine } from "./schema/ddl/index.js";
5
+ import { CREATE_SMRT_CHANGES_TABLE } from "./system/schema.js";
6
+ import { createLogger } from "@happyvertical/logger";
7
+ //#region src/change-feed.ts
8
+ /**
9
+ * Adapter-agnostic change feed — the framework's change-observation spine
10
+ * (issue #1758, parent PRD #1755).
11
+ *
12
+ * Every framework `save()`/`delete()` appends exactly one row to the
13
+ * `_smrt_changes` system table (monotonic per-database sequence, table name,
14
+ * row id, operation, tenant id, timestamp). Deletes are recorded as
15
+ * tombstones (`operation: 'delete'`), distinguishable from updates. One read
16
+ * interface — {@link getChangesSince} — returns changes after a cursor,
17
+ * filterable by table and tenant, and serves three eventual consumers:
18
+ * client delta pull, the SSE push channel, and the per-table version source
19
+ * backing ETags.
20
+ *
21
+ * ## Cursor semantics (the precise guarantee)
22
+ *
23
+ * Sequences are allocated *inside* the append statement as
24
+ * `COALESCE(MAX(seq), 0) + 1` over the feed table itself, with a retry on
25
+ * primary-key conflict. `MAX(seq)` only observes committed rows, so a row
26
+ * with sequence `N` can only be inserted while every row with sequence
27
+ * `< N` is already committed (a conflicting in-flight allocation of the same
28
+ * value blocks, then retries). Committed rows therefore always form a
29
+ * contiguous run ending at `MAX(seq)` — the **committed horizon**. Sequence
30
+ * order equals commit order; out-of-order commit visibility (the classic
31
+ * MVCC race that makes native identity/serial columns unsafe as cursors
32
+ * under concurrent writers) cannot occur.
33
+ *
34
+ * {@link getChangesSince} reads the committed horizon `H = MAX(seq)`, then
35
+ * returns matching rows with `since < seq <= H` (bounded by `limit`), and a
36
+ * `cursor` that is either `H` (page exhaustive) or the last returned `seq`
37
+ * (page limited). Because no change can ever commit at or below an observed
38
+ * horizon after it was observed, polling with returned cursors misses no
39
+ * committed change and never returns the same change twice — under any
40
+ * number of concurrent writers, identically on SQLite, Postgres and DuckDB.
41
+ * This is the design reason the allocator is `MAX+1` rather than a native
42
+ * AUTOINCREMENT/identity column: identity values are allocated before
43
+ * commit, so a reader on Postgres could observe seq 101 while seq 100 is
44
+ * still uncommitted and advance its cursor past it. (No shared
45
+ * auto-increment mechanism exists in the system-table schema path either;
46
+ * see `system/schema.ts`.)
47
+ *
48
+ * Contention note: appends serialize on the head of the log. Each append is
49
+ * a single small INSERT (issued from the write path *after* the user's row
50
+ * was written), so the serialization window is one statement; conflicts
51
+ * resolve with a bounded retry loop and are impossible on single-writer
52
+ * engines (SQLite).
53
+ *
54
+ * ## Failure semantics
55
+ *
56
+ * A feed-write failure must never fail the user's write. The interceptor
57
+ * wraps the append in a try/catch: on failure it logs a warning (deduped per
58
+ * database) and continues. The trade-off is availability of the user's
59
+ * write over completeness of the feed — consumers already need a
60
+ * full-resync path for cursors older than the retention window, and the
61
+ * same path covers a (rare) dropped feed row. When the user's write runs
62
+ * inside a caller-managed transaction on the same handle, the append joins
63
+ * it and shares its fate (a rollback removes the change row with the data
64
+ * row).
65
+ *
66
+ * ## Known gaps (documented in the PRD)
67
+ *
68
+ * - Writes that bypass framework mutation paths (raw SQL) are invisible to
69
+ * the feed — the same accepted gap as the #1499 collection cache.
70
+ * {@link bumpChangeFeed} is the manual escape hatch: out-of-band writers
71
+ * append a synthetic change row for the affected table.
72
+ * - **Spurious `update` entries**: `SmrtObject.save()` has no dirty-check,
73
+ * so a field-unchanged `.save()` still appends an `update` row. This is
74
+ * by design — the writer observes writes, not diffs (it has no old-row
75
+ * access), so the feed faithfully mirrors the write path. Diff-aware
76
+ * paths (`getOrUpsert()`'s diff guard, the sync-apply endpoint's no-op
77
+ * detection) short-circuit before `save()` and append nothing.
78
+ * Subscribers must tolerate spurious entries; they are convergent — a
79
+ * re-fetch returns identical data.
80
+ *
81
+ * ## Retention
82
+ *
83
+ * The log is append-only and grows with write volume. {@link pruneChangeFeed}
84
+ * bounds it by age (`maxAgeMs`) and/or row count (`maxRows`); call it from a
85
+ * scheduled job sized so the retention window comfortably exceeds the
86
+ * slowest consumer's polling interval. Pruning deletes oldest-first and
87
+ * always retains the newest entry, so retained sequences stay a contiguous
88
+ * `[floor..horizon]` run — which is how {@link getChangesSince} *detects* a
89
+ * consumer whose cursor predates the retained window and answers it with
90
+ * `resyncRequired: true` instead of silently skipping the pruned changes.
91
+ *
92
+ * @see https://github.com/happyvertical/smrt/issues/1758
93
+ * @packageDocumentation
94
+ */
95
+ var logger = createLogger({ level: "info" });
96
+ /** Name of the append-only change-feed system table. */
97
+ var CHANGE_FEED_TABLE = "_smrt_changes";
98
+ /** Interceptor name of the framework's change-feed writer. */
99
+ var CHANGE_FEED_INTERCEPTOR_NAME = "smrt-change-feed";
100
+ /** Default page size for {@link getChangesSince}. */
101
+ var DEFAULT_CHANGES_LIMIT = 500;
102
+ /** Hard cap on the page size for {@link getChangesSince}. */
103
+ var MAX_CHANGES_LIMIT = 5e3;
104
+ /**
105
+ * Maximum append attempts under sequence contention. Conflicts only occur
106
+ * with concurrent writers on MVCC engines and resolve as soon as the
107
+ * blocking transaction commits, so a small bound is ample.
108
+ */
109
+ var MAX_APPEND_ATTEMPTS = 20;
110
+ var VALID_OPERATIONS = /* @__PURE__ */ new Set([
111
+ "create",
112
+ "update",
113
+ "delete"
114
+ ]);
115
+ function getEngine(db) {
116
+ const withConfig = db;
117
+ return detectEngine(db.url || withConfig.config?.url || "", withConfig.type || withConfig.config?.type);
118
+ }
119
+ /**
120
+ * Positional placeholder factory: Postgres uses `$n`, SQLite/DuckDB use `?`.
121
+ */
122
+ function placeholders(db) {
123
+ return getEngine(db) === "postgres" ? (index) => `$${index}` : () => "?";
124
+ }
125
+ function getQueryRows(result) {
126
+ if (Array.isArray(result)) return result;
127
+ if (result && typeof result === "object" && "rows" in result) {
128
+ const rows = result.rows;
129
+ if (Array.isArray(rows)) return rows;
130
+ }
131
+ return [];
132
+ }
133
+ function isUniqueViolation(error) {
134
+ const message = error instanceof Error ? error.message : String(error ?? "");
135
+ return /unique constraint/i.test(message) || /duplicate key/i.test(message) || /primary key constraint/i.test(message) || /constraint error/i.test(message);
136
+ }
137
+ /**
138
+ * Ensure the `_smrt_changes` system table exists on a database handle that
139
+ * may not have passed through framework initialization (e.g. a raw handle
140
+ * given to the REST generator). Idempotent (`CREATE ... IF NOT EXISTS`) and
141
+ * guarded to run once per handle. Databases initialized through the
142
+ * framework already have the table via the system-table bootstrap.
143
+ */
144
+ var ensuredHandles = /* @__PURE__ */ new WeakSet();
145
+ async function ensureChangeFeedTable(db) {
146
+ if (ensuredHandles.has(db)) return;
147
+ const statements = CREATE_SMRT_CHANGES_TABLE.split(";").map((statement) => statement.trim()).filter((statement) => statement.length > 0);
148
+ for (const statement of statements) await db.query(statement);
149
+ ensuredHandles.add(db);
150
+ }
151
+ /**
152
+ * Append one change entry with a database-allocated, strictly monotonic
153
+ * sequence.
154
+ *
155
+ * The sequence is allocated inside the INSERT itself
156
+ * (`COALESCE(MAX(seq), 0) + 1`) and retried on primary-key conflict, which
157
+ * keeps committed sequences contiguous and makes commit order equal
158
+ * sequence order — the property the cursor guarantee rests on (see the
159
+ * module docs). Throws after {@link MAX_APPEND_ATTEMPTS} consecutive
160
+ * conflicts or on any non-conflict database error; the framework's
161
+ * interceptor catches and logs instead of failing the user's write.
162
+ *
163
+ * **Transaction assumption.** The conflict retry assumes an autocommit
164
+ * handle, which is the default framework path: `save()`/`delete()` run their
165
+ * statements as independent autocommit calls, so a conflicting append aborts
166
+ * nothing and the retry recomputes `MAX(seq)` against the committed head. If a
167
+ * caller instead wraps `save()` in its own transaction on an MVCC engine
168
+ * (e.g. Postgres), a duplicate-key error can abort the *surrounding*
169
+ * transaction — the retry is then futile and the caller's write rolls back.
170
+ * Callers wrapping mutations in a transaction under genuine seq-head write
171
+ * contention should disable the feed writer for that path or accept that
172
+ * risk. Savepoint-scoped protection is a possible follow-up; it is
173
+ * intentionally out of scope for the v1 spine.
174
+ */
175
+ async function appendChange(db, input) {
176
+ const table = input.table?.trim();
177
+ if (!table) throw new Error("appendChange requires a non-empty table name");
178
+ const operation = input.operation ?? "update";
179
+ if (!VALID_OPERATIONS.has(operation)) throw new Error(`appendChange operation must be one of create/update/delete, got '${String(input.operation)}'`);
180
+ const p = placeholders(db);
181
+ const sql = `INSERT INTO ${CHANGE_FEED_TABLE} (seq, table_name, row_id, operation, tenant_id, created_at) SELECT COALESCE(MAX(seq), 0) + 1, ${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)} FROM ${CHANGE_FEED_TABLE}`;
182
+ const params = [
183
+ table,
184
+ input.rowId ?? null,
185
+ operation,
186
+ input.tenantId ?? null,
187
+ (/* @__PURE__ */ new Date()).toISOString()
188
+ ];
189
+ for (let attempt = 1; attempt <= MAX_APPEND_ATTEMPTS; attempt++) try {
190
+ await db.query(sql, ...params);
191
+ return;
192
+ } catch (error) {
193
+ if (!isUniqueViolation(error) || attempt === MAX_APPEND_ATTEMPTS) throw error;
194
+ }
195
+ }
196
+ /**
197
+ * Manual bump escape hatch for out-of-band writers.
198
+ *
199
+ * Framework mutation paths feed the log automatically, but raw SQL issued
200
+ * outside `save()`/`delete()` is invisible to it (documented gap, shared
201
+ * with the #1499 collection cache). Call this after such a write so feed
202
+ * consumers observe the change. Omitting `rowId` records a table-level
203
+ * change (`rowId: null`), which consumers should treat as "anything in this
204
+ * table may have changed".
205
+ *
206
+ * @example
207
+ * ```typescript
208
+ * await db.query(`UPDATE products SET price = price * 1.1`);
209
+ * await bumpChangeFeed(db, { table: 'products' });
210
+ * ```
211
+ */
212
+ async function bumpChangeFeed(db, input) {
213
+ await ensureChangeFeedTable(db);
214
+ await appendChange(db, input);
215
+ }
216
+ /**
217
+ * Read committed changes after a cursor.
218
+ *
219
+ * Returns every committed change with `since < seq <= cursor` that matches
220
+ * the filters, ordered by ascending `seq`. The returned cursor is safe to
221
+ * persist and poll with: committed sequences are contiguous (see module
222
+ * docs), so nothing can commit at or below the observed horizon afterwards —
223
+ * reads miss no committed change under concurrent writers and never return
224
+ * the same change twice. When `since` is already at the horizon, returns an
225
+ * empty page with `cursor: since`.
226
+ *
227
+ * ## Resync detection (pruned / foreign cursors)
228
+ *
229
+ * A cursor that cannot be served incrementally is flagged with
230
+ * `resyncRequired: true` (empty `changes`, `cursor` echoed unchanged) so
231
+ * pollers never go silently, permanently stale:
232
+ *
233
+ * - **Pruned gap**: retained sequences always form a contiguous run
234
+ * `[floor..horizon]` and {@link pruneChangeFeed} deletes oldest-first
235
+ * while always retaining the newest entry, so `since < floor - 1` proves
236
+ * changes between the cursor and the retained window were pruned away.
237
+ * - **Foreign/reset cursor**: `since > horizon` (ahead of anything this
238
+ * database ever allocated), including any `since > 0` against a feed
239
+ * with no entries.
240
+ *
241
+ * Detection runs on the **unfiltered** log — `tables`/`tenantId` filters
242
+ * legitimately hide rows and never trigger (or mask) the signal. A caught-up
243
+ * consumer (`since === horizon`) is never asked to resync, even when
244
+ * retention has pruned everything older.
245
+ *
246
+ * Filters (`tables`, `tenantId`) affect which rows are *returned*, never how
247
+ * the cursor advances — an exhausted filtered page still advances to the
248
+ * horizon so pollers do not rescan filtered-out rows.
249
+ */
250
+ async function getChangesSince(db, options) {
251
+ const { since } = options;
252
+ if (!Number.isFinite(since) || since < 0) throw new Error(`getChangesSince requires a non-negative numeric cursor, got '${String(since)}'`);
253
+ const limit = Math.min(Math.max(Math.floor(options.limit ?? 500), 1), MAX_CHANGES_LIMIT);
254
+ const p = placeholders(db);
255
+ const boundsRows = getQueryRows(await db.query(`SELECT MIN(seq) AS floor, MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`));
256
+ const floor = toSeqNumber(boundsRows[0]?.floor);
257
+ const horizon = toSeqNumber(boundsRows[0]?.horizon);
258
+ if (horizon === 0) return since === 0 ? {
259
+ changes: [],
260
+ cursor: 0
261
+ } : {
262
+ changes: [],
263
+ cursor: since,
264
+ resyncRequired: true
265
+ };
266
+ if (since > horizon) return {
267
+ changes: [],
268
+ cursor: since,
269
+ resyncRequired: true
270
+ };
271
+ if (since < floor - 1) return {
272
+ changes: [],
273
+ cursor: since,
274
+ resyncRequired: true
275
+ };
276
+ if (horizon === since) return {
277
+ changes: [],
278
+ cursor: since
279
+ };
280
+ const conditions = [];
281
+ const params = [];
282
+ let index = 0;
283
+ const next = () => p(++index);
284
+ conditions.push(`seq > ${next()}`);
285
+ params.push(since);
286
+ conditions.push(`seq <= ${next()}`);
287
+ params.push(horizon);
288
+ const tables = options.tables?.filter((table) => table.trim().length > 0);
289
+ if (tables && tables.length > 0) {
290
+ conditions.push(`table_name IN (${tables.map(() => next()).join(", ")})`);
291
+ params.push(...tables);
292
+ }
293
+ if (options.tenantId === null) conditions.push("tenant_id IS NULL");
294
+ else if (typeof options.tenantId === "string") {
295
+ conditions.push(`(tenant_id = ${next()} OR tenant_id IS NULL)`);
296
+ params.push(options.tenantId);
297
+ }
298
+ const sql = `SELECT seq, table_name, row_id, operation, tenant_id, created_at FROM ${CHANGE_FEED_TABLE} WHERE ${conditions.join(" AND ")} ORDER BY seq ASC LIMIT ${next()}`;
299
+ params.push(limit);
300
+ const changes = getQueryRows(await db.query(sql, ...params)).map(rowToEntry);
301
+ return {
302
+ changes,
303
+ cursor: changes.length === limit ? changes[changes.length - 1].seq : horizon
304
+ };
305
+ }
306
+ /**
307
+ * {@link getChangesSince} scoped by the active tenant context.
308
+ *
309
+ * Resolves the tenant through the same dependency-inversion hook the
310
+ * DispatchBus uses ({@link resolveDispatchTenantScope}), so it works without
311
+ * core depending on `@happyvertical/smrt-tenancy`:
312
+ *
313
+ * - Tenancy disabled (no resolver registered) → no tenant filter.
314
+ * - Tenancy enabled with an active tenant `T` → `T`'s rows plus global rows.
315
+ * - Tenancy enabled with **no** active tenant → global rows only
316
+ * (**fail-closed**: a missing context never widens visibility to all
317
+ * tenants).
318
+ *
319
+ * This is the read the generated `_changes` routes call after establishing
320
+ * tenant context from the authenticated principal.
321
+ */
322
+ async function getTenantScopedChangesSince(db, options) {
323
+ const scope = resolveDispatchTenantScope();
324
+ if (!scope.enforced) return getChangesSince(db, options);
325
+ return getChangesSince(db, {
326
+ ...options,
327
+ tenantId: scope.tenantId
328
+ });
329
+ }
330
+ function toSeqNumber(value) {
331
+ const parsed = typeof value === "number" ? value : Number(value ?? 0);
332
+ return Number.isFinite(parsed) ? parsed : 0;
333
+ }
334
+ function rowToEntry(row) {
335
+ return {
336
+ seq: toSeqNumber(row.seq),
337
+ table: String(row.table_name ?? ""),
338
+ rowId: row.row_id == null ? null : String(row.row_id),
339
+ operation: String(row.operation ?? "update"),
340
+ tenantId: row.tenant_id == null ? null : String(row.tenant_id),
341
+ timestamp: normalizeTimestamp(row.created_at)
342
+ };
343
+ }
344
+ function normalizeTimestamp(value) {
345
+ if (value instanceof Date) return value.toISOString();
346
+ return String(value ?? "");
347
+ }
348
+ /**
349
+ * Prune the change feed to bound its growth.
350
+ *
351
+ * Applies whichever bounds are provided (at least one is required):
352
+ * - `maxRows`: keep only the newest N entries by sequence.
353
+ * - `maxAgeMs`: drop entries older than the cutoff.
354
+ *
355
+ * Pruning deletes oldest-first, never renumbers surviving entries, and
356
+ * **always retains the newest entry** (a non-empty feed is never emptied,
357
+ * whatever the bounds say). That invariant anchors pruned-cursor detection:
358
+ * retained sequences stay a contiguous run `[floor..horizon]`, so
359
+ * {@link getChangesSince} can prove a cursor predates the retained window
360
+ * (`resyncRequired`) — and a fully caught-up consumer keeps polling
361
+ * normally even after everything older was pruned.
362
+ *
363
+ * Cursors within the retained window keep working. Schedule pruning (e.g.
364
+ * via `@happyvertical/smrt-jobs`) with a retention window comfortably
365
+ * larger than the slowest consumer's polling interval; consumers whose
366
+ * cursor falls out of it are told to full-resync via `resyncRequired`.
367
+ *
368
+ * @returns The number of entries pruned (approximate under concurrent prunes).
369
+ */
370
+ async function pruneChangeFeed(db, retention) {
371
+ const { maxAgeMs, maxRows } = retention;
372
+ if (maxAgeMs == null && maxRows == null) throw new Error("pruneChangeFeed requires maxAgeMs and/or maxRows");
373
+ if (maxAgeMs != null && (!Number.isFinite(maxAgeMs) || maxAgeMs < 0)) throw new Error(`pruneChangeFeed maxAgeMs must be >= 0, got ${maxAgeMs}`);
374
+ if (maxRows != null && (!Number.isFinite(maxRows) || maxRows < 0)) throw new Error(`pruneChangeFeed maxRows must be >= 0, got ${maxRows}`);
375
+ const p = placeholders(db);
376
+ const horizon = toSeqNumber(getQueryRows(await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`))[0]?.horizon);
377
+ if (horizon === 0) return { pruned: 0 };
378
+ let pruned = 0;
379
+ if (maxRows != null) {
380
+ const pruneThrough = Math.min(horizon - Math.floor(maxRows), horizon - 1);
381
+ if (pruneThrough > 0) pruned += await deleteCounted(db, `seq <= ${p(1)}`, [pruneThrough]);
382
+ }
383
+ if (maxAgeMs != null) {
384
+ const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
385
+ pruned += await deleteCounted(db, `created_at < ${p(1)} AND seq < ${p(2)}`, [cutoff, horizon]);
386
+ }
387
+ return { pruned };
388
+ }
389
+ async function deleteCounted(db, condition, params) {
390
+ const total = toSeqNumber(getQueryRows(await db.query(`SELECT COUNT(*) AS total FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`, ...params))[0]?.total);
391
+ if (total > 0) await db.query(`DELETE FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`, ...params);
392
+ return total;
393
+ }
394
+ var WAS_PERSISTED_KEY = "_smrtChangeFeedWasPersisted";
395
+ /** Databases we already warned about after a failed feed append. */
396
+ var warnedAppendFailures = /* @__PURE__ */ new Set();
397
+ /**
398
+ * Register the change-feed writer with {@link GlobalInterceptors}.
399
+ *
400
+ * Called automatically during framework initialization (every
401
+ * `SmrtClass.initialize()` passes through it), so applications never need
402
+ * to call it directly; it is exported for tests and for re-registering
403
+ * after `GlobalInterceptors.clear()`. Idempotent — a second call while the
404
+ * writer is registered is a no-op.
405
+ *
406
+ * The writer observes the same hooks the reports scheduler and tenancy
407
+ * interceptors use:
408
+ * - `beforeSave` stashes whether the instance was already persisted (this
409
+ * is what distinguishes `create` from `update` in the feed).
410
+ * - `afterSave`/`afterDelete` append exactly one change entry per framework
411
+ * save/delete. `_smrt_*` system tables are skipped — the feed observes
412
+ * application data, not framework bookkeeping (and never itself).
413
+ *
414
+ * Failure policy: appends run after the user's write succeeded and must not
415
+ * un-succeed it — failures are logged (deduped per database) and swallowed.
416
+ */
417
+ function registerChangeFeedWriter() {
418
+ if (GlobalInterceptors.getAll().some((interceptor) => interceptor.name === "smrt-change-feed")) return;
419
+ GlobalInterceptors.register({
420
+ name: CHANGE_FEED_INTERCEPTOR_NAME,
421
+ priority: 0,
422
+ beforeSave(instance, context) {
423
+ try {
424
+ context.metadata = {
425
+ ...context.metadata,
426
+ [WAS_PERSISTED_KEY]: instance.isPersisted === true
427
+ };
428
+ } catch {}
429
+ },
430
+ async afterSave(instance, context) {
431
+ await appendForInstance(instance, context.metadata?.[WAS_PERSISTED_KEY] === true ? "update" : "create");
432
+ },
433
+ async afterDelete(instance) {
434
+ await appendForInstance(instance, "delete");
435
+ }
436
+ });
437
+ }
438
+ /** Unregister the change-feed writer (test helper). */
439
+ function unregisterChangeFeedWriter() {
440
+ return GlobalInterceptors.unregister(CHANGE_FEED_INTERCEPTOR_NAME);
441
+ }
442
+ async function appendForInstance(instance, operation) {
443
+ let db;
444
+ let table;
445
+ try {
446
+ table = instance.tableName;
447
+ if (!table || table.startsWith("_smrt_")) return;
448
+ db = instance.db;
449
+ } catch {
450
+ return;
451
+ }
452
+ try {
453
+ const id = instance.id;
454
+ const tenantId = instance.tenantId;
455
+ await appendChange(db, {
456
+ table,
457
+ rowId: typeof id === "string" && id ? id : null,
458
+ operation,
459
+ tenantId: typeof tenantId === "string" && tenantId ? tenantId : null
460
+ });
461
+ } catch (error) {
462
+ warnAppendFailureOnce(db, table, error);
463
+ }
464
+ }
465
+ function warnAppendFailureOnce(db, table, error) {
466
+ try {
467
+ const dbKey = resolveDbCacheKey(db);
468
+ if (warnedAppendFailures.has(dbKey)) return;
469
+ warnedAppendFailures.add(dbKey);
470
+ logger.warn(`Change feed: failed to append a change entry for '${table}'. The write itself succeeded; the feed is missing this change (further failures for this database are suppressed). Consumers recover on full resync.`, { error: error instanceof Error ? error.message : String(error) });
471
+ } catch {}
472
+ }
473
+ /**
474
+ * Reset the append-failure warning dedup (test helper).
475
+ */
476
+ function resetChangeFeedWarnings() {
477
+ warnedAppendFailures.clear();
478
+ }
479
+ //#endregion
480
+ export { CHANGE_FEED_INTERCEPTOR_NAME, CHANGE_FEED_TABLE, DEFAULT_CHANGES_LIMIT, MAX_CHANGES_LIMIT, appendChange, bumpChangeFeed, ensureChangeFeedTable, getChangesSince, getTenantScopedChangesSince, pruneChangeFeed, registerChangeFeedWriter, resetChangeFeedWarnings, unregisterChangeFeedWriter };
481
+
482
+ //# sourceMappingURL=change-feed.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"change-feed.js","names":[],"sources":["../src/change-feed.ts"],"sourcesContent":["/**\n * Adapter-agnostic change feed — the framework's change-observation spine\n * (issue #1758, parent PRD #1755).\n *\n * Every framework `save()`/`delete()` appends exactly one row to the\n * `_smrt_changes` system table (monotonic per-database sequence, table name,\n * row id, operation, tenant id, timestamp). Deletes are recorded as\n * tombstones (`operation: 'delete'`), distinguishable from updates. One read\n * interface — {@link getChangesSince} — returns changes after a cursor,\n * filterable by table and tenant, and serves three eventual consumers:\n * client delta pull, the SSE push channel, and the per-table version source\n * backing ETags.\n *\n * ## Cursor semantics (the precise guarantee)\n *\n * Sequences are allocated *inside* the append statement as\n * `COALESCE(MAX(seq), 0) + 1` over the feed table itself, with a retry on\n * primary-key conflict. `MAX(seq)` only observes committed rows, so a row\n * with sequence `N` can only be inserted while every row with sequence\n * `< N` is already committed (a conflicting in-flight allocation of the same\n * value blocks, then retries). Committed rows therefore always form a\n * contiguous run ending at `MAX(seq)` — the **committed horizon**. Sequence\n * order equals commit order; out-of-order commit visibility (the classic\n * MVCC race that makes native identity/serial columns unsafe as cursors\n * under concurrent writers) cannot occur.\n *\n * {@link getChangesSince} reads the committed horizon `H = MAX(seq)`, then\n * returns matching rows with `since < seq <= H` (bounded by `limit`), and a\n * `cursor` that is either `H` (page exhaustive) or the last returned `seq`\n * (page limited). Because no change can ever commit at or below an observed\n * horizon after it was observed, polling with returned cursors misses no\n * committed change and never returns the same change twice — under any\n * number of concurrent writers, identically on SQLite, Postgres and DuckDB.\n * This is the design reason the allocator is `MAX+1` rather than a native\n * AUTOINCREMENT/identity column: identity values are allocated before\n * commit, so a reader on Postgres could observe seq 101 while seq 100 is\n * still uncommitted and advance its cursor past it. (No shared\n * auto-increment mechanism exists in the system-table schema path either;\n * see `system/schema.ts`.)\n *\n * Contention note: appends serialize on the head of the log. Each append is\n * a single small INSERT (issued from the write path *after* the user's row\n * was written), so the serialization window is one statement; conflicts\n * resolve with a bounded retry loop and are impossible on single-writer\n * engines (SQLite).\n *\n * ## Failure semantics\n *\n * A feed-write failure must never fail the user's write. The interceptor\n * wraps the append in a try/catch: on failure it logs a warning (deduped per\n * database) and continues. The trade-off is availability of the user's\n * write over completeness of the feed — consumers already need a\n * full-resync path for cursors older than the retention window, and the\n * same path covers a (rare) dropped feed row. When the user's write runs\n * inside a caller-managed transaction on the same handle, the append joins\n * it and shares its fate (a rollback removes the change row with the data\n * row).\n *\n * ## Known gaps (documented in the PRD)\n *\n * - Writes that bypass framework mutation paths (raw SQL) are invisible to\n * the feed — the same accepted gap as the #1499 collection cache.\n * {@link bumpChangeFeed} is the manual escape hatch: out-of-band writers\n * append a synthetic change row for the affected table.\n * - **Spurious `update` entries**: `SmrtObject.save()` has no dirty-check,\n * so a field-unchanged `.save()` still appends an `update` row. This is\n * by design — the writer observes writes, not diffs (it has no old-row\n * access), so the feed faithfully mirrors the write path. Diff-aware\n * paths (`getOrUpsert()`'s diff guard, the sync-apply endpoint's no-op\n * detection) short-circuit before `save()` and append nothing.\n * Subscribers must tolerate spurious entries; they are convergent — a\n * re-fetch returns identical data.\n *\n * ## Retention\n *\n * The log is append-only and grows with write volume. {@link pruneChangeFeed}\n * bounds it by age (`maxAgeMs`) and/or row count (`maxRows`); call it from a\n * scheduled job sized so the retention window comfortably exceeds the\n * slowest consumer's polling interval. Pruning deletes oldest-first and\n * always retains the newest entry, so retained sequences stay a contiguous\n * `[floor..horizon]` run — which is how {@link getChangesSince} *detects* a\n * consumer whose cursor predates the retained window and answers it with\n * `resyncRequired: true` instead of silently skipping the pruned changes.\n *\n * @see https://github.com/happyvertical/smrt/issues/1758\n * @packageDocumentation\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { resolveDbCacheKey } from './collection-cache.js';\nimport { resolveDispatchTenantScope } from './dispatch/tenant-resolver.js';\nimport { GlobalInterceptors, type InterceptorContext } from './interceptors.js';\nimport type { SmrtObject } from './object.js';\nimport { detectEngine } from './schema/ddl/index.js';\nimport { CREATE_SMRT_CHANGES_TABLE } from './system/schema.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/** Name of the append-only change-feed system table. */\nexport const CHANGE_FEED_TABLE = '_smrt_changes';\n\n/** Interceptor name of the framework's change-feed writer. */\nexport const CHANGE_FEED_INTERCEPTOR_NAME = 'smrt-change-feed';\n\n/**\n * Change operations recorded in the feed. Deletes are tombstones —\n * consumers can distinguish \"row changed\" from \"row is gone\" without\n * consulting the source table.\n */\nexport type ChangeOperation = 'create' | 'update' | 'delete';\n\n/** One entry of the change feed. */\nexport interface ChangeFeedEntry {\n /** Strictly monotonic per-database sequence (the cursor dimension). */\n seq: number;\n /** Physical table the change happened in (STI children report the shared base table). */\n table: string;\n /**\n * Primary key of the changed row, or `null` for table-level synthetic\n * bumps recorded via {@link bumpChangeFeed} without a row id.\n */\n rowId: string | null;\n /** What happened. `'delete'` entries double as tombstones. */\n operation: ChangeOperation;\n /** Tenant the changed row belongs to, or `null` for global/non-tenant rows. */\n tenantId: string | null;\n /** ISO-8601 timestamp recorded when the change was appended. */\n timestamp: string;\n}\n\n/** Options for {@link getChangesSince}. */\nexport interface GetChangesOptions {\n /**\n * Cursor to read after. Only rows with `seq` strictly greater than `since`\n * are returned; pass a previously returned {@link ChangeFeedPage.cursor} to\n * poll.\n *\n * `0` reads from the start of the log only while it has not been pruned past\n * the beginning. Once retention has raised the retained floor above the\n * start, `since: 0` (like any cursor older than the retained window) can no\n * longer be served incrementally — the read returns\n * {@link ChangeFeedPage.resyncRequired} and the caller must do a full\n * resync.\n */\n since: number;\n /** Restrict to these physical table names. Empty/omitted → all tables. */\n tables?: string[];\n /**\n * Tenant visibility filter:\n * - omitted/`undefined` → no tenant filter (all rows).\n * - `null` → only global rows (`tenant_id IS NULL`).\n * - `'<tenantId>'` → that tenant's rows **plus** global rows, matching the\n * DispatchBus read rule (`tenant_id = T OR tenant_id IS NULL`). A tenant\n * never sees another tenant's changes.\n */\n tenantId?: string | null;\n /**\n * Page size (default {@link DEFAULT_CHANGES_LIMIT}, capped at\n * {@link MAX_CHANGES_LIMIT}). When a page fills up, the returned cursor\n * stops at the last returned row so the next poll continues seamlessly.\n */\n limit?: number;\n}\n\n/** Result page of {@link getChangesSince}. */\nexport interface ChangeFeedPage {\n /** Matching changes ordered by ascending `seq`. */\n changes: ChangeFeedEntry[];\n /**\n * The next cursor. Monotonic: never lower than the `since` it was derived\n * from. Equal to the committed horizon when the page was exhaustive, or to\n * the last returned `seq` when the page hit `limit`. Feed the value back\n * as `since` to observe every later change exactly once.\n */\n cursor: number;\n /**\n * Present (and `true`) when the supplied cursor cannot be served\n * incrementally and the consumer must fall back to a full resync:\n *\n * - the cursor predates the retained window (entries at or below it were\n * pruned away — the changes between it and the retained floor are gone\n * for good), or\n * - the cursor is ahead of the committed horizon / unknown to this\n * database (a foreign or reset cursor).\n *\n * When set, `changes` is empty and `cursor` echoes `since` unchanged —\n * the consumer must re-fetch its data in full and restart polling from\n * the cursor returned by its post-resync read. Detection is computed on\n * the **unfiltered** log: `tables`/`tenantId` filters legitimately hide\n * rows and never trigger (or mask) a resync signal.\n */\n resyncRequired?: boolean;\n}\n\n/** Input for {@link appendChange} / {@link bumpChangeFeed}. */\nexport interface AppendChangeInput {\n /** Physical table name the change refers to. */\n table: string;\n /** Changed row's primary key; `null`/omitted records a table-level change. */\n rowId?: string | null;\n /** Operation to record (default `'update'`). */\n operation?: ChangeOperation;\n /** Tenant the change belongs to (default `null` = global). */\n tenantId?: string | null;\n}\n\n/** Retention bounds for {@link pruneChangeFeed}. At least one is required. */\nexport interface ChangeFeedRetention {\n /** Prune entries older than this many milliseconds. */\n maxAgeMs?: number;\n /** Keep at most this many newest entries (by sequence). */\n maxRows?: number;\n}\n\n/** Default page size for {@link getChangesSince}. */\nexport const DEFAULT_CHANGES_LIMIT = 500;\n\n/** Hard cap on the page size for {@link getChangesSince}. */\nexport const MAX_CHANGES_LIMIT = 5_000;\n\n/**\n * Maximum append attempts under sequence contention. Conflicts only occur\n * with concurrent writers on MVCC engines and resolve as soon as the\n * blocking transaction commits, so a small bound is ample.\n */\nconst MAX_APPEND_ATTEMPTS = 20;\n\nconst VALID_OPERATIONS: ReadonlySet<string> = new Set([\n 'create',\n 'update',\n 'delete',\n]);\n\n// ============================================================================\n// Engine / SQL helpers (mirrors system/compatibility.ts conventions)\n// ============================================================================\n\ntype DatabaseWithConfig = DatabaseInterface & {\n config?: { type?: string; url?: string };\n type?: string;\n};\n\nfunction getEngine(db: DatabaseInterface): ReturnType<typeof detectEngine> {\n const withConfig = db as DatabaseWithConfig;\n return detectEngine(\n db.url || withConfig.config?.url || '',\n withConfig.type || withConfig.config?.type,\n );\n}\n\n/**\n * Positional placeholder factory: Postgres uses `$n`, SQLite/DuckDB use `?`.\n */\nfunction placeholders(db: DatabaseInterface): (index: number) => string {\n const engine = getEngine(db);\n return engine === 'postgres' ? (index) => `$${index}` : () => '?';\n}\n\nfunction getQueryRows(result: unknown): Record<string, unknown>[] {\n if (Array.isArray(result)) {\n return result as Record<string, unknown>[];\n }\n if (result && typeof result === 'object' && 'rows' in result) {\n const rows = (result as { rows?: unknown }).rows;\n if (Array.isArray(rows)) {\n return rows as Record<string, unknown>[];\n }\n }\n return [];\n}\n\nfunction isUniqueViolation(error: unknown): boolean {\n const message = error instanceof Error ? error.message : String(error ?? '');\n return (\n /unique constraint/i.test(message) ||\n /duplicate key/i.test(message) ||\n /primary key constraint/i.test(message) ||\n /constraint error/i.test(message)\n );\n}\n\n/**\n * Ensure the `_smrt_changes` system table exists on a database handle that\n * may not have passed through framework initialization (e.g. a raw handle\n * given to the REST generator). Idempotent (`CREATE ... IF NOT EXISTS`) and\n * guarded to run once per handle. Databases initialized through the\n * framework already have the table via the system-table bootstrap.\n */\nconst ensuredHandles = new WeakSet<object>();\n\nexport async function ensureChangeFeedTable(\n db: DatabaseInterface,\n): Promise<void> {\n if (ensuredHandles.has(db)) return;\n const statements = CREATE_SMRT_CHANGES_TABLE.split(';')\n .map((statement) => statement.trim())\n .filter((statement) => statement.length > 0);\n for (const statement of statements) {\n await db.query(statement);\n }\n ensuredHandles.add(db);\n}\n\n// ============================================================================\n// Append (writer primitive + manual bump escape hatch)\n// ============================================================================\n\n/**\n * Append one change entry with a database-allocated, strictly monotonic\n * sequence.\n *\n * The sequence is allocated inside the INSERT itself\n * (`COALESCE(MAX(seq), 0) + 1`) and retried on primary-key conflict, which\n * keeps committed sequences contiguous and makes commit order equal\n * sequence order — the property the cursor guarantee rests on (see the\n * module docs). Throws after {@link MAX_APPEND_ATTEMPTS} consecutive\n * conflicts or on any non-conflict database error; the framework's\n * interceptor catches and logs instead of failing the user's write.\n *\n * **Transaction assumption.** The conflict retry assumes an autocommit\n * handle, which is the default framework path: `save()`/`delete()` run their\n * statements as independent autocommit calls, so a conflicting append aborts\n * nothing and the retry recomputes `MAX(seq)` against the committed head. If a\n * caller instead wraps `save()` in its own transaction on an MVCC engine\n * (e.g. Postgres), a duplicate-key error can abort the *surrounding*\n * transaction — the retry is then futile and the caller's write rolls back.\n * Callers wrapping mutations in a transaction under genuine seq-head write\n * contention should disable the feed writer for that path or accept that\n * risk. Savepoint-scoped protection is a possible follow-up; it is\n * intentionally out of scope for the v1 spine.\n */\nexport async function appendChange(\n db: DatabaseInterface,\n input: AppendChangeInput,\n): Promise<void> {\n const table = input.table?.trim();\n if (!table) {\n throw new Error('appendChange requires a non-empty table name');\n }\n const operation = input.operation ?? 'update';\n if (!VALID_OPERATIONS.has(operation)) {\n throw new Error(\n `appendChange operation must be one of create/update/delete, got '${String(\n input.operation,\n )}'`,\n );\n }\n\n const p = placeholders(db);\n const sql =\n `INSERT INTO ${CHANGE_FEED_TABLE} ` +\n '(seq, table_name, row_id, operation, tenant_id, created_at) ' +\n `SELECT COALESCE(MAX(seq), 0) + 1, ${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)} ` +\n `FROM ${CHANGE_FEED_TABLE}`;\n const params = [\n table,\n input.rowId ?? null,\n operation,\n input.tenantId ?? null,\n new Date().toISOString(),\n ];\n\n for (let attempt = 1; attempt <= MAX_APPEND_ATTEMPTS; attempt++) {\n try {\n await db.query(sql, ...params);\n return;\n } catch (error) {\n if (!isUniqueViolation(error) || attempt === MAX_APPEND_ATTEMPTS) {\n throw error;\n }\n // Sequence head contention: another append won the value. Re-running\n // recomputes MAX(seq) against the now-committed head.\n }\n }\n}\n\n/**\n * Manual bump escape hatch for out-of-band writers.\n *\n * Framework mutation paths feed the log automatically, but raw SQL issued\n * outside `save()`/`delete()` is invisible to it (documented gap, shared\n * with the #1499 collection cache). Call this after such a write so feed\n * consumers observe the change. Omitting `rowId` records a table-level\n * change (`rowId: null`), which consumers should treat as \"anything in this\n * table may have changed\".\n *\n * @example\n * ```typescript\n * await db.query(`UPDATE products SET price = price * 1.1`);\n * await bumpChangeFeed(db, { table: 'products' });\n * ```\n */\nexport async function bumpChangeFeed(\n db: DatabaseInterface,\n input: AppendChangeInput,\n): Promise<void> {\n await ensureChangeFeedTable(db);\n await appendChange(db, input);\n}\n\n// ============================================================================\n// Read interface\n// ============================================================================\n\n/**\n * Read committed changes after a cursor.\n *\n * Returns every committed change with `since < seq <= cursor` that matches\n * the filters, ordered by ascending `seq`. The returned cursor is safe to\n * persist and poll with: committed sequences are contiguous (see module\n * docs), so nothing can commit at or below the observed horizon afterwards —\n * reads miss no committed change under concurrent writers and never return\n * the same change twice. When `since` is already at the horizon, returns an\n * empty page with `cursor: since`.\n *\n * ## Resync detection (pruned / foreign cursors)\n *\n * A cursor that cannot be served incrementally is flagged with\n * `resyncRequired: true` (empty `changes`, `cursor` echoed unchanged) so\n * pollers never go silently, permanently stale:\n *\n * - **Pruned gap**: retained sequences always form a contiguous run\n * `[floor..horizon]` and {@link pruneChangeFeed} deletes oldest-first\n * while always retaining the newest entry, so `since < floor - 1` proves\n * changes between the cursor and the retained window were pruned away.\n * - **Foreign/reset cursor**: `since > horizon` (ahead of anything this\n * database ever allocated), including any `since > 0` against a feed\n * with no entries.\n *\n * Detection runs on the **unfiltered** log — `tables`/`tenantId` filters\n * legitimately hide rows and never trigger (or mask) the signal. A caught-up\n * consumer (`since === horizon`) is never asked to resync, even when\n * retention has pruned everything older.\n *\n * Filters (`tables`, `tenantId`) affect which rows are *returned*, never how\n * the cursor advances — an exhausted filtered page still advances to the\n * horizon so pollers do not rescan filtered-out rows.\n */\nexport async function getChangesSince(\n db: DatabaseInterface,\n options: GetChangesOptions,\n): Promise<ChangeFeedPage> {\n const { since } = options;\n if (!Number.isFinite(since) || since < 0) {\n throw new Error(\n `getChangesSince requires a non-negative numeric cursor, got '${String(since)}'`,\n );\n }\n const limit = Math.min(\n Math.max(Math.floor(options.limit ?? DEFAULT_CHANGES_LIMIT), 1),\n MAX_CHANGES_LIMIT,\n );\n\n const p = placeholders(db);\n\n // The committed horizon: every seq <= horizon is committed and immutable\n // (append-only + contiguous allocation), so the page below is stable even\n // though it runs as a separate statement. The floor bounds the retained\n // window for pruned-cursor detection; both are computed UNFILTERED so\n // table/tenant filters can neither trigger nor mask a resync signal.\n const boundsRows = getQueryRows(\n await db.query(\n `SELECT MIN(seq) AS floor, MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`,\n ),\n );\n const floor = toSeqNumber(boundsRows[0]?.floor);\n const horizon = toSeqNumber(boundsRows[0]?.horizon);\n\n if (horizon === 0) {\n // No entries at all. A zero cursor is simply \"no changes ever\"; any\n // other cursor came from a different database (or a reset feed) and\n // cannot be served incrementally.\n return since === 0\n ? { changes: [], cursor: 0 }\n : { changes: [], cursor: since, resyncRequired: true };\n }\n\n if (since > horizon) {\n // Foreign or reset cursor — ahead of anything this database allocated.\n return { changes: [], cursor: since, resyncRequired: true };\n }\n\n if (since < floor - 1) {\n // Pruned gap — the changes with seq in (since, floor) are gone for good.\n return { changes: [], cursor: since, resyncRequired: true };\n }\n\n if (horizon === since) {\n return { changes: [], cursor: since };\n }\n\n const conditions: string[] = [];\n const params: unknown[] = [];\n let index = 0;\n const next = () => p(++index);\n\n conditions.push(`seq > ${next()}`);\n params.push(since);\n conditions.push(`seq <= ${next()}`);\n params.push(horizon);\n\n const tables = options.tables?.filter((table) => table.trim().length > 0);\n if (tables && tables.length > 0) {\n conditions.push(`table_name IN (${tables.map(() => next()).join(', ')})`);\n params.push(...tables);\n }\n\n if (options.tenantId === null) {\n conditions.push('tenant_id IS NULL');\n } else if (typeof options.tenantId === 'string') {\n conditions.push(`(tenant_id = ${next()} OR tenant_id IS NULL)`);\n params.push(options.tenantId);\n }\n\n const sql =\n 'SELECT seq, table_name, row_id, operation, tenant_id, created_at ' +\n `FROM ${CHANGE_FEED_TABLE} WHERE ${conditions.join(' AND ')} ` +\n `ORDER BY seq ASC LIMIT ${next()}`;\n params.push(limit);\n\n const rows = getQueryRows(await db.query(sql, ...params));\n const changes = rows.map(rowToEntry);\n\n // Page limited → resume after the last returned row. Page exhaustive →\n // everything up to the horizon (matching or filtered out) has been\n // observed, so advance all the way.\n const cursor =\n changes.length === limit ? changes[changes.length - 1].seq : horizon;\n\n return { changes, cursor };\n}\n\n/**\n * {@link getChangesSince} scoped by the active tenant context.\n *\n * Resolves the tenant through the same dependency-inversion hook the\n * DispatchBus uses ({@link resolveDispatchTenantScope}), so it works without\n * core depending on `@happyvertical/smrt-tenancy`:\n *\n * - Tenancy disabled (no resolver registered) → no tenant filter.\n * - Tenancy enabled with an active tenant `T` → `T`'s rows plus global rows.\n * - Tenancy enabled with **no** active tenant → global rows only\n * (**fail-closed**: a missing context never widens visibility to all\n * tenants).\n *\n * This is the read the generated `_changes` routes call after establishing\n * tenant context from the authenticated principal.\n */\nexport async function getTenantScopedChangesSince(\n db: DatabaseInterface,\n options: Omit<GetChangesOptions, 'tenantId'>,\n): Promise<ChangeFeedPage> {\n const scope = resolveDispatchTenantScope();\n if (!scope.enforced) {\n return getChangesSince(db, options);\n }\n return getChangesSince(db, { ...options, tenantId: scope.tenantId });\n}\n\nfunction toSeqNumber(value: unknown): number {\n // Postgres adapters may surface BIGINT aggregates as strings.\n const parsed = typeof value === 'number' ? value : Number(value ?? 0);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction rowToEntry(row: Record<string, unknown>): ChangeFeedEntry {\n return {\n seq: toSeqNumber(row.seq),\n table: String(row.table_name ?? ''),\n rowId: row.row_id == null ? null : String(row.row_id),\n operation: String(row.operation ?? 'update') as ChangeOperation,\n tenantId: row.tenant_id == null ? null : String(row.tenant_id),\n timestamp: normalizeTimestamp(row.created_at),\n };\n}\n\nfunction normalizeTimestamp(value: unknown): string {\n if (value instanceof Date) return value.toISOString();\n return String(value ?? '');\n}\n\n// ============================================================================\n// Retention / compaction\n// ============================================================================\n\n/**\n * Prune the change feed to bound its growth.\n *\n * Applies whichever bounds are provided (at least one is required):\n * - `maxRows`: keep only the newest N entries by sequence.\n * - `maxAgeMs`: drop entries older than the cutoff.\n *\n * Pruning deletes oldest-first, never renumbers surviving entries, and\n * **always retains the newest entry** (a non-empty feed is never emptied,\n * whatever the bounds say). That invariant anchors pruned-cursor detection:\n * retained sequences stay a contiguous run `[floor..horizon]`, so\n * {@link getChangesSince} can prove a cursor predates the retained window\n * (`resyncRequired`) — and a fully caught-up consumer keeps polling\n * normally even after everything older was pruned.\n *\n * Cursors within the retained window keep working. Schedule pruning (e.g.\n * via `@happyvertical/smrt-jobs`) with a retention window comfortably\n * larger than the slowest consumer's polling interval; consumers whose\n * cursor falls out of it are told to full-resync via `resyncRequired`.\n *\n * @returns The number of entries pruned (approximate under concurrent prunes).\n */\nexport async function pruneChangeFeed(\n db: DatabaseInterface,\n retention: ChangeFeedRetention,\n): Promise<{ pruned: number }> {\n const { maxAgeMs, maxRows } = retention;\n if (maxAgeMs == null && maxRows == null) {\n throw new Error('pruneChangeFeed requires maxAgeMs and/or maxRows');\n }\n if (maxAgeMs != null && (!Number.isFinite(maxAgeMs) || maxAgeMs < 0)) {\n throw new Error(`pruneChangeFeed maxAgeMs must be >= 0, got ${maxAgeMs}`);\n }\n if (maxRows != null && (!Number.isFinite(maxRows) || maxRows < 0)) {\n throw new Error(`pruneChangeFeed maxRows must be >= 0, got ${maxRows}`);\n }\n\n const p = placeholders(db);\n\n // Snapshot the horizon once: both bounds prune strictly below it so the\n // newest entry always survives (see resync-detection contract above).\n const horizonRows = getQueryRows(\n await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`),\n );\n const horizon = toSeqNumber(horizonRows[0]?.horizon);\n if (horizon === 0) {\n return { pruned: 0 };\n }\n\n let pruned = 0;\n\n if (maxRows != null) {\n const pruneThrough = Math.min(horizon - Math.floor(maxRows), horizon - 1);\n if (pruneThrough > 0) {\n pruned += await deleteCounted(db, `seq <= ${p(1)}`, [pruneThrough]);\n }\n }\n\n if (maxAgeMs != null) {\n const cutoff = new Date(Date.now() - maxAgeMs).toISOString();\n pruned += await deleteCounted(\n db,\n `created_at < ${p(1)} AND seq < ${p(2)}`,\n [cutoff, horizon],\n );\n }\n\n return { pruned };\n}\n\nasync function deleteCounted(\n db: DatabaseInterface,\n condition: string,\n params: unknown[],\n): Promise<number> {\n const countRows = getQueryRows(\n await db.query(\n `SELECT COUNT(*) AS total FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`,\n ...params,\n ),\n );\n const total = toSeqNumber(countRows[0]?.total);\n if (total > 0) {\n await db.query(\n `DELETE FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`,\n ...params,\n );\n }\n return total;\n}\n\n// ============================================================================\n// Framework writer (GlobalInterceptors registration)\n// ============================================================================\n\nconst WAS_PERSISTED_KEY = '_smrtChangeFeedWasPersisted';\n\n/** Databases we already warned about after a failed feed append. */\nconst warnedAppendFailures = new Set<string>();\n\n/**\n * Register the change-feed writer with {@link GlobalInterceptors}.\n *\n * Called automatically during framework initialization (every\n * `SmrtClass.initialize()` passes through it), so applications never need\n * to call it directly; it is exported for tests and for re-registering\n * after `GlobalInterceptors.clear()`. Idempotent — a second call while the\n * writer is registered is a no-op.\n *\n * The writer observes the same hooks the reports scheduler and tenancy\n * interceptors use:\n * - `beforeSave` stashes whether the instance was already persisted (this\n * is what distinguishes `create` from `update` in the feed).\n * - `afterSave`/`afterDelete` append exactly one change entry per framework\n * save/delete. `_smrt_*` system tables are skipped — the feed observes\n * application data, not framework bookkeeping (and never itself).\n *\n * Failure policy: appends run after the user's write succeeded and must not\n * un-succeed it — failures are logged (deduped per database) and swallowed.\n */\nexport function registerChangeFeedWriter(): void {\n if (\n GlobalInterceptors.getAll().some(\n (interceptor) => interceptor.name === CHANGE_FEED_INTERCEPTOR_NAME,\n )\n ) {\n return;\n }\n\n GlobalInterceptors.register({\n name: CHANGE_FEED_INTERCEPTOR_NAME,\n // Below tenancy (100) so tenantId auto-population precedes the stash;\n // above the reports refresh interceptor (-10) so a triggered refresh\n // can already observe the appended change entry.\n priority: 0,\n\n beforeSave(instance: SmrtObject, context: InterceptorContext): void {\n try {\n context.metadata = {\n ...context.metadata,\n [WAS_PERSISTED_KEY]: instance.isPersisted === true,\n };\n } catch {\n // Never let feed bookkeeping block a save.\n }\n },\n\n async afterSave(\n instance: SmrtObject,\n context: InterceptorContext,\n ): Promise<void> {\n const wasPersisted = context.metadata?.[WAS_PERSISTED_KEY] === true;\n await appendForInstance(instance, wasPersisted ? 'update' : 'create');\n },\n\n async afterDelete(instance: SmrtObject): Promise<void> {\n await appendForInstance(instance, 'delete');\n },\n });\n}\n\n/** Unregister the change-feed writer (test helper). */\nexport function unregisterChangeFeedWriter(): boolean {\n return GlobalInterceptors.unregister(CHANGE_FEED_INTERCEPTOR_NAME);\n}\n\nasync function appendForInstance(\n instance: SmrtObject,\n operation: ChangeOperation,\n): Promise<void> {\n let db: DatabaseInterface;\n let table: string;\n try {\n table = instance.tableName;\n // System tables are framework bookkeeping, not client-syncable data —\n // recording them would let the feed observe (and re-observe) itself.\n if (!table || table.startsWith('_smrt_')) return;\n db = instance.db;\n } catch {\n // Not a fully initialized SmrtObject (e.g. plain-object doubles in\n // tests) — nothing to record.\n return;\n }\n\n try {\n const id = (instance as { id?: unknown }).id;\n const tenantId = (instance as unknown as Record<string, unknown>).tenantId;\n await appendChange(db, {\n table,\n rowId: typeof id === 'string' && id ? id : null,\n operation,\n tenantId: typeof tenantId === 'string' && tenantId ? tenantId : null,\n });\n } catch (error) {\n warnAppendFailureOnce(db, table, error);\n }\n}\n\nfunction warnAppendFailureOnce(\n db: DatabaseInterface,\n table: string,\n error: unknown,\n): void {\n try {\n const dbKey = resolveDbCacheKey(db);\n if (warnedAppendFailures.has(dbKey)) return;\n warnedAppendFailures.add(dbKey);\n logger.warn(\n `Change feed: failed to append a change entry for '${table}'. The ` +\n 'write itself succeeded; the feed is missing this change (further ' +\n 'failures for this database are suppressed). Consumers recover on ' +\n 'full resync.',\n { error: error instanceof Error ? error.message : String(error) },\n );\n } catch {\n // Logging must never propagate into the write path.\n }\n}\n\n/**\n * Reset the append-failure warning dedup (test helper).\n */\nexport function resetChangeFeedWarnings(): void {\n warnedAppendFailures.clear();\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"class.d.ts","sourceRoot":"","sources":["../src/class.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"class.d.ts","sourceRoot":"","sources":["../src/class.ts"],"names":[],"mappings":"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"}
package/dist/class.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { AiUsageCollector, AiUsagePersistenceHandler } from "./adapters/ai-usage.js";
2
2
  import { estimateAiUsageCost } from "./adapters/cost-rates.js";
3
- import { config } from "./config.js";
4
3
  import { detectEngine } from "./schema/ddl/index.js";
4
+ import { ALL_SYSTEM_TABLES, SMRT_SCHEMA_VERSION } from "./system/schema.js";
5
+ import { registerChangeFeedWriter } from "./change-feed.js";
6
+ import { config } from "./config.js";
5
7
  import { SignalBus } from "./signals/bus.js";
6
8
  import { ensureLegacySystemTableCompatibility, tableExists } from "./system/compatibility.js";
7
- import { ALL_SYSTEM_TABLES, SMRT_SCHEMA_VERSION } from "./system/schema.js";
8
9
  import { createLogger } from "@happyvertical/logger";
9
10
  import { getAI } from "@happyvertical/ai";
10
11
  import { FilesystemAdapter } from "@happyvertical/files";
@@ -235,6 +236,7 @@ var SmrtClass = class SmrtClass {
235
236
  * repeated system-table checks.
236
237
  */
237
238
  async initializeCoreResources() {
239
+ registerChangeFeedWriter();
238
240
  if (this.options.persistence && !this.options.db) this.options.db = this.options.persistence;
239
241
  if (this.requiresDatabase() && !this.options.db) throw new Error(`${this._className} requires a database configuration. Please provide 'db' in options: { db: { url: '...' } } or { db: 'database.db' }`);
240
242
  if (this.options.db) {