@happyvertical/smrt-core 0.40.70 → 0.41.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 (157) hide show
  1. package/AGENTS.md +2 -2
  2. package/agents/change-feed.md +1 -1
  3. package/agents/schema-paths.md +487 -29
  4. package/dist/browser.js +5 -4
  5. package/dist/cascade.d.ts +120 -0
  6. package/dist/cascade.d.ts.map +1 -0
  7. package/dist/cascade.js +430 -0
  8. package/dist/cascade.js.map +1 -0
  9. package/dist/change-feed.d.ts +34 -2
  10. package/dist/change-feed.d.ts.map +1 -1
  11. package/dist/change-feed.js +52 -11
  12. package/dist/change-feed.js.map +1 -1
  13. package/dist/class.d.ts +36 -3
  14. package/dist/class.d.ts.map +1 -1
  15. package/dist/class.js +59 -5
  16. package/dist/class.js.map +1 -1
  17. package/dist/collection-cache.js +0 -0
  18. package/dist/collection-cache.js.map +1 -1
  19. package/dist/collection.d.ts +29 -0
  20. package/dist/collection.d.ts.map +1 -1
  21. package/dist/collection.js +66 -14
  22. package/dist/collection.js.map +1 -1
  23. package/dist/config.d.ts +10 -0
  24. package/dist/config.d.ts.map +1 -1
  25. package/dist/config.js.map +1 -1
  26. package/dist/decorators/index.d.ts +63 -5
  27. package/dist/decorators/index.d.ts.map +1 -1
  28. package/dist/decorators/index.js +37 -5
  29. package/dist/decorators/index.js.map +1 -1
  30. package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
  31. package/dist/dispatch/collections/Dispatches.js +19 -4
  32. package/dist/dispatch/collections/Dispatches.js.map +1 -1
  33. package/dist/dispatch/types.d.ts +5 -0
  34. package/dist/dispatch/types.d.ts.map +1 -1
  35. package/dist/embedded-write-queue.d.ts +46 -0
  36. package/dist/embedded-write-queue.d.ts.map +1 -0
  37. package/dist/embedded-write-queue.js +66 -0
  38. package/dist/embedded-write-queue.js.map +1 -0
  39. package/dist/embeddings/storage.d.ts +7 -0
  40. package/dist/embeddings/storage.d.ts.map +1 -1
  41. package/dist/embeddings/storage.js +29 -12
  42. package/dist/embeddings/storage.js.map +1 -1
  43. package/dist/errors.d.ts +1 -1
  44. package/dist/hierarchical.js +1 -1
  45. package/dist/index.d.ts +2 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +8 -6
  48. package/dist/interceptors.d.ts +21 -0
  49. package/dist/interceptors.d.ts.map +1 -1
  50. package/dist/interceptors.js +27 -1
  51. package/dist/interceptors.js.map +1 -1
  52. package/dist/manifest/generator.d.ts.map +1 -1
  53. package/dist/manifest/generator.js +4 -7
  54. package/dist/manifest/generator.js.map +1 -1
  55. package/dist/manifest/static-manifest.js +10 -10
  56. package/dist/manifest/static-manifest.js.map +1 -1
  57. package/dist/manifest/store.js +1 -1
  58. package/dist/manifest/store.js.map +1 -1
  59. package/dist/manifest.json +19 -19
  60. package/dist/migrations/differ.d.ts +9 -1
  61. package/dist/migrations/differ.d.ts.map +1 -1
  62. package/dist/migrations/differ.js +10 -5
  63. package/dist/migrations/differ.js.map +1 -1
  64. package/dist/migrations/index.d.ts +1 -0
  65. package/dist/migrations/index.d.ts.map +1 -1
  66. package/dist/migrations/index.js +2 -1
  67. package/dist/migrations/minor-units.d.ts +162 -0
  68. package/dist/migrations/minor-units.d.ts.map +1 -0
  69. package/dist/migrations/minor-units.js +381 -0
  70. package/dist/migrations/minor-units.js.map +1 -0
  71. package/dist/migrations.js +2 -1
  72. package/dist/object.d.ts +98 -5
  73. package/dist/object.d.ts.map +1 -1
  74. package/dist/object.js +138 -30
  75. package/dist/object.js.map +1 -1
  76. package/dist/query-bounds.d.ts +5 -1
  77. package/dist/query-bounds.d.ts.map +1 -1
  78. package/dist/query-bounds.js +5 -1
  79. package/dist/query-bounds.js.map +1 -1
  80. package/dist/registry/class-registration.d.ts.map +1 -1
  81. package/dist/registry/class-registration.js +3 -1
  82. package/dist/registry/class-registration.js.map +1 -1
  83. package/dist/registry/manifest-field-merge.d.ts +12 -0
  84. package/dist/registry/manifest-field-merge.d.ts.map +1 -1
  85. package/dist/registry/manifest-field-merge.js +14 -2
  86. package/dist/registry/manifest-field-merge.js.map +1 -1
  87. package/dist/registry/schema-builder.d.ts +22 -1
  88. package/dist/registry/schema-builder.d.ts.map +1 -1
  89. package/dist/registry/schema-builder.js +205 -165
  90. package/dist/registry/schema-builder.js.map +1 -1
  91. package/dist/registry/types.d.ts +3 -2
  92. package/dist/registry/types.d.ts.map +1 -1
  93. package/dist/registry.d.ts +41 -46
  94. package/dist/registry.d.ts.map +1 -1
  95. package/dist/registry.js +61 -83
  96. package/dist/registry.js.map +1 -1
  97. package/dist/scanner/manifest-generator.d.ts +45 -0
  98. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  99. package/dist/scanner/manifest-generator.js +92 -28
  100. package/dist/scanner/manifest-generator.js.map +1 -1
  101. package/dist/schema/conflict-target.d.ts +104 -0
  102. package/dist/schema/conflict-target.d.ts.map +1 -0
  103. package/dist/schema/conflict-target.js +129 -0
  104. package/dist/schema/conflict-target.js.map +1 -0
  105. package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
  106. package/dist/schema/ddl/duckdb-strategy.js +2 -1
  107. package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
  108. package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
  109. package/dist/schema/ddl/postgres-strategy.js +12 -1
  110. package/dist/schema/ddl/postgres-strategy.js.map +1 -1
  111. package/dist/schema/generator.d.ts +252 -16
  112. package/dist/schema/generator.d.ts.map +1 -1
  113. package/dist/schema/generator.js +411 -106
  114. package/dist/schema/generator.js.map +1 -1
  115. package/dist/schema/index-utils.d.ts +120 -0
  116. package/dist/schema/index-utils.d.ts.map +1 -1
  117. package/dist/schema/index-utils.js +242 -1
  118. package/dist/schema/index-utils.js.map +1 -1
  119. package/dist/schema/index.d.ts +1 -1
  120. package/dist/schema/index.d.ts.map +1 -1
  121. package/dist/schema/index.js +2 -2
  122. package/dist/schema/live-parity.d.ts.map +1 -1
  123. package/dist/schema/live-parity.js +4 -2
  124. package/dist/schema/live-parity.js.map +1 -1
  125. package/dist/schema/system-table-shapes.d.ts +13 -0
  126. package/dist/schema/system-table-shapes.d.ts.map +1 -1
  127. package/dist/schema/system-table-shapes.js +14 -1
  128. package/dist/schema/system-table-shapes.js.map +1 -1
  129. package/dist/schema/utils.d.ts +1 -1
  130. package/dist/schema/utils.d.ts.map +1 -1
  131. package/dist/schema/utils.js +3 -3
  132. package/dist/schema/utils.js.map +1 -1
  133. package/dist/schema.js +2 -2
  134. package/dist/smrt-knowledge.json +9 -9
  135. package/dist/system/compatibility.d.ts +42 -0
  136. package/dist/system/compatibility.d.ts.map +1 -1
  137. package/dist/system/compatibility.js +182 -9
  138. package/dist/system/compatibility.js.map +1 -1
  139. package/dist/system/index.d.ts +1 -0
  140. package/dist/system/index.d.ts.map +1 -1
  141. package/dist/system/index.js +3 -2
  142. package/dist/system/retention.d.ts +237 -0
  143. package/dist/system/retention.d.ts.map +1 -0
  144. package/dist/system/retention.js +497 -0
  145. package/dist/system/retention.js.map +1 -0
  146. package/dist/system/schema.d.ts +100 -15
  147. package/dist/system/schema.d.ts.map +1 -1
  148. package/dist/system/schema.js +81 -45
  149. package/dist/system/schema.js.map +1 -1
  150. package/dist/system/types.d.ts +0 -2
  151. package/dist/system/types.d.ts.map +1 -1
  152. package/dist/testing/database.d.ts.map +1 -1
  153. package/dist/testing/database.js.map +1 -1
  154. package/dist/vite-plugin/index.d.ts.map +1 -1
  155. package/dist/vite-plugin/index.js +4 -9
  156. package/dist/vite-plugin/index.js.map +1 -1
  157. package/package.json +4 -4
@@ -1,6 +1,29 @@
1
1
  import { DatabaseInterface } from '@happyvertical/sql';
2
2
  /** Name of the append-only change-feed system table. */
3
3
  export declare const CHANGE_FEED_TABLE = "_smrt_changes";
4
+ /**
5
+ * Tables the change-feed writer never records (issue #2376).
6
+ *
7
+ * The writer used to skip every table whose name started with `_smrt_`. That
8
+ * prefix stopped meaning "framework bookkeeping" once packages began naming
9
+ * their *domain* tables `_smrt_feature_overrides`, `_smrt_prompt_overrides`,
10
+ * `_smrt_subscription_plans`, `_smrt_report_schedules`, `_smrt_field_policies`
11
+ * and ~20 more: clients syncing those through `_changes` never saw an update.
12
+ *
13
+ * The exclusion is now an allowlist of tables the framework actually owns —
14
+ * the hand-written bookkeeping DDL ({@link SYSTEM_TABLE_NAMES}, which includes
15
+ * the feed's own table so it can never observe itself), the model-backed
16
+ * operational plumbing ({@link FRAMEWORK_OPERATIONAL_TABLES}), and the retired
17
+ * system tables that may still exist on older databases.
18
+ */
19
+ export declare const CHANGE_FEED_EXCLUDED_TABLES: ReadonlySet<string>;
20
+ /**
21
+ * Whether framework writes to `tableName` are recorded in the change feed.
22
+ *
23
+ * Exported so tooling and tests can reason about feed coverage without
24
+ * re-deriving the rule. See {@link CHANGE_FEED_EXCLUDED_TABLES}.
25
+ */
26
+ export declare function isChangeFeedObservableTable(tableName: string): boolean;
4
27
  /** Interceptor name of the framework's change-feed writer. */
5
28
  export declare const CHANGE_FEED_INTERCEPTOR_NAME = "smrt-change-feed";
6
29
  /**
@@ -114,6 +137,13 @@ export interface ChangeFeedRetention {
114
137
  maxAgeMs?: number;
115
138
  /** Keep at most this many newest entries (by sequence). */
116
139
  maxRows?: number;
140
+ /**
141
+ * Count the entries the bounds select without deleting them (#2375).
142
+ *
143
+ * Lets the retention sweep and `smrt db:prune --dry-run` preview the exact
144
+ * same predicate the real prune would execute.
145
+ */
146
+ dryRun?: boolean;
117
147
  }
118
148
  /** Default page size for {@link getChangesSince}. */
119
149
  export declare const DEFAULT_CHANGES_LIMIT = 500;
@@ -288,7 +318,8 @@ export declare function getTableVersion(db: DatabaseInterface, table: string): P
288
318
  * larger than the slowest consumer's polling interval; consumers whose
289
319
  * cursor falls out of it are told to full-resync via `resyncRequired`.
290
320
  *
291
- * @returns The number of entries pruned (approximate under concurrent prunes).
321
+ * @returns The number of entries pruned or, with `dryRun`, the number the
322
+ * same bounds would prune (approximate under concurrent prunes).
292
323
  */
293
324
  export declare function pruneChangeFeed(db: DatabaseInterface, retention: ChangeFeedRetention): Promise<{
294
325
  pruned: number;
@@ -307,7 +338,8 @@ export declare function pruneChangeFeed(db: DatabaseInterface, retention: Change
307
338
  * - `beforeSave` stashes whether the instance was already persisted (this
308
339
  * is what distinguishes `create` from `update` in the feed).
309
340
  * - `afterSave`/`afterDelete` append exactly one change entry per framework
310
- * save/delete. `_smrt_*` system tables are skipped — the feed observes
341
+ * save/delete. Framework-owned tables listed in
342
+ * {@link CHANGE_FEED_EXCLUDED_TABLES} are skipped — the feed observes
311
343
  * application data, not framework bookkeeping (and never itself).
312
344
  *
313
345
  * Failure policy: appends run after the user's write succeeded and must not
@@ -1 +1 @@
1
- {"version":3,"file":"change-feed.d.ts","sourceRoot":"","sources":["../src/change-feed.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"change-feed.d.ts","sourceRoot":"","sources":["../src/change-feed.ts"],"names":[],"mappings":"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"}
@@ -1,9 +1,11 @@
1
1
  import { resolveDbCacheKey } from "./collection-cache.js";
2
2
  import { publishChangeSignal } from "./change-signals.js";
3
3
  import { resolveDispatchTenantScope } from "./dispatch/tenant-resolver.js";
4
+ import { isEmbeddedDatabase, withEmbeddedWriteQueue } from "./embedded-write-queue.js";
4
5
  import { GlobalInterceptors } from "./interceptors.js";
5
6
  import { detectEngine } from "./schema/ddl/index.js";
6
- import { CREATE_SMRT_CHANGES_TABLE, ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, ENSURE_POSTGRES_CHANGE_FEED_SCHEMA, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME, REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION } from "./system/schema.js";
7
+ import { CREATE_SMRT_CHANGES_TABLE, ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, ENSURE_POSTGRES_CHANGE_FEED_SCHEMA, FRAMEWORK_OPERATIONAL_TABLES, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME, REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, RETIRED_SYSTEM_TABLES } from "./system/schema.js";
8
+ import { SYSTEM_TABLE_NAMES } from "./schema/system-table-shapes.js";
7
9
  import { createLogger } from "@happyvertical/logger";
8
10
  //#region src/change-feed.ts
9
11
  /**
@@ -100,6 +102,35 @@ import { createLogger } from "@happyvertical/logger";
100
102
  var logger = createLogger({ level: "info" });
101
103
  /** Name of the append-only change-feed system table. */
102
104
  var CHANGE_FEED_TABLE = "_smrt_changes";
105
+ /**
106
+ * Tables the change-feed writer never records (issue #2376).
107
+ *
108
+ * The writer used to skip every table whose name started with `_smrt_`. That
109
+ * prefix stopped meaning "framework bookkeeping" once packages began naming
110
+ * their *domain* tables `_smrt_feature_overrides`, `_smrt_prompt_overrides`,
111
+ * `_smrt_subscription_plans`, `_smrt_report_schedules`, `_smrt_field_policies`
112
+ * and ~20 more: clients syncing those through `_changes` never saw an update.
113
+ *
114
+ * The exclusion is now an allowlist of tables the framework actually owns —
115
+ * the hand-written bookkeeping DDL ({@link SYSTEM_TABLE_NAMES}, which includes
116
+ * the feed's own table so it can never observe itself), the model-backed
117
+ * operational plumbing ({@link FRAMEWORK_OPERATIONAL_TABLES}), and the retired
118
+ * system tables that may still exist on older databases.
119
+ */
120
+ var CHANGE_FEED_EXCLUDED_TABLES = /* @__PURE__ */ new Set([
121
+ ...SYSTEM_TABLE_NAMES,
122
+ ...FRAMEWORK_OPERATIONAL_TABLES,
123
+ ...RETIRED_SYSTEM_TABLES
124
+ ]);
125
+ /**
126
+ * Whether framework writes to `tableName` are recorded in the change feed.
127
+ *
128
+ * Exported so tooling and tests can reason about feed coverage without
129
+ * re-deriving the rule. See {@link CHANGE_FEED_EXCLUDED_TABLES}.
130
+ */
131
+ function isChangeFeedObservableTable(tableName) {
132
+ return Boolean(tableName) && !CHANGE_FEED_EXCLUDED_TABLES.has(tableName);
133
+ }
103
134
  /** Interceptor name of the framework's change-feed writer. */
104
135
  var CHANGE_FEED_INTERCEPTOR_NAME = "smrt-change-feed";
105
136
  /** Default page size for {@link getChangesSince}. */
@@ -266,7 +297,7 @@ async function appendChange(db, input) {
266
297
  (/* @__PURE__ */ new Date()).toISOString()
267
298
  ];
268
299
  for (let attempt = 1; attempt <= MAX_APPEND_ATTEMPTS; attempt++) try {
269
- const row = getQueryRows(await db.query(sql, ...params))[0];
300
+ const row = getQueryRows(await withEmbeddedWriteQueue(db, isEmbeddedDatabase(db), () => db.query(sql, ...params)))[0];
270
301
  if (!row) throw new Error("Change feed append returned no result row");
271
302
  if (engine === "postgres" && row.error_code != null) {
272
303
  const error = new Error(String(row.error_message || "PostgreSQL change-feed append failed"));
@@ -503,10 +534,11 @@ function normalizeTimestamp(value) {
503
534
  * larger than the slowest consumer's polling interval; consumers whose
504
535
  * cursor falls out of it are told to full-resync via `resyncRequired`.
505
536
  *
506
- * @returns The number of entries pruned (approximate under concurrent prunes).
537
+ * @returns The number of entries pruned or, with `dryRun`, the number the
538
+ * same bounds would prune (approximate under concurrent prunes).
507
539
  */
508
540
  async function pruneChangeFeed(db, retention) {
509
- const { maxAgeMs, maxRows } = retention;
541
+ const { maxAgeMs, maxRows, dryRun = false } = retention;
510
542
  if (maxAgeMs == null && maxRows == null) throw new Error("pruneChangeFeed requires maxAgeMs and/or maxRows");
511
543
  if (maxAgeMs != null && (!Number.isFinite(maxAgeMs) || maxAgeMs < 0)) throw new Error(`pruneChangeFeed maxAgeMs must be >= 0, got ${maxAgeMs}`);
512
544
  if (maxRows != null && (!Number.isFinite(maxRows) || maxRows < 0)) throw new Error(`pruneChangeFeed maxRows must be >= 0, got ${maxRows}`);
@@ -514,19 +546,27 @@ async function pruneChangeFeed(db, retention) {
514
546
  const horizon = toSeqNumber(getQueryRows(await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`))[0]?.horizon);
515
547
  if (horizon === 0) return { pruned: 0 };
516
548
  let pruned = 0;
549
+ let prunedThrough = 0;
517
550
  if (maxRows != null) {
518
551
  const pruneThrough = Math.min(horizon - Math.floor(maxRows), horizon - 1);
519
- if (pruneThrough > 0) pruned += await deleteCounted(db, `seq <= ${p(1)}`, [pruneThrough]);
552
+ if (pruneThrough > 0) {
553
+ prunedThrough = pruneThrough;
554
+ pruned += await deleteCounted(db, `seq <= ${p(1)}`, [pruneThrough], dryRun);
555
+ }
520
556
  }
521
557
  if (maxAgeMs != null) {
522
558
  const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
523
- pruned += await deleteCounted(db, `created_at < ${p(1)} AND seq < ${p(2)}`, [cutoff, horizon]);
559
+ pruned += await deleteCounted(db, `created_at < ${p(1)} AND seq < ${p(2)} AND seq > ${p(3)}`, [
560
+ cutoff,
561
+ horizon,
562
+ prunedThrough
563
+ ], dryRun);
524
564
  }
525
565
  return { pruned };
526
566
  }
527
- async function deleteCounted(db, condition, params) {
567
+ async function deleteCounted(db, condition, params, dryRun = false) {
528
568
  const total = toSeqNumber(getQueryRows(await db.query(`SELECT COUNT(*) AS total FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`, ...params))[0]?.total);
529
- if (total > 0) await db.query(`DELETE FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`, ...params);
569
+ if (total > 0 && !dryRun) await db.query(`DELETE FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`, ...params);
530
570
  return total;
531
571
  }
532
572
  var WAS_PERSISTED_KEY = "_smrtChangeFeedWasPersisted";
@@ -548,7 +588,8 @@ var warnedSignalPublishFailures = /* @__PURE__ */ new Set();
548
588
  * - `beforeSave` stashes whether the instance was already persisted (this
549
589
  * is what distinguishes `create` from `update` in the feed).
550
590
  * - `afterSave`/`afterDelete` append exactly one change entry per framework
551
- * save/delete. `_smrt_*` system tables are skipped — the feed observes
591
+ * save/delete. Framework-owned tables listed in
592
+ * {@link CHANGE_FEED_EXCLUDED_TABLES} are skipped — the feed observes
552
593
  * application data, not framework bookkeeping (and never itself).
553
594
  *
554
595
  * Failure policy: appends run after the user's write succeeded and must not
@@ -584,7 +625,7 @@ async function appendForInstance(instance, operation) {
584
625
  let table;
585
626
  try {
586
627
  table = instance.tableName;
587
- if (!table || table.startsWith("_smrt_")) return;
628
+ if (!isChangeFeedObservableTable(table)) return;
588
629
  db = instance.db;
589
630
  } catch {
590
631
  return;
@@ -639,6 +680,6 @@ function resetChangeFeedWarnings() {
639
680
  warnedSignalPublishFailures.clear();
640
681
  }
641
682
  //#endregion
642
- export { CHANGE_FEED_INTERCEPTOR_NAME, CHANGE_FEED_TABLE, DEFAULT_CHANGES_LIMIT, MAX_CHANGES_LIMIT, appendChange, bumpChangeFeed, ensureChangeFeedTable, ensurePostgresChangeFeedAppendFunction, getChangesSince, getTableVersion, getTenantScopedChangesSince, pruneChangeFeed, registerChangeFeedWriter, resetChangeFeedWarnings, unregisterChangeFeedWriter };
683
+ export { CHANGE_FEED_EXCLUDED_TABLES, CHANGE_FEED_INTERCEPTOR_NAME, CHANGE_FEED_TABLE, DEFAULT_CHANGES_LIMIT, MAX_CHANGES_LIMIT, appendChange, bumpChangeFeed, ensureChangeFeedTable, ensurePostgresChangeFeedAppendFunction, getChangesSince, getTableVersion, getTenantScopedChangesSince, isChangeFeedObservableTable, pruneChangeFeed, registerChangeFeedWriter, resetChangeFeedWarnings, unregisterChangeFeedWriter };
643
684
 
644
685
  //# sourceMappingURL=change-feed.js.map
@@ -1 +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 * one small INSERT (issued from the write path *after* the user's row was\n * written), so the serialization window is one statement; conflicts resolve\n * with a bounded retry loop and are impossible on single-writer engines\n * (SQLite). PostgreSQL invokes that INSERT through the framework-owned\n * `_smrt_append_change` function so failure isolation remains one statement.\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. PostgreSQL runs the INSERT in a\n * PL/pgSQL exception subtransaction and returns a caught SQLSTATE as data;\n * JavaScript only throws/logs after PostgreSQL has restored the caller's\n * transaction, so a swallowed append failure cannot surface later as 25P02.\n * The append still joins a caller-managed transaction on the same handle and\n * shares its fate (a rollback removes the change row with the data 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` plus a fresh resume cursor instead of silently\n * 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 { publishChangeSignal } from './change-signals.js';\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 {\n CREATE_SMRT_CHANGES_TABLE,\n ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION,\n ENSURE_POSTGRES_CHANGE_FEED_SCHEMA,\n POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY,\n POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME,\n REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION,\n} 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 before resuming from {@link ChangeFeedPage.resyncCursor}.\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 * After its full data refetch, the consumer should resume polling from\n * {@link resyncCursor}, the committed horizon observed by this read.\n * Detection is computed on the **unfiltered** log: `tables`/`tenantId`\n * filters legitimately hide rows and never trigger (or mask) a resync\n * signal.\n */\n resyncRequired?: boolean;\n /**\n * Current committed horizon to use after handling a resync. Present with\n * {@link resyncRequired}; separated from `cursor` so old callers that rely\n * on `cursor` echoing the rejected value keep their monotonic-cursor\n * invariant.\n */\n resyncCursor?: number;\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(\n db: DatabaseInterface,\n typeHint?: string,\n): ReturnType<typeof detectEngine> {\n const withConfig = db as DatabaseWithConfig;\n return detectEngine(\n db.url || withConfig.config?.url || '',\n typeHint || 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 signals: string[] = [];\n const pending: unknown[] = [error];\n const seen = new Set<object>();\n\n while (pending.length > 0 && seen.size < 5) {\n const candidate = pending.shift();\n if (typeof candidate === 'string') {\n signals.push(candidate);\n continue;\n }\n if (!candidate || typeof candidate !== 'object' || seen.has(candidate)) {\n continue;\n }\n seen.add(candidate);\n\n const shaped = candidate as {\n cause?: unknown;\n code?: unknown;\n context?: unknown;\n message?: unknown;\n };\n if (typeof shaped.message === 'string') signals.push(shaped.message);\n if (typeof shaped.code === 'string') signals.push(shaped.code);\n if (shaped.cause !== undefined) pending.push(shaped.cause);\n\n if (shaped.context && typeof shaped.context === 'object') {\n const originalError = (shaped.context as { originalError?: unknown })\n .originalError;\n if (originalError !== undefined) pending.push(originalError);\n }\n }\n\n const message = signals.join(', ');\n return (\n /\\b23505\\b/.test(message) ||\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\nasync function postgresChangeFeedAppendFunctionExists(\n db: DatabaseInterface,\n): Promise<boolean> {\n const rows = getQueryRows(\n await db.query(\n `SELECT to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}') AS function_name`,\n ),\n );\n return Boolean(rows[0]?.function_name);\n}\n\nasync function getPostgresChangeFeedSchemaState(\n db: DatabaseInterface,\n): Promise<{\n tableExists: boolean;\n functionExists: boolean;\n createdAtType: string | null;\n}> {\n const rows = getQueryRows(\n await db.query(\n `SELECT\n to_regclass('${CHANGE_FEED_TABLE}') AS table_name,\n to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}') AS function_name,\n (\n SELECT data_type\n FROM information_schema.columns\n WHERE table_schema = current_schema()\n AND table_name = '${CHANGE_FEED_TABLE}'\n AND column_name = 'created_at'\n ) AS created_at_type`,\n ),\n );\n return {\n tableExists: Boolean(rows[0]?.table_name),\n functionExists: Boolean(rows[0]?.function_name),\n createdAtType: rows[0]?.created_at_type\n ? String(rows[0].created_at_type)\n : null,\n };\n}\n\nfunction assertPostgresChangeFeedTimestampCurrent(\n state: Awaited<ReturnType<typeof getPostgresChangeFeedSchemaState>>,\n): void {\n if (state.createdAtType === 'timestamp without time zone') {\n throw new Error(\n 'Legacy _smrt_changes.created_at requires an explicit audited migratePostgresSystemTimestamps() call before change-feed initialization',\n );\n }\n}\n\n/**\n * Install/refresh the PostgreSQL exception-subtransaction append boundary.\n *\n * Framework bootstrap calls this while applying the system-schema version that\n * introduced the helper, so upgraded databases acquire it before the migration\n * is recorded. Raw-handle initialization passes `replaceExisting: false` so a\n * read route does not require function ownership when the installed helper is\n * already current. A missing helper is installed by one server-side statement\n * that locks and rechecks before DDL. Non-PostgreSQL adapters are a no-op.\n *\n * @internal\n */\nexport async function ensurePostgresChangeFeedAppendFunction(\n db: DatabaseInterface,\n options: {\n replaceExisting?: boolean;\n typeHint?: string;\n } = {},\n): Promise<void> {\n if (getEngine(db, options.typeHint) !== 'postgres') return;\n\n assertPostgresChangeFeedTimestampCurrent(\n await getPostgresChangeFeedSchemaState(db),\n );\n\n if (options.replaceExisting === false) {\n if (await postgresChangeFeedAppendFunctionExists(db)) return;\n await db.query(ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION);\n return;\n }\n\n await db.query(REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION);\n}\n\nexport async function ensureChangeFeedTable(\n db: DatabaseInterface,\n): Promise<void> {\n if (ensuredHandles.has(db)) return;\n if (getEngine(db) === 'postgres') {\n const state = await getPostgresChangeFeedSchemaState(db);\n assertPostgresChangeFeedTimestampCurrent(state);\n if (\n state.tableExists &&\n state.functionExists &&\n state.createdAtType === 'timestamp with time zone'\n ) {\n ensuredHandles.add(db);\n return;\n }\n await db.query(ENSURE_POSTGRES_CHANGE_FEED_SCHEMA);\n } else {\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 }\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 * **PostgreSQL transaction safety (#2026).** The INSERT runs inside the\n * framework-owned `_smrt_append_change` PL/pgSQL function. Its exception\n * handler is a PostgreSQL subtransaction: a failed attempt is rolled back\n * before the function returns `{ error_code, error_message }`. This method\n * then throws in JavaScript, where the existing retry/swallow policy applies\n * without aborting a caller-managed transaction. Keeping isolation inside one\n * database statement also prevents concurrent work on the same transaction\n * handle from interleaving inside a manual SAVEPOINT scope.\n */\nexport async function appendChange(\n db: DatabaseInterface,\n input: AppendChangeInput,\n): Promise<number> {\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 engine = getEngine(db);\n const p = placeholders(db);\n // The INSERT yields the ACTUAL sequence it allocated in the SAME statement\n // (directly via RETURNING on portable engines, through the function on\n // PostgreSQL). A separate follow-up `SELECT MAX(seq)` is racy under concurrent\n // appends (a peer can commit a higher seq in between), which would hand two\n // distinct changes the same SSE `id` and let a client's `Last-Event-ID`\n // overshoot a change it never received. The allocator stays `MAX+1` under the\n // unique-PK retry, so committed sequences remain contiguous (the cursor\n // guarantee — see module docs).\n const sql =\n engine === 'postgres'\n ? `SELECT allocated_seq, error_code, error_message FROM ` +\n `${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME}(` +\n `${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)})`\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} RETURNING seq`;\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 const rows = getQueryRows(await db.query(sql, ...params));\n const row = rows[0];\n if (!row) {\n throw new Error('Change feed append returned no result row');\n }\n if (engine === 'postgres' && row.error_code != null) {\n const error = new Error(\n String(row.error_message || 'PostgreSQL change-feed append failed'),\n ) as Error & { code: string };\n error.code = String(row.error_code);\n throw error;\n }\n return toSeqNumber(engine === 'postgres' ? row.allocated_seq : row.seq);\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 // Unreachable: the loop returns a seq or throws on the final attempt. Present\n // so the function satisfies its `Promise<number>` contract structurally.\n throw new Error('appendChange exhausted retries without allocating a seq');\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,\n * `resyncCursor` set to the current horizon) so pollers never go silently,\n * 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, resyncCursor: 0 };\n }\n\n if (since > horizon) {\n // Foreign or reset cursor — ahead of anything this database allocated.\n return {\n changes: [],\n cursor: since,\n resyncRequired: true,\n resyncCursor: horizon,\n };\n }\n\n if (since < floor - 1) {\n // Pruned gap — the changes with seq in (since, floor) are gone for good.\n return {\n changes: [],\n cursor: since,\n resyncRequired: true,\n resyncCursor: horizon,\n };\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\n/**\n * The per-table change version — the ETag source for zero-query conditional\n * GETs (#1765).\n *\n * Returns `MAX(seq)` over the feed rows for `table`: a monotonic number that\n * advances on every framework write to that table (create/update/delete, and\n * writes through the sync-apply endpoint, which all `save()`/`delete()`).\n * Because sequences are the change feed's globally-monotonic cursor dimension\n * (allocated `MAX+1` at commit time, never a native identity — see the module\n * docs), the value is **replica-stable**: two processes reading the same\n * committed database compute the same version, with no per-process divergence.\n * That is what lets a generated read route derive an ETag that short-circuits a\n * matching `If-None-Match` into a `304` before the collection query runs — an\n * unchanged table costs one indexed `MAX(seq)` lookup (backed by\n * `idx_smrt_changes_table_seq`) to revalidate, not a table scan.\n *\n * ## Why the fallback to the global horizon (and not 0)\n *\n * A table with no *retained* feed entry falls back to the global horizon\n * (`MAX(seq)` across all tables), returning 0 only when the whole feed is\n * empty. Retention prunes oldest-first and always keeps the newest entry, so a\n * quiet table can lose all of its own entries while busier tables advance. If\n * such a table reported 0, a client that cached it while it was empty (version\n * 0) could, after a change→prune→change→prune cycle returned the lookup to 0,\n * be wrongly answered `304` against data that has since changed — a false-304.\n *\n * The horizon fallback closes that hole: any write to the table appends a new\n * sequence strictly greater than every previously-observed value (its own or\n * the horizon), so the version — and therefore the ETag — strictly exceeds any\n * value a client already holds, forcing a fresh `200`. The only cost is that a\n * table with no retained entries of its own revalidates whenever the global\n * horizon moves; a table with a retained entry uses its own stable `MAX(seq)`\n * and is unaffected by writes to sibling tables. A persistent per-table\n * high-water mark that survives pruning would remove even that cost; it is a\n * deliberate follow-up, out of scope for this slice.\n *\n * Idempotently ensures the feed table exists first, so it is safe to call from\n * a read route on a raw handle that has never been written to.\n */\nexport async function getTableVersion(\n db: DatabaseInterface,\n table: string,\n): Promise<number> {\n const name = table?.trim();\n if (!name) {\n throw new Error('getTableVersion requires a non-empty table name');\n }\n await ensureChangeFeedTable(db);\n\n const p = placeholders(db);\n const tableRows = getQueryRows(\n await db.query(\n `SELECT MAX(seq) AS version FROM ${CHANGE_FEED_TABLE} WHERE table_name = ${p(1)}`,\n name,\n ),\n );\n const tableVersion = tableRows[0]?.version;\n if (tableVersion != null) {\n return toSeqNumber(tableVersion);\n }\n\n // No retained entry for this table — fall back to the global horizon so an\n // all-pruned (or never-written) table never reports a resettable low value\n // that could false-304 a stale client. 0 only when the feed is empty.\n const horizonRows = getQueryRows(\n await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`),\n );\n return toSeqNumber(horizonRows[0]?.horizon);\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/** Databases we already warned about after a failed signal publish (#1763). */\nconst warnedSignalPublishFailures = 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 const rowId = typeof id === 'string' && id ? id : null;\n const rowTenantId =\n typeof tenantId === 'string' && tenantId ? tenantId : null;\n const seq = await appendChange(db, {\n table,\n rowId,\n operation,\n tenantId: rowTenantId,\n });\n\n // Publish a coarse live signal for the SSE `_events` route (#1763). This\n // runs only after the durable feed append SUCCEEDED (same try block, so a\n // failed append never emits a signal — \"no signal without a durable feed\n // row\"). Its own try/catch (distinct dedup key) keeps a signal-publish\n // problem from failing the user's write or masking the append's own\n // failure semantics above.\n try {\n publishChangeSignal(db, {\n table,\n operation,\n rowId,\n tenantId: rowTenantId,\n seq,\n });\n } catch (error) {\n warnSignalPublishFailureOnce(db, table, error);\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\nfunction warnSignalPublishFailureOnce(\n db: DatabaseInterface,\n table: string,\n error: unknown,\n): void {\n try {\n const dbKey = resolveDbCacheKey(db);\n if (warnedSignalPublishFailures.has(dbKey)) return;\n warnedSignalPublishFailures.add(dbKey);\n logger.warn(\n `Change feed: failed to publish a live change signal for '${table}'. ` +\n 'The write and its durable feed row are unaffected; live SSE ' +\n 'subscribers miss this signal but recover via cursor catch-up ' +\n '(further failures for this database are suppressed).',\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 and signal-publish warning dedups (test helper).\n */\nexport function resetChangeFeedWarnings(): void {\n warnedAppendFailures.clear();\n warnedSignalPublishFailures.clear();\n}\n"],"mappings":"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"}
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 * one small INSERT (issued from the write path *after* the user's row was\n * written), so the serialization window is one statement; conflicts resolve\n * with a bounded retry loop and are impossible on single-writer engines\n * (SQLite). PostgreSQL invokes that INSERT through the framework-owned\n * `_smrt_append_change` function so failure isolation remains one statement.\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. PostgreSQL runs the INSERT in a\n * PL/pgSQL exception subtransaction and returns a caught SQLSTATE as data;\n * JavaScript only throws/logs after PostgreSQL has restored the caller's\n * transaction, so a swallowed append failure cannot surface later as 25P02.\n * The append still joins a caller-managed transaction on the same handle and\n * shares its fate (a rollback removes the change row with the data 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` plus a fresh resume cursor instead of silently\n * 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 { publishChangeSignal } from './change-signals.js';\nimport { resolveDbCacheKey } from './collection-cache.js';\nimport { resolveDispatchTenantScope } from './dispatch/tenant-resolver.js';\nimport {\n isEmbeddedDatabase,\n withEmbeddedWriteQueue,\n} from './embedded-write-queue.js';\nimport { GlobalInterceptors, type InterceptorContext } from './interceptors.js';\nimport type { SmrtObject } from './object.js';\nimport { detectEngine } from './schema/ddl/index.js';\nimport { SYSTEM_TABLE_NAMES } from './schema/system-table-shapes.js';\nimport {\n CREATE_SMRT_CHANGES_TABLE,\n ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION,\n ENSURE_POSTGRES_CHANGE_FEED_SCHEMA,\n FRAMEWORK_OPERATIONAL_TABLES,\n POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY,\n POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME,\n REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION,\n RETIRED_SYSTEM_TABLES,\n} 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/**\n * Tables the change-feed writer never records (issue #2376).\n *\n * The writer used to skip every table whose name started with `_smrt_`. That\n * prefix stopped meaning \"framework bookkeeping\" once packages began naming\n * their *domain* tables `_smrt_feature_overrides`, `_smrt_prompt_overrides`,\n * `_smrt_subscription_plans`, `_smrt_report_schedules`, `_smrt_field_policies`\n * and ~20 more: clients syncing those through `_changes` never saw an update.\n *\n * The exclusion is now an allowlist of tables the framework actually owns —\n * the hand-written bookkeeping DDL ({@link SYSTEM_TABLE_NAMES}, which includes\n * the feed's own table so it can never observe itself), the model-backed\n * operational plumbing ({@link FRAMEWORK_OPERATIONAL_TABLES}), and the retired\n * system tables that may still exist on older databases.\n */\nexport const CHANGE_FEED_EXCLUDED_TABLES: ReadonlySet<string> = new Set([\n ...SYSTEM_TABLE_NAMES,\n ...FRAMEWORK_OPERATIONAL_TABLES,\n ...RETIRED_SYSTEM_TABLES,\n]);\n\n/**\n * Whether framework writes to `tableName` are recorded in the change feed.\n *\n * Exported so tooling and tests can reason about feed coverage without\n * re-deriving the rule. See {@link CHANGE_FEED_EXCLUDED_TABLES}.\n */\nexport function isChangeFeedObservableTable(tableName: string): boolean {\n return Boolean(tableName) && !CHANGE_FEED_EXCLUDED_TABLES.has(tableName);\n}\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 before resuming from {@link ChangeFeedPage.resyncCursor}.\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 * After its full data refetch, the consumer should resume polling from\n * {@link resyncCursor}, the committed horizon observed by this read.\n * Detection is computed on the **unfiltered** log: `tables`/`tenantId`\n * filters legitimately hide rows and never trigger (or mask) a resync\n * signal.\n */\n resyncRequired?: boolean;\n /**\n * Current committed horizon to use after handling a resync. Present with\n * {@link resyncRequired}; separated from `cursor` so old callers that rely\n * on `cursor` echoing the rejected value keep their monotonic-cursor\n * invariant.\n */\n resyncCursor?: number;\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 * Count the entries the bounds select without deleting them (#2375).\n *\n * Lets the retention sweep and `smrt db:prune --dry-run` preview the exact\n * same predicate the real prune would execute.\n */\n dryRun?: boolean;\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(\n db: DatabaseInterface,\n typeHint?: string,\n): ReturnType<typeof detectEngine> {\n const withConfig = db as DatabaseWithConfig;\n return detectEngine(\n db.url || withConfig.config?.url || '',\n typeHint || 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 signals: string[] = [];\n const pending: unknown[] = [error];\n const seen = new Set<object>();\n\n while (pending.length > 0 && seen.size < 5) {\n const candidate = pending.shift();\n if (typeof candidate === 'string') {\n signals.push(candidate);\n continue;\n }\n if (!candidate || typeof candidate !== 'object' || seen.has(candidate)) {\n continue;\n }\n seen.add(candidate);\n\n const shaped = candidate as {\n cause?: unknown;\n code?: unknown;\n context?: unknown;\n message?: unknown;\n };\n if (typeof shaped.message === 'string') signals.push(shaped.message);\n if (typeof shaped.code === 'string') signals.push(shaped.code);\n if (shaped.cause !== undefined) pending.push(shaped.cause);\n\n if (shaped.context && typeof shaped.context === 'object') {\n const originalError = (shaped.context as { originalError?: unknown })\n .originalError;\n if (originalError !== undefined) pending.push(originalError);\n }\n }\n\n const message = signals.join(', ');\n return (\n /\\b23505\\b/.test(message) ||\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\nasync function postgresChangeFeedAppendFunctionExists(\n db: DatabaseInterface,\n): Promise<boolean> {\n const rows = getQueryRows(\n await db.query(\n `SELECT to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}') AS function_name`,\n ),\n );\n return Boolean(rows[0]?.function_name);\n}\n\nasync function getPostgresChangeFeedSchemaState(\n db: DatabaseInterface,\n): Promise<{\n tableExists: boolean;\n functionExists: boolean;\n createdAtType: string | null;\n}> {\n const rows = getQueryRows(\n await db.query(\n `SELECT\n to_regclass('${CHANGE_FEED_TABLE}') AS table_name,\n to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}') AS function_name,\n (\n SELECT data_type\n FROM information_schema.columns\n WHERE table_schema = current_schema()\n AND table_name = '${CHANGE_FEED_TABLE}'\n AND column_name = 'created_at'\n ) AS created_at_type`,\n ),\n );\n return {\n tableExists: Boolean(rows[0]?.table_name),\n functionExists: Boolean(rows[0]?.function_name),\n createdAtType: rows[0]?.created_at_type\n ? String(rows[0].created_at_type)\n : null,\n };\n}\n\nfunction assertPostgresChangeFeedTimestampCurrent(\n state: Awaited<ReturnType<typeof getPostgresChangeFeedSchemaState>>,\n): void {\n if (state.createdAtType === 'timestamp without time zone') {\n throw new Error(\n 'Legacy _smrt_changes.created_at requires an explicit audited migratePostgresSystemTimestamps() call before change-feed initialization',\n );\n }\n}\n\n/**\n * Install/refresh the PostgreSQL exception-subtransaction append boundary.\n *\n * Framework bootstrap calls this while applying the system-schema version that\n * introduced the helper, so upgraded databases acquire it before the migration\n * is recorded. Raw-handle initialization passes `replaceExisting: false` so a\n * read route does not require function ownership when the installed helper is\n * already current. A missing helper is installed by one server-side statement\n * that locks and rechecks before DDL. Non-PostgreSQL adapters are a no-op.\n *\n * @internal\n */\nexport async function ensurePostgresChangeFeedAppendFunction(\n db: DatabaseInterface,\n options: {\n replaceExisting?: boolean;\n typeHint?: string;\n } = {},\n): Promise<void> {\n if (getEngine(db, options.typeHint) !== 'postgres') return;\n\n assertPostgresChangeFeedTimestampCurrent(\n await getPostgresChangeFeedSchemaState(db),\n );\n\n if (options.replaceExisting === false) {\n if (await postgresChangeFeedAppendFunctionExists(db)) return;\n await db.query(ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION);\n return;\n }\n\n await db.query(REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION);\n}\n\nexport async function ensureChangeFeedTable(\n db: DatabaseInterface,\n): Promise<void> {\n if (ensuredHandles.has(db)) return;\n if (getEngine(db) === 'postgres') {\n const state = await getPostgresChangeFeedSchemaState(db);\n assertPostgresChangeFeedTimestampCurrent(state);\n if (\n state.tableExists &&\n state.functionExists &&\n state.createdAtType === 'timestamp with time zone'\n ) {\n ensuredHandles.add(db);\n return;\n }\n await db.query(ENSURE_POSTGRES_CHANGE_FEED_SCHEMA);\n } else {\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 }\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 * **PostgreSQL transaction safety (#2026).** The INSERT runs inside the\n * framework-owned `_smrt_append_change` PL/pgSQL function. Its exception\n * handler is a PostgreSQL subtransaction: a failed attempt is rolled back\n * before the function returns `{ error_code, error_message }`. This method\n * then throws in JavaScript, where the existing retry/swallow policy applies\n * without aborting a caller-managed transaction. Keeping isolation inside one\n * database statement also prevents concurrent work on the same transaction\n * handle from interleaving inside a manual SAVEPOINT scope.\n */\nexport async function appendChange(\n db: DatabaseInterface,\n input: AppendChangeInput,\n): Promise<number> {\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 engine = getEngine(db);\n const p = placeholders(db);\n // The INSERT yields the ACTUAL sequence it allocated in the SAME statement\n // (directly via RETURNING on portable engines, through the function on\n // PostgreSQL). A separate follow-up `SELECT MAX(seq)` is racy under concurrent\n // appends (a peer can commit a higher seq in between), which would hand two\n // distinct changes the same SSE `id` and let a client's `Last-Event-ID`\n // overshoot a change it never received. The allocator stays `MAX+1` under the\n // unique-PK retry, so committed sequences remain contiguous (the cursor\n // guarantee — see module docs).\n const sql =\n engine === 'postgres'\n ? `SELECT allocated_seq, error_code, error_message FROM ` +\n `${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME}(` +\n `${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)})`\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} RETURNING seq`;\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 // The append is a root-connection write; on embedded engines it goes\n // through the per-database write queue so it can never overlap a\n // null-aware upsert's second-connection transaction (#2360 — the pair\n // livelocked into SQLITE_BUSY under concurrent NULL-tenant creates).\n const rows = getQueryRows(\n await withEmbeddedWriteQueue(db, isEmbeddedDatabase(db), () =>\n db.query(sql, ...params),\n ),\n );\n const row = rows[0];\n if (!row) {\n throw new Error('Change feed append returned no result row');\n }\n if (engine === 'postgres' && row.error_code != null) {\n const error = new Error(\n String(row.error_message || 'PostgreSQL change-feed append failed'),\n ) as Error & { code: string };\n error.code = String(row.error_code);\n throw error;\n }\n return toSeqNumber(engine === 'postgres' ? row.allocated_seq : row.seq);\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 // Unreachable: the loop returns a seq or throws on the final attempt. Present\n // so the function satisfies its `Promise<number>` contract structurally.\n throw new Error('appendChange exhausted retries without allocating a seq');\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,\n * `resyncCursor` set to the current horizon) so pollers never go silently,\n * 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, resyncCursor: 0 };\n }\n\n if (since > horizon) {\n // Foreign or reset cursor — ahead of anything this database allocated.\n return {\n changes: [],\n cursor: since,\n resyncRequired: true,\n resyncCursor: horizon,\n };\n }\n\n if (since < floor - 1) {\n // Pruned gap — the changes with seq in (since, floor) are gone for good.\n return {\n changes: [],\n cursor: since,\n resyncRequired: true,\n resyncCursor: horizon,\n };\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\n/**\n * The per-table change version — the ETag source for zero-query conditional\n * GETs (#1765).\n *\n * Returns `MAX(seq)` over the feed rows for `table`: a monotonic number that\n * advances on every framework write to that table (create/update/delete, and\n * writes through the sync-apply endpoint, which all `save()`/`delete()`).\n * Because sequences are the change feed's globally-monotonic cursor dimension\n * (allocated `MAX+1` at commit time, never a native identity — see the module\n * docs), the value is **replica-stable**: two processes reading the same\n * committed database compute the same version, with no per-process divergence.\n * That is what lets a generated read route derive an ETag that short-circuits a\n * matching `If-None-Match` into a `304` before the collection query runs — an\n * unchanged table costs one indexed `MAX(seq)` lookup (backed by\n * `idx_smrt_changes_table_seq`) to revalidate, not a table scan.\n *\n * ## Why the fallback to the global horizon (and not 0)\n *\n * A table with no *retained* feed entry falls back to the global horizon\n * (`MAX(seq)` across all tables), returning 0 only when the whole feed is\n * empty. Retention prunes oldest-first and always keeps the newest entry, so a\n * quiet table can lose all of its own entries while busier tables advance. If\n * such a table reported 0, a client that cached it while it was empty (version\n * 0) could, after a change→prune→change→prune cycle returned the lookup to 0,\n * be wrongly answered `304` against data that has since changed — a false-304.\n *\n * The horizon fallback closes that hole: any write to the table appends a new\n * sequence strictly greater than every previously-observed value (its own or\n * the horizon), so the version — and therefore the ETag — strictly exceeds any\n * value a client already holds, forcing a fresh `200`. The only cost is that a\n * table with no retained entries of its own revalidates whenever the global\n * horizon moves; a table with a retained entry uses its own stable `MAX(seq)`\n * and is unaffected by writes to sibling tables. A persistent per-table\n * high-water mark that survives pruning would remove even that cost; it is a\n * deliberate follow-up, out of scope for this slice.\n *\n * Idempotently ensures the feed table exists first, so it is safe to call from\n * a read route on a raw handle that has never been written to.\n */\nexport async function getTableVersion(\n db: DatabaseInterface,\n table: string,\n): Promise<number> {\n const name = table?.trim();\n if (!name) {\n throw new Error('getTableVersion requires a non-empty table name');\n }\n await ensureChangeFeedTable(db);\n\n const p = placeholders(db);\n const tableRows = getQueryRows(\n await db.query(\n `SELECT MAX(seq) AS version FROM ${CHANGE_FEED_TABLE} WHERE table_name = ${p(1)}`,\n name,\n ),\n );\n const tableVersion = tableRows[0]?.version;\n if (tableVersion != null) {\n return toSeqNumber(tableVersion);\n }\n\n // No retained entry for this table — fall back to the global horizon so an\n // all-pruned (or never-written) table never reports a resettable low value\n // that could false-304 a stale client. 0 only when the feed is empty.\n const horizonRows = getQueryRows(\n await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`),\n );\n return toSeqNumber(horizonRows[0]?.horizon);\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 — or, with `dryRun`, the number the\n * same bounds would prune (approximate under concurrent prunes).\n */\nexport async function pruneChangeFeed(\n db: DatabaseInterface,\n retention: ChangeFeedRetention,\n): Promise<{ pruned: number }> {\n const { maxAgeMs, maxRows, dryRun = false } = 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 let prunedThrough = 0;\n\n if (maxRows != null) {\n const pruneThrough = Math.min(horizon - Math.floor(maxRows), horizon - 1);\n if (pruneThrough > 0) {\n prunedThrough = pruneThrough;\n pruned += await deleteCounted(\n db,\n `seq <= ${p(1)}`,\n [pruneThrough],\n dryRun,\n );\n }\n }\n\n if (maxAgeMs != null) {\n const cutoff = new Date(Date.now() - maxAgeMs).toISOString();\n // `seq > prunedThrough` excludes what the row bound already accounted for.\n // Redundant when the rows were really deleted, load-bearing under\n // `dryRun`, where nothing was — without it overlapping entries would be\n // counted by both bounds.\n pruned += await deleteCounted(\n db,\n `created_at < ${p(1)} AND seq < ${p(2)} AND seq > ${p(3)}`,\n [cutoff, horizon, prunedThrough],\n dryRun,\n );\n }\n\n return { pruned };\n}\n\nasync function deleteCounted(\n db: DatabaseInterface,\n condition: string,\n params: unknown[],\n dryRun = false,\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 && !dryRun) {\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/** Databases we already warned about after a failed signal publish (#1763). */\nconst warnedSignalPublishFailures = 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. Framework-owned tables listed in\n * {@link CHANGE_FEED_EXCLUDED_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 // Framework-owned tables are bookkeeping, not client-syncable data —\n // recording them would let the feed observe (and re-observe) itself. The\n // test is an allowlist, not the `_smrt_` prefix: ~25 domain tables carry\n // that prefix and must be observed (issue #2376).\n if (!isChangeFeedObservableTable(table)) 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 const rowId = typeof id === 'string' && id ? id : null;\n const rowTenantId =\n typeof tenantId === 'string' && tenantId ? tenantId : null;\n const seq = await appendChange(db, {\n table,\n rowId,\n operation,\n tenantId: rowTenantId,\n });\n\n // Publish a coarse live signal for the SSE `_events` route (#1763). This\n // runs only after the durable feed append SUCCEEDED (same try block, so a\n // failed append never emits a signal — \"no signal without a durable feed\n // row\"). Its own try/catch (distinct dedup key) keeps a signal-publish\n // problem from failing the user's write or masking the append's own\n // failure semantics above.\n try {\n publishChangeSignal(db, {\n table,\n operation,\n rowId,\n tenantId: rowTenantId,\n seq,\n });\n } catch (error) {\n warnSignalPublishFailureOnce(db, table, error);\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\nfunction warnSignalPublishFailureOnce(\n db: DatabaseInterface,\n table: string,\n error: unknown,\n): void {\n try {\n const dbKey = resolveDbCacheKey(db);\n if (warnedSignalPublishFailures.has(dbKey)) return;\n warnedSignalPublishFailures.add(dbKey);\n logger.warn(\n `Change feed: failed to publish a live change signal for '${table}'. ` +\n 'The write and its durable feed row are unaffected; live SSE ' +\n 'subscribers miss this signal but recover via cursor catch-up ' +\n '(further failures for this database are suppressed).',\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 and signal-publish warning dedups (test helper).\n */\nexport function resetChangeFeedWarnings(): void {\n warnedAppendFailures.clear();\n warnedSignalPublishFailures.clear();\n}\n"],"mappings":"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"}
package/dist/class.d.ts CHANGED
@@ -205,11 +205,20 @@ export declare class SmrtClass {
205
205
  /**
206
206
  * Ensure SMRT system tables exist in the database
207
207
  *
208
- * System tables use _smrt_ prefix and store framework metadata:
208
+ * System tables use the _smrt_ prefix and store framework metadata:
209
209
  * - _smrt_contexts: Context memory storage for remembered patterns
210
210
  * - _smrt_migrations: Schema version tracking
211
- * - _smrt_registry: Object registry persistence
212
- * - _smrt_signals: Signal history/audit log
211
+ * - _smrt_schema_migrations / _smrt_backfills: Migration and backfill ledgers
212
+ * - _smrt_embeddings: Embedding vectors for semantic search
213
+ * - _smrt_dispatch / _smrt_dispatch_subscriptions: Inter-agent dispatch queue
214
+ * - _smrt_ai_usage: AI usage telemetry
215
+ * - _smrt_changes: Append-only change feed
216
+ *
217
+ * Note that the _smrt_ prefix alone does NOT mean "system table" — ~25
218
+ * `@smrt()` domain tables (feature flags, prompt overrides, subscription
219
+ * plans, …) carry it too and are created by `db:migrate`, not here. The
220
+ * framework's own list is `SYSTEM_TABLE_NAMES` in
221
+ * `schema/system-table-shapes.ts`, derived from this DDL.
213
222
  *
214
223
  * This method is idempotent and safe to call multiple times.
215
224
  * Tables are only created once per database connection.
@@ -218,6 +227,30 @@ export declare class SmrtClass {
218
227
  private withSystemTableBootstrapLock;
219
228
  private rollbackSystemTableBootstrap;
220
229
  private bootstrapSystemTables;
230
+ /**
231
+ * Run the compatibility pass for the system tables `db:migrate` creates, then
232
+ * stamp its own marker in `_smrt_migrations` (issue #2376).
233
+ *
234
+ * Deliberately runs OUTSIDE the bootstrap lock/transaction, and deliberately
235
+ * cannot fail startup:
236
+ *
237
+ * - `_smrt_jobs` / `_smrt_job_events` are created by `db:migrate` from the
238
+ * jobs manifest, which on a fresh install runs AFTER the framework's first
239
+ * bootstrap. Gating this pass on {@link SMRT_SCHEMA_VERSION} would mean the
240
+ * version gets stamped while the tables are still absent and the pass never
241
+ * runs on that database again. It therefore carries its own marker, which
242
+ * is written only once every deferred table exists and has been upgraded.
243
+ * - Its statements are ALTER TABLE / CREATE INDEX against tables the
244
+ * framework does not own. Inside the PostgreSQL bootstrap transaction one
245
+ * failure (a permissions problem, say) would abort the transaction and roll
246
+ * back system-table creation with it. Outside, a failure is a logged
247
+ * warning and the next process start retries — the pass is idempotent and
248
+ * already tolerates concurrent creators.
249
+ *
250
+ * Until the tables exist this costs two catalog probes per process per
251
+ * database; afterwards the marker short-circuits it.
252
+ */
253
+ private settleDeferredSystemTableCompatibility;
221
254
  private ensureNativeVectorStorage;
222
255
  /**
223
256
  * Access system tables through standard database interface
@@ -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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"}