@happyvertical/smrt-core 0.49.3 → 0.49.4

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 (156) hide show
  1. package/README.md +4 -0
  2. package/agents/change-feed.md +19 -0
  3. package/agents/collection-reads.md +21 -0
  4. package/agents/generators.md +5 -0
  5. package/agents/memory.md +23 -3
  6. package/agents/null-equal-indexes.md +88 -0
  7. package/agents/object-runtime.md +85 -0
  8. package/agents/schema-paths.md +7 -7
  9. package/dist/browser.js +2 -2
  10. package/dist/cascade.d.ts.map +1 -1
  11. package/dist/cascade.js +13 -10
  12. package/dist/cascade.js.map +1 -1
  13. package/dist/change-feed.d.ts +22 -0
  14. package/dist/change-feed.d.ts.map +1 -1
  15. package/dist/change-feed.js +147 -6
  16. package/dist/change-feed.js.map +1 -1
  17. package/dist/class.js +1 -1
  18. package/dist/collection-cache.d.ts +10 -1
  19. package/dist/collection-cache.d.ts.map +1 -1
  20. package/dist/collection-cache.js +65 -10
  21. package/dist/collection-cache.js.map +1 -1
  22. package/dist/collection.d.ts +69 -2
  23. package/dist/collection.d.ts.map +1 -1
  24. package/dist/collection.js +142 -9
  25. package/dist/collection.js.map +1 -1
  26. package/dist/consumer-plugin/artifact-publication.d.ts +27 -0
  27. package/dist/consumer-plugin/artifact-publication.d.ts.map +1 -0
  28. package/dist/consumer-plugin/artifact-publication.js +80 -0
  29. package/dist/consumer-plugin/artifact-publication.js.map +1 -0
  30. package/dist/consumer-plugin/index.d.ts.map +1 -1
  31. package/dist/consumer-plugin/index.js +37 -4
  32. package/dist/consumer-plugin/index.js.map +1 -1
  33. package/dist/db-errors.js +1 -1
  34. package/dist/db-errors.js.map +1 -1
  35. package/dist/embedded-write-queue.d.ts +1 -1
  36. package/dist/embedded-write-queue.d.ts.map +1 -1
  37. package/dist/embedded-write-queue.js +16 -6
  38. package/dist/embedded-write-queue.js.map +1 -1
  39. package/dist/embeddings/storage.d.ts +16 -0
  40. package/dist/embeddings/storage.d.ts.map +1 -1
  41. package/dist/embeddings/storage.js +40 -0
  42. package/dist/embeddings/storage.js.map +1 -1
  43. package/dist/index.d.ts +1 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +3 -3
  46. package/dist/interceptors.d.ts +22 -0
  47. package/dist/interceptors.d.ts.map +1 -1
  48. package/dist/interceptors.js +12 -0
  49. package/dist/interceptors.js.map +1 -1
  50. package/dist/junction.d.ts +12 -3
  51. package/dist/junction.d.ts.map +1 -1
  52. package/dist/junction.js +34 -4
  53. package/dist/junction.js.map +1 -1
  54. package/dist/knowledge-config.d.ts +6 -0
  55. package/dist/knowledge-config.d.ts.map +1 -0
  56. package/dist/knowledge-config.js +48 -0
  57. package/dist/knowledge-config.js.map +1 -0
  58. package/dist/manifest/static-manifest.d.ts.map +1 -1
  59. package/dist/manifest/static-manifest.js +91 -1
  60. package/dist/manifest/static-manifest.js.map +1 -1
  61. package/dist/manifest/store.js +1 -1
  62. package/dist/manifest/store.js.map +1 -1
  63. package/dist/manifest.json +104 -1
  64. package/dist/migrations/differ.d.ts.map +1 -1
  65. package/dist/migrations/differ.js +2 -0
  66. package/dist/migrations/differ.js.map +1 -1
  67. package/dist/migrations/generator.d.ts.map +1 -1
  68. package/dist/migrations/generator.js +2 -0
  69. package/dist/migrations/generator.js.map +1 -1
  70. package/dist/migrations/index.d.ts +1 -0
  71. package/dist/migrations/index.d.ts.map +1 -1
  72. package/dist/migrations/index.js +2 -1
  73. package/dist/migrations/null-equal-indexes.d.ts +37 -0
  74. package/dist/migrations/null-equal-indexes.d.ts.map +1 -0
  75. package/dist/migrations/null-equal-indexes.js +133 -0
  76. package/dist/migrations/null-equal-indexes.js.map +1 -0
  77. package/dist/migrations/tracker.d.ts.map +1 -1
  78. package/dist/migrations/tracker.js +6 -0
  79. package/dist/migrations/tracker.js.map +1 -1
  80. package/dist/migrations.js +2 -1
  81. package/dist/object.d.ts +33 -0
  82. package/dist/object.d.ts.map +1 -1
  83. package/dist/object.js +316 -46
  84. package/dist/object.js.map +1 -1
  85. package/dist/plain-json.d.ts +6 -0
  86. package/dist/plain-json.d.ts.map +1 -0
  87. package/dist/plain-json.js +41 -0
  88. package/dist/plain-json.js.map +1 -0
  89. package/dist/postgres-permissions.d.ts.map +1 -1
  90. package/dist/postgres-permissions.js +6 -2
  91. package/dist/postgres-permissions.js.map +1 -1
  92. package/dist/prebuild/index.d.ts.map +1 -1
  93. package/dist/prebuild/index.js +3 -3
  94. package/dist/prebuild/index.js.map +1 -1
  95. package/dist/registry/class-registration.d.ts.map +1 -1
  96. package/dist/registry/class-registration.js +4 -1
  97. package/dist/registry/class-registration.js.map +1 -1
  98. package/dist/registry/schema-builder.d.ts.map +1 -1
  99. package/dist/registry/schema-builder.js +4 -3
  100. package/dist/registry/schema-builder.js.map +1 -1
  101. package/dist/registry/validator.d.ts +1 -0
  102. package/dist/registry/validator.d.ts.map +1 -1
  103. package/dist/registry/validator.js +8 -1
  104. package/dist/registry/validator.js.map +1 -1
  105. package/dist/registry.js +1 -1
  106. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  107. package/dist/scanner/manifest-generator.js +21 -0
  108. package/dist/scanner/manifest-generator.js.map +1 -1
  109. package/dist/scanner/types.d.ts +2 -0
  110. package/dist/scanner/types.d.ts.map +1 -1
  111. package/dist/scanner/types.js.map +1 -1
  112. package/dist/schema/conflict-target.d.ts +8 -2
  113. package/dist/schema/conflict-target.d.ts.map +1 -1
  114. package/dist/schema/conflict-target.js +11 -3
  115. package/dist/schema/conflict-target.js.map +1 -1
  116. package/dist/schema/ddl/null-equal-index.d.ts +5 -0
  117. package/dist/schema/ddl/null-equal-index.d.ts.map +1 -0
  118. package/dist/schema/ddl/null-equal-index.js +17 -0
  119. package/dist/schema/ddl/null-equal-index.js.map +1 -0
  120. package/dist/schema/ddl/postgres-strategy.d.ts +2 -1
  121. package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
  122. package/dist/schema/ddl/postgres-strategy.js +7 -0
  123. package/dist/schema/ddl/postgres-strategy.js.map +1 -1
  124. package/dist/schema/generator.d.ts.map +1 -1
  125. package/dist/schema/generator.js +8 -3
  126. package/dist/schema/generator.js.map +1 -1
  127. package/dist/schema/manifest-schema.d.ts +2 -1
  128. package/dist/schema/manifest-schema.d.ts.map +1 -1
  129. package/dist/schema/manifest-schema.js +12 -4
  130. package/dist/schema/manifest-schema.js.map +1 -1
  131. package/dist/schema/schema-manager.d.ts.map +1 -1
  132. package/dist/schema/schema-manager.js +1 -1
  133. package/dist/schema/schema-manager.js.map +1 -1
  134. package/dist/schema/types.d.ts +2 -0
  135. package/dist/schema/types.d.ts.map +1 -1
  136. package/dist/scripts/browser-plain-json-regression.mjs +85 -0
  137. package/dist/smrt-knowledge.json +73 -15
  138. package/dist/system/schema.d.ts +14 -3
  139. package/dist/system/schema.d.ts.map +1 -1
  140. package/dist/system/schema.js +120 -2
  141. package/dist/system/schema.js.map +1 -1
  142. package/dist/vite-plugin/api-client-entries.d.ts.map +1 -1
  143. package/dist/vite-plugin/api-client-entries.js +9 -7
  144. package/dist/vite-plugin/api-client-entries.js.map +1 -1
  145. package/dist/vite-plugin/index.d.ts +24 -0
  146. package/dist/vite-plugin/index.d.ts.map +1 -1
  147. package/dist/vite-plugin/index.js +47 -64
  148. package/dist/vite-plugin/index.js.map +1 -1
  149. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  150. package/dist/vite-plugin/sveltekit-generator.js +2 -2
  151. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  152. package/dist/vite-plugin/web-collections.d.ts +10 -0
  153. package/dist/vite-plugin/web-collections.d.ts.map +1 -1
  154. package/dist/vite-plugin/web-collections.js +14 -2
  155. package/dist/vite-plugin/web-collections.js.map +1 -1
  156. package/package.json +5 -4
@@ -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 * ## Staged appends inside caller transactions (PostgreSQL, #2649)\n *\n * `MAX+1` allocation costs a *wait*: two appends that pick the same value\n * conflict on the primary key, and the loser waits for the winner's\n * transaction to end. Under autocommit that is one statement. Inside a\n * caller-managed transaction it is the whole transaction — and a long write\n * transaction that appends and then keeps taking row locks forms a genuine\n * lock cycle with any writer that took those row locks first and then\n * appended. PostgreSQL detects it (`40P01`) and aborts one side, so an\n * ordinary concurrent request could abort a legitimate long write.\n *\n * PostgreSQL appends issued inside a caller transaction are therefore\n * **staged**: `_smrt_append_change` inserts into `_smrt_changes_pending`,\n * whose identity key conflicts with nothing, so the append never waits on\n * another transaction and the cycle cannot form. The staged row is still\n * fate-shared with the caller (a rollback removes it). {@link drainChangeFeed}\n * — run by {@link getChangesSince} before it reads, and (throttled) by the\n * append path itself — moves *committed* staged rows into `_smrt_changes`\n * under a try-only advisory lock, numbering them `MAX(seq) + row_number()` in\n * staged order. Draining is driven from JavaScript rather than inside the\n * append helper so that every sequenced entry also gets its live `_events`\n * signal: an entry sequenced invisibly server-side would be skipped for good\n * by a subscriber whose `Last-Event-ID` came from a later, higher sequence.\n * Signals for drained entries are published only once the drain is proven\n * committed, so a rollback can never advertise a sequence another appender\n * then reuses.\n *\n * The cursor guarantee is unchanged, because sequences are still allocated by\n * exactly one `MAX+1` writer at a time and only ever for already-committed\n * work: committed sequences stay contiguous, and no entry can appear at or\n * below a horizon a reader already observed. What a staged append gives up is\n * *promptness*, not durability or order: its entry becomes visible one drain\n * after its transaction commits, and its position in the log is its drain\n * order rather than its statement order. An autocommit append drains before\n * allocating whenever its throttle allows, so staged work usually keeps its\n * place ahead of later writes.\n *\n * SQLite and DuckDB keep the direct `MAX+1` insert unchanged — SQLite\n * serializes writers outright, so the defect is unreachable there.\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 staged 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 { type ChangeSignal, 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 POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_IDENTITY,\n POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_NAME,\n POSTGRES_CHANGE_FEED_HELPER_MARKER,\n POSTGRES_CHANGE_FEED_PENDING_TABLE,\n REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION,\n RETIRED_SYSTEM_TABLES,\n} from './system/schema.js';\nimport { toSafeInteger } from './utils/safe-integer.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 // PostgreSQL-only staging table for deferred appends (#2649). It is created\n // by the change-feed ensure path rather than the portable system DDL, so it\n // is not covered by SYSTEM_TABLE_NAMES.\n POSTGRES_CHANGE_FEED_PENDING_TABLE,\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 /**\n * Whether to sequence staged entries before reading (default `true`). On\n * PostgreSQL that drain writes through `_smrt_drain_changes()`; a\n * diagnostics reader that must stay SELECT-only passes `false` and\n * accepts that entries still staged by an open caller transaction remain\n * invisible until a writer drains them.\n */\n drain?: boolean;\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\n/**\n * Maximum bounded drain batches one {@link drainChangeFeed} call sequences.\n * A cap rather than \"until empty\" so a pathological writer cannot make one\n * reader drain forever; the remainder is picked up by the next drain.\n */\nconst MAX_DRAIN_PASSES = 50;\n\n/** Cap on signals held for a handle that keeps draining without committing. */\nconst MAX_DEFERRED_SIGNALS = 5_000;\n\n/**\n * Per-handle low-water mark of sequences a drain allocated but has not proven\n * committed (#2649).\n *\n * The hold-back has to outlive the drain call that created it. A caller\n * transaction can drain in one statement and read in the next — the second\n * read's own drain allocates nothing (the server-side helper refuses once the\n * transaction has an id), yet the transaction still sees the first drain's\n * uncommitted rows and would serve them. Keyed on the handle object, which is\n * the transaction's identity for its whole life, so every later read on the\n * same transaction keeps holding back from the same mark.\n *\n * The mark is recorded unconditionally and *used* only while a transaction id\n * is still assigned at read time. Under autocommit the drain committed as its\n * own transaction, so the next read clears the mark and serves the rows.\n */\nconst uncommittedDrainMarks = new WeakMap<object, number>();\n\n/**\n * How often the append path drains on its own behalf, per database.\n *\n * The append helper deliberately does not drain server-side: sequences it\n * assigned there would never reach JavaScript, so no `_events` signal would be\n * published for them while this append's own signal carries a higher sequence\n * — and an EventSource resuming from that `Last-Event-ID` would skip them for\n * good. Draining from here instead keeps every sequenced entry signalled, in\n * ascending order, and keeps a deployment whose writers never read the feed\n * from accumulating staged entries indefinitely. Throttled because it is a\n * liveness convenience, not a correctness requirement: cursor readers drain\n * for themselves, and `getTableVersion()` already counts staged entries.\n */\nconst APPEND_DRAIN_INTERVAL_MS = 250;\n\n/** dbKey → last time the append path drained for it. */\nconst lastAppendDrain = new Map<string, number>();\n\n/** dbKeys that staged an append since this process last drained them. */\nconst stagedSinceLastDrain = new Set<string>();\n\nfunction noteStagedAppend(db: DatabaseInterface): void {\n try {\n stagedSinceLastDrain.add(resolveDbCacheKey(db));\n } catch {\n // Unkeyable handle — the interval below still drives the drain.\n }\n}\n\nasync function drainBeforeAppend(\n db: DatabaseInterface,\n): Promise<ChangeSignal[]> {\n if (getEngine(db) !== 'postgres') return [];\n let dbKey: string;\n try {\n dbKey = resolveDbCacheKey(db);\n } catch {\n return [];\n }\n const now = Date.now();\n const last = lastAppendDrain.get(dbKey);\n // A handle that staged an append since its last drain skips the interval:\n // that entry is known to be waiting for a sequence and a signal.\n if (\n !stagedSinceLastDrain.has(dbKey) &&\n last !== undefined &&\n now - last < APPEND_DRAIN_INTERVAL_MS\n ) {\n return [];\n }\n stagedSinceLastDrain.delete(dbKey);\n lastAppendDrain.set(dbKey, now);\n // Inside a caller transaction the drain helper refuses server-side, so this\n // costs one cheap statement and never sequences anything there. `settle:\n // false, maxPasses: 1` keep it to that ONE statement: the caller may own the\n // surrounding transaction, and any further statement issued here could fail\n // and abort it behind the feed's own error-swallowing (#2026). The drain\n // statement itself cannot — the helper catches everything, preflight\n // included, and returns failures as data — but a write path is no place to\n // loop over a backlog either; the rest drains on the next append or read.\n // The signals come back undecided; appendChange settles them without\n // spending a statement.\n try {\n return (await drainChangeFeedDetailed(db, { settle: false, maxPasses: 1 }))\n .unsettledSignals;\n } catch (error) {\n warnDrainFailureOnce(db, error);\n return [];\n }\n}\n\nfunction recordUncommittedDrainMark(\n db: DatabaseInterface,\n firstAllocatedSeq: number | null,\n): void {\n if (firstAllocatedSeq === null) return;\n const key = db as unknown as object;\n const existing = uncommittedDrainMarks.get(key);\n if (existing === undefined || firstAllocatedSeq < existing) {\n uncommittedDrainMarks.set(key, firstAllocatedSeq);\n }\n}\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\n/**\n * SQL fragment that reports a helper as present ONLY when its body is current.\n *\n * `to_regprocedure()` alone answers \"a function of this name and signature\n * exists\", which is not the same question: an install can hold a helper whose\n * body predates a fix, and an existence probe would call it current and never\n * replace it. Matching {@link POSTGRES_CHANGE_FEED_HELPER_MARKER} against\n * `pg_proc.prosrc` makes the probe version-aware, so bumping the marker is all\n * a future helper change needs to reach existing databases.\n */\nfunction currentHelperProbe(identity: string): string {\n return `(\n SELECT p.oid\n FROM pg_proc AS p\n WHERE p.oid = to_regprocedure('${identity}')\n AND p.prosrc LIKE '%${POSTGRES_CHANGE_FEED_HELPER_MARKER}%'\n )`;\n}\n\nasync function postgresChangeFeedHelpersCurrent(\n db: DatabaseInterface,\n): Promise<boolean> {\n const rows = getQueryRows(\n await db.query(\n `SELECT\n ${currentHelperProbe(POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY)} AS function_name,\n ${currentHelperProbe(POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_IDENTITY)} AS drain_function_name,\n to_regclass('${POSTGRES_CHANGE_FEED_PENDING_TABLE}') AS pending_table_name`,\n ),\n );\n return Boolean(\n rows[0]?.function_name &&\n rows[0]?.drain_function_name &&\n rows[0]?.pending_table_name,\n );\n}\n\nasync function getPostgresChangeFeedSchemaState(\n db: DatabaseInterface,\n): Promise<{\n tableExists: boolean;\n functionExists: boolean;\n pendingTableExists: boolean;\n drainFunctionExists: 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 ${currentHelperProbe(POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY)} AS function_name,\n to_regclass('${POSTGRES_CHANGE_FEED_PENDING_TABLE}') AS pending_table_name,\n ${currentHelperProbe(POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_IDENTITY)} AS drain_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 pendingTableExists: Boolean(rows[0]?.pending_table_name),\n drainFunctionExists: Boolean(rows[0]?.drain_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 postgresChangeFeedHelpersCurrent(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\n/**\n * Refresh the PostgreSQL change-feed helpers on an already-initialized\n * database (issue #2649).\n *\n * `bootstrapSystemTables()` installs the helpers only while applying a system\n * schema version, and #2649 changes the helper body without changing the\n * portable system DDL — so a database already stamped with the current version\n * would keep the pre-#2649 append function, and its `40P01` deadlock, until\n * some feed route happened to call {@link ensureChangeFeedTable}. Ordinary\n * model writes never do. Bootstrap therefore calls this before its\n * version fast-return; it is one catalog probe when the helpers are current.\n *\n * Databases whose `_smrt_changes.created_at` is still the legacy\n * timezone-naive column are left alone: they need their audited\n * `migratePostgresSystemTimestamps()` pass first, and failing bootstrap on\n * them here would be a new, unrelated break.\n *\n * @internal\n */\nexport async function ensurePostgresChangeFeedHelpers(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n if (getEngine(db, typeHint) !== 'postgres') return;\n const state = await getPostgresChangeFeedSchemaState(db);\n if (!state.tableExists) return;\n if (state.createdAtType !== 'timestamp with time zone') return;\n if (\n state.functionExists &&\n state.pendingTableExists &&\n state.drainFunctionExists\n ) {\n return;\n }\n await db.query(ENSURE_POSTGRES_CHANGE_FEED_SCHEMA);\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.pendingTableExists &&\n state.drainFunctionExists &&\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 * **Returns `null` for a staged append** (PostgreSQL, #2649): an append issued\n * inside a caller-managed transaction is written to `_smrt_changes_pending`\n * and receives its sequence from the next {@link drainChangeFeed} after that\n * transaction commits, so no sequence exists to return yet. The entry is\n * durable and ordered — it simply is not numbered at this instant. Callers\n * that need the sequence (an SSE event id, say) must treat `null` as \"not\n * available yet\", never as a failure: a failure still throws.\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 | null> {\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 // Sequence anything staged by a committed transaction before taking a\n // sequence of our own, so signals stay in ascending order and no staged\n // entry is left unsignalled beneath this one (see drainBeforeAppend). Their\n // signals are settled below, once this append tells us whether that drain\n // committed.\n const drainedSignals = await drainBeforeAppend(db);\n\n /**\n * Settle the pre-append drain's signals from what this append revealed.\n *\n * A drain that allocated anything assigned a transaction id, so if this\n * append then took the DIRECT path — `pg_current_xact_id_if_assigned()` was\n * still null at its entry — that id is already gone, which can only mean the\n * drain committed as its own autocommit transaction. Publishing here is\n * therefore proven safe AND correctly ordered: these sequences are below the\n * one this append is about to take, and the interceptor publishes that one\n * only after this call returns. A deferred append proves nothing, so its\n * signals go to the queue.\n */\n const settleDrainedSignals = (appended: number | null): void => {\n if (drainedSignals.length === 0) return;\n if (appended === null) {\n queueDeferredSignals(db, drainedSignals);\n return;\n }\n publishSignals(db, drainedSignals);\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 if (engine === 'postgres' && row.allocated_seq == null) {\n noteStagedAppend(db);\n settleDrainedSignals(null);\n // Staged, not sequenced (#2649): the append ran inside a\n // caller-managed transaction, so it went to `_smrt_changes_pending`\n // and receives its sequence from the next drain after that\n // transaction commits. Durable and ordered, just not numbered yet.\n return null;\n }\n const seq = toSeqNumber(\n engine === 'postgres' ? row.allocated_seq : row.seq,\n );\n settleDrainedSignals(seq);\n if (engine === 'postgres') {\n // A direct allocation made as a caller transaction's FIRST statement\n // is this transaction's uncommitted work, exactly like a drain's, and\n // a read on the same handle must not serve it or hand back a cursor\n // above it — a rollback frees that sequence for somebody else. Record\n // it unconditionally: an autocommit read clears the mark before using\n // it, because no transaction id remains by then.\n recordUncommittedDrainMark(db, seq);\n }\n return seq;\n } catch (error) {\n if (!isUniqueViolation(error) || attempt === MAX_APPEND_ATTEMPTS) {\n // Nothing was proven about the drain; hold its signals for a settling\n // drain rather than dropping or publishing them.\n queueDeferredSignals(db, drainedSignals);\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 | null>` contract structurally.\n throw new Error('appendChange exhausted retries without allocating a seq');\n}\n\n/**\n * Sequence change-feed entries staged inside caller-managed transactions\n * (issue #2649). PostgreSQL only; a no-op on every other engine.\n *\n * A `save()`/`delete()`/{@link appendChange} issued inside a caller\n * transaction cannot allocate `MAX(seq) + 1` there: the allocation waits for\n * any competing appender's transaction to end, and a long write transaction\n * that goes on to take row locks the waiter holds closes a real lock cycle\n * (`40P01`). Such appends are staged instead, and this call moves every\n * **committed** staged entry into `_smrt_changes` with contiguous sequences,\n * in staged order, under a try-only advisory lock.\n *\n * It runs by itself on the paths that matter — every autocommit append drains\n * server-side before allocating its own sequence, and {@link getChangesSince}\n * drains before it reads — so applications do not normally need to call it. A\n * drain issued from inside a transaction that has already written is skipped\n * server-side: allocating there would hold sequences uncommitted for the rest\n * of that transaction, which is the wait this fix removes.\n * Call it explicitly from a scheduled job when a deployment writes *only*\n * through transactions and reads the feed from a connection that cannot write\n * (a read replica or a read-only role), because neither self-draining path is\n * then available.\n *\n * Best-effort by the feed's failure policy: a drain that cannot run leaves the\n * staged entries in place for the next attempt and never throws into the\n * caller's write path. Nothing is lost — staged entries are durable — they are\n * simply not yet visible to cursor readers.\n *\n * @returns How many staged entries were sequenced.\n */\nexport async function drainChangeFeed(db: DatabaseInterface): Promise<number> {\n return (await drainChangeFeedDetailed(db)).drained;\n}\n\n/**\n * {@link drainChangeFeed} plus the lowest sequence this call allocated.\n *\n * A drain runs on the caller's handle, so when that handle is inside a\n * caller-managed transaction the rows it just wrote are visible **to this\n * transaction only** and disappear if it rolls back. A reader that served them\n * would report entries that never committed and advance its cursor past\n * sequences a concurrent autocommit appender goes on to claim — a permanently\n * skipped change. {@link getChangesSince} therefore refuses to serve at or\n * above `firstAllocatedSeq`: everything below it was committed by somebody\n * else before this drain started, and the drain's own rows are served by the\n * next poll, once they are committed for everyone.\n *\n * @internal\n */\nasync function drainChangeFeedDetailed(\n db: DatabaseInterface,\n options: { settle?: boolean; maxPasses?: number } = {},\n): Promise<{\n drained: number;\n firstAllocatedSeq: number | null;\n unsettledSignals: ChangeSignal[];\n}> {\n if (getEngine(db) !== 'postgres')\n return { drained: 0, firstAllocatedSeq: null, unsettledSignals: [] };\n\n // `settle: false` is the write path (see drainBeforeAppend): it issues the\n // drain statement and NOTHING else, because every additional statement run\n // on the caller's handle could fail and abort a caller-owned transaction,\n // which the feed's failure policy would then swallow — leaving the caller in\n // PostgreSQL's aborted state (`25P02`), the exact #2026 hazard. The drain\n // statement itself is safe: the helper catches everything, preflight\n // included, and returns failures as data. Its signals are queued and settled\n // later, from a read path that is free to issue extra statements.\n const settle = options.settle !== false;\n\n // Anything a previous, unproven drain queued is settled first: its\n // transaction has ended by now, and each queued entry is published only if\n // its sequence still holds the row it was drained for.\n if (settle) await flushDeferredSignals(db);\n\n let firstAllocatedSeq: number | null = null;\n let drained = 0;\n const unsettledSignals: ChangeSignal[] = [];\n // Each call sequences one bounded batch; loop so a large backlog is not left\n // behind, with a hard iteration cap so a persistent failure cannot spin.\n const maxPasses = Math.max(1, options.maxPasses ?? MAX_DRAIN_PASSES);\n for (let pass = 0; pass < maxPasses; pass++) {\n const rows = getQueryRows(\n await db.query(\n `SELECT * FROM ${POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_NAME}()`,\n ),\n );\n const failure = rows.find((row) => row.error_code != null);\n if (failure) {\n const error = new Error(\n String(failure.error_message || 'PostgreSQL change-feed drain failed'),\n ) as Error & { code: string };\n error.code = String(failure.error_code);\n // A concurrent autocommit append can win the head between this drain's\n // MAX(seq) read and its insert. The whole batch rolled back, so the\n // staged rows are still there — recompute and try again, exactly like\n // the appender's own conflict retry.\n if (isUniqueViolation(error)) continue;\n throw error;\n }\n const sequenced = rows.filter((row) => row.drained_seq != null);\n drained += sequenced.length;\n for (const row of sequenced) {\n const seq = toSeqNumber(row.drained_seq);\n if (firstAllocatedSeq === null || seq < firstAllocatedSeq) {\n firstAllocatedSeq = seq;\n }\n }\n\n // A drained entry is cursor-visible once its sequence is committed, which\n // is the first honest moment to signal it. Publish ONLY after proving the\n // drain committed: a drain that ran as the first statement of a caller\n // transaction is still uncommitted, and a rollback would release the\n // sequences it just advertised for a concurrent appender to reuse — a\n // subscriber would then hold a `Last-Event-ID` naming somebody else's\n // entry and skip the real one.\n //\n // An unproven drain's entries are QUEUED, never dropped. Once its\n // transaction commits they are no longer staged, so no later drain would\n // ever return them again — and an entry that is never signalled while a\n // later one is is exactly the skip this is guarding against.\n const signals = sequenced.map((row) => ({\n table: String(row.drained_table ?? ''),\n operation: String(row.drained_operation ?? 'update') as ChangeOperation,\n rowId: row.drained_row_id == null ? null : String(row.drained_row_id),\n tenantId:\n row.drained_tenant_id == null ? null : String(row.drained_tenant_id),\n seq: toSeqNumber(row.drained_seq),\n }));\n if (signals.length > 0) {\n if (!settle) {\n // The write path decides: see appendChange, which can prove these\n // committed without spending a statement on it.\n unsettledSignals.push(...signals);\n } else if (await drainCommitted(db)) {\n publishSignals(db, signals);\n } else {\n queueDeferredSignals(db, signals);\n }\n }\n\n if (sequenced.length === 0) break;\n }\n recordUncommittedDrainMark(db, firstAllocatedSeq);\n return { drained, firstAllocatedSeq, unsettledSignals };\n}\n\n/** Signals a drain produced but could not yet prove committed. */\nconst deferredSignals = new Map<string, ChangeSignal[]>();\n\nfunction publishSignals(db: DatabaseInterface, signals: ChangeSignal[]): void {\n for (const signal of signals) {\n try {\n publishChangeSignal(db, signal);\n } catch (error) {\n warnSignalPublishFailureOnce(db, signal.table, error);\n }\n }\n}\n\nfunction queueDeferredSignals(\n db: DatabaseInterface,\n signals: ChangeSignal[],\n): void {\n try {\n const dbKey = resolveDbCacheKey(db);\n const queued = deferredSignals.get(dbKey);\n if (queued) {\n queued.push(...signals);\n // Bound the queue: a handle that only ever drains inside uncommitted\n // transactions must not grow it without limit. Dropped entries cost a\n // live signal, never a feed row — cursor readers still see them.\n if (queued.length > MAX_DEFERRED_SIGNALS) {\n queued.splice(0, queued.length - MAX_DEFERRED_SIGNALS);\n }\n } else {\n deferredSignals.set(dbKey, [...signals]);\n }\n } catch {\n // Unkeyable handle — the signal is best-effort, the feed row is not.\n }\n}\n\n/**\n * Publish queued signals whose sequences are now committed.\n *\n * A queued entry's transaction may have rolled back, which returns its row to\n * the staging table and frees its sequence for somebody else. Each queued\n * signal is therefore verified against the committed log before it is\n * published: the sequence must still carry the same table and row.\n *\n * An entry that does not verify is **kept, not dropped**. The queue is keyed\n * per database, and every connection to that database shares it — so a second\n * connection can reach this while the transaction that queued the entry is\n * still open. Its own commit probe says \"not in a transaction\", but it cannot\n * see the other connection's uncommitted rows, and discarding on that basis\n * would strand a signal nobody can ever republish once that transaction\n * commits. Unverified entries therefore wait for a later flush, bounded only\n * by {@link MAX_DEFERRED_SIGNALS}, which evicts oldest-first — an entry whose\n * transaction really did roll back is re-sequenced and re-signalled by a later\n * drain regardless.\n */\nasync function flushDeferredSignals(db: DatabaseInterface): Promise<void> {\n let dbKey: string;\n try {\n dbKey = resolveDbCacheKey(db);\n } catch {\n return;\n }\n const queued = deferredSignals.get(dbKey);\n if (!queued || queued.length === 0) return;\n\n // Claim the queue synchronously, before the first await. Two drains on the\n // same database run concurrently, and a claim taken after an await lets both\n // verify and publish the same entries — duplicate SSE event ids. Whatever\n // this flush does not publish is put back below.\n deferredSignals.delete(dbKey);\n const candidates = queued;\n let unpublished = candidates;\n try {\n if (!(await drainCommitted(db))) return;\n unpublished = await publishVerifiedSignals(db, candidates);\n } finally {\n requeueDeferredSignals(dbKey, unpublished);\n }\n}\n\n/**\n * Publish the queued signals whose sequences still carry the row they were\n * drained for, and return the ones that could not be verified.\n */\nasync function publishVerifiedSignals(\n db: DatabaseInterface,\n candidates: ChangeSignal[],\n): Promise<ChangeSignal[]> {\n const p = placeholders(db);\n const rows = getQueryRows(\n await db.query(\n `SELECT seq, table_name, row_id FROM ${CHANGE_FEED_TABLE} WHERE seq IN (${candidates\n .map((_, index) => p(index + 1))\n .join(', ')})`,\n ...candidates.map((signal) => signal.seq),\n ),\n );\n const identity = (table: string, rowId: string | null) =>\n `${table}\\u0000${rowId ?? ''}`;\n const committed = new Map(\n rows.map((row) => [\n toSeqNumber(row.seq),\n identity(\n String(row.table_name ?? ''),\n row.row_id == null ? null : String(row.row_id),\n ),\n ]),\n );\n\n const verified = candidates.filter(\n (signal) =>\n committed.get(signal.seq) === identity(signal.table, signal.rowId),\n );\n if (verified.length === 0) return candidates;\n\n const publishedSeqs = new Set(verified.map((signal) => signal.seq));\n publishSignals(db, verified);\n return candidates.filter((signal) => !publishedSeqs.has(signal.seq));\n}\n\n/** Put unpublished signals back, ahead of anything queued meanwhile. */\nfunction requeueDeferredSignals(dbKey: string, signals: ChangeSignal[]): void {\n if (signals.length === 0) return;\n const queuedSince = deferredSignals.get(dbKey) ?? [];\n const merged = [...signals, ...queuedSince];\n deferredSignals.set(\n dbKey,\n merged.length > MAX_DEFERRED_SIGNALS\n ? merged.slice(merged.length - MAX_DEFERRED_SIGNALS)\n : merged,\n );\n}\n\n/**\n * Whether the drain that just ran has committed.\n *\n * Under autocommit the drain was its own transaction and ended with its\n * statement, so no transaction id is assigned by the time this separate\n * statement runs. Inside a caller transaction the id the drain assigned is\n * still live and its rows are still uncommitted.\n */\nasync function drainCommitted(db: DatabaseInterface): Promise<boolean> {\n const rows = getQueryRows(\n await db.query(\n 'SELECT pg_current_xact_id_if_assigned() IS NULL AS committed',\n ),\n );\n const committed = rows[0]?.committed;\n return committed === true || committed === 't';\n}\n\n/**\n * Drain without ever failing the caller — the read-path and prune policy.\n *\n * Any sequences it allocates are recorded on the handle's uncommitted-drain\n * mark; see {@link uncommittedDrainMarks} for why a reader must not serve at\n * or above it while the caller's transaction is still open.\n */\nasync function drainChangeFeedBestEffort(db: DatabaseInterface): Promise<void> {\n try {\n await drainChangeFeedDetailed(db);\n } catch (error) {\n warnDrainFailureOnce(db, error);\n }\n}\n\n/**\n * Manual bump escape hatch for out-of-band writers.\n *\n * Framework mutation paths feed the log automatically, but raw SQL issued\n * outside `save()`/`delete()` is invisible to it (documented gap, shared\n * with the #1499 collection cache). Call this after such a write so feed\n * consumers observe the change. Omitting `rowId` records a table-level\n * change (`rowId: null`), which consumers should treat as \"anything in this\n * table may have changed\".\n *\n * @example\n * ```typescript\n * await db.query(`UPDATE products SET price = price * 1.1`);\n * await bumpChangeFeed(db, { table: 'products' });\n * ```\n */\nexport async function bumpChangeFeed(\n db: DatabaseInterface,\n input: AppendChangeInput,\n): Promise<void> {\n await ensureChangeFeedTable(db);\n await appendChange(db, input);\n}\n\n// ============================================================================\n// Read interface\n// ============================================================================\n\n/**\n * Read committed changes after a cursor.\n *\n * Returns every committed change with `since < seq <= cursor` that matches\n * the filters, ordered by ascending `seq`. The returned cursor is safe to\n * persist and poll with: committed sequences are contiguous (see module\n * docs), so nothing can commit at or below the observed horizon afterwards —\n * reads miss no committed change under concurrent writers and never return\n * the same change twice. When `since` is already at the horizon, returns an\n * empty page with `cursor: since`.\n *\n * ## Resync detection (pruned / foreign cursors)\n *\n * A cursor that cannot be served incrementally is flagged with\n * `resyncRequired: true` (empty `changes`, `cursor` echoed unchanged,\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 // Sequence anything staged by a caller transaction that has since committed\n // (#2649) so this read observes it. Best-effort: on a handle that cannot\n // write, staged entries simply stay invisible until some writer drains —\n // they are durable, never lost, and the horizon below is still a horizon.\n //\n // A drain writes on THIS handle, so if the caller wrapped this read in its\n // own transaction the drained rows are visible only here and vanish on a\n // rollback. Serving them would report changes that never committed and\n // advance the cursor past sequences a concurrent autocommit appender then\n // claims for its own entries. Anything this drain allocated is therefore\n // held back to the next poll.\n if (options.drain !== false) {\n await drainChangeFeedBestEffort(db);\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 // `in_transaction` decides whether the drain above is safe to serve. It is\n // read in a SEPARATE statement from the drain on purpose: under autocommit\n // the drain was its own transaction and has already committed, so no id is\n // assigned here and its rows are committed for everyone. Inside a caller\n // transaction the id the drain assigned is still live, and those same rows\n // are this transaction's uncommitted work.\n const boundsRows = getQueryRows(\n await db.query(\n getEngine(db) === 'postgres'\n ? `SELECT MIN(seq) AS floor, MAX(seq) AS horizon, pg_current_xact_id_if_assigned() IS NOT NULL AS in_transaction FROM ${CHANGE_FEED_TABLE}`\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 // Everything below the handle's uncommitted-drain mark was committed by\n // another transaction before this read's transaction began; at or above it\n // is this transaction's own uncommitted work. The mark spans the whole\n // transaction, so a second read on the same handle holds back what the\n // first read's drain wrote even though this read drained nothing.\n const inCallerTransaction =\n boundsRows[0]?.in_transaction === true ||\n boundsRows[0]?.in_transaction === 't';\n const handleKey = db as unknown as object;\n if (!inCallerTransaction) {\n // The drain committed as its own transaction; the mark has served out.\n uncommittedDrainMarks.delete(handleKey);\n }\n const drainMark = inCallerTransaction\n ? uncommittedDrainMarks.get(handleKey)\n : undefined;\n const servedHorizon =\n drainMark === undefined ? horizon : Math.min(horizon, drainMark - 1);\n\n if (servedHorizon === 0) {\n // Nothing committed that this read may serve. A zero cursor is simply \"no\n // changes ever\"; any other cursor came from a different database (or a\n // reset feed) and cannot be served incrementally. Decided on the SERVED\n // horizon, not the raw one: inside a caller transaction the raw horizon\n // includes this transaction's own uncommitted rows, and accepting a cursor\n // against those would wave through a foreign cursor that a rollback then\n // makes point at somebody else's entry.\n return since === 0\n ? { changes: [], cursor: 0 }\n : { changes: [], cursor: since, resyncRequired: true, resyncCursor: 0 };\n }\n\n if (since > servedHorizon) {\n // Foreign or reset cursor — ahead of anything this database allocated.\n return {\n changes: [],\n cursor: since,\n resyncRequired: true,\n resyncCursor: servedHorizon,\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: servedHorizon,\n };\n }\n\n if (servedHorizon <= 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(servedHorizon);\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 : servedHorizon;\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\n // Staged-but-unsequenced entries for this table (#2649) still have to move\n // the version, or a client holding the pre-write ETag would be answered 304\n // against data that has changed. Adding the table's own staged count is\n // monotonic: draining `n` staged rows for this table raises its MAX(seq) by\n // at least `n` (drained sequences start above the whole log's horizon) while\n // the count drops by exactly `n`, so the sum never goes backwards.\n //\n // The two terms MUST come from one statement, i.e. one snapshot. Read\n // separately, a drain landing between them is counted twice — the staged row\n // in the first read and its sequence in the second — so the version jumps,\n // then falls back on the next call, and a later write can re-mint the value\n // a client already cached. That is a false 304 against changed data.\n const versionRows = getQueryRows(\n await db.query(\n getEngine(db) === 'postgres'\n ? `SELECT\n (SELECT MAX(seq) FROM ${CHANGE_FEED_TABLE} WHERE table_name = ${p(1)}) AS version,\n (SELECT MAX(seq) FROM ${CHANGE_FEED_TABLE}) AS horizon,\n (SELECT COUNT(*) FROM ${POSTGRES_CHANGE_FEED_PENDING_TABLE} WHERE table_name = ${p(1)}) AS staged`\n : `SELECT\n (SELECT MAX(seq) FROM ${CHANGE_FEED_TABLE} WHERE table_name = ${p(1)}) AS version,\n (SELECT MAX(seq) FROM ${CHANGE_FEED_TABLE}) AS horizon,\n 0 AS staged`,\n name,\n ),\n );\n const row = versionRows[0];\n const staged = toSeqNumber(row?.staged);\n const tableVersion = row?.version;\n if (tableVersion != null) {\n return toSeqNumber(tableVersion) + staged;\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 return toSeqNumber(row?.horizon) + staged;\n}\n\nfunction toSeqNumber(value: unknown): number {\n // PostgreSQL surfaces BIGINT aggregates as strings and DuckDB can return\n // BigInt. Change-feed cursors must never advance on a rounded value.\n return toSafeInteger(value ?? 0, 'Change-feed sequence');\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 // Sequence staged entries first (#2649) so retention prunes a complete log\n // rather than leaving newer staged work outside the window it just sized.\n await drainChangeFeedBestEffort(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\n // Prune by age as a PREFIX, not as a predicate. Deleting every row with\n // `created_at < cutoff` assumes `created_at` and `seq` are co-monotonic;\n // they are not. `created_at` is stamped by the writer's clock (so two\n // processes can skew) and, since #2649, a staged entry carries its\n // stage-time stamp into the sequence the drain assigns later. Either way an\n // old timestamp can sit at a high sequence, and deleting it would punch a\n // hole in the MIDDLE of the retained run — which `getChangesSince` cannot\n // detect: its pruned-cursor proof (`since < floor - 1`) only sees the\n // floor move, so a reader below that hole is silently, permanently short\n // one committed change.\n //\n // Retaining the whole run from the oldest entry that is still inside the\n // window keeps deletion oldest-first and the retained sequences contiguous\n // — the invariant the resync signal rests on. The cost is that a stale\n // entry sequenced after a fresh one survives until the fresh one ages out.\n const retainRows = getQueryRows(\n await db.query(\n `SELECT MIN(seq) AS first_retained FROM ${CHANGE_FEED_TABLE} WHERE created_at >= ${p(1)}`,\n cutoff,\n ),\n );\n const firstRetained = toSeqNumber(retainRows[0]?.first_retained);\n // Nothing is inside the window → everything but the newest entry may go.\n const ageThrough = Math.min(\n firstRetained > 0 ? firstRetained - 1 : horizon - 1,\n horizon - 1,\n );\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 if (ageThrough > prunedThrough) {\n pruned += await deleteCounted(\n db,\n `seq <= ${p(1)} AND seq > ${p(2)}`,\n [ageThrough, prunedThrough],\n dryRun,\n );\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\nexport const CHANGE_FEED_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/** Databases we already warned about after a failed feed drain (#2649). */\nconst warnedDrainFailures = 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 [CHANGE_FEED_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 =\n context.metadata?.[CHANGE_FEED_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 // A staged append (#2649) has no sequence yet, so there is no cursor to\n // signal with. The signal is published by the drain that sequences it,\n // which is also the first moment it is honest — the pre-commit signal this\n // path used to publish for a transaction-wrapped write could describe work\n // a later rollback undid. Live subscribers see such writes one drain\n // later; cursor polling, the documented fallback, is unaffected.\n if (seq == null) return;\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\n/** Record a framework-owned mutation that intentionally bypasses save hooks. */\nexport async function recordInstanceChange(\n instance: SmrtObject,\n operation: ChangeOperation = 'update',\n): Promise<void> {\n await appendForInstance(instance, operation);\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 warnDrainFailureOnce(db: DatabaseInterface, error: unknown): void {\n try {\n const dbKey = resolveDbCacheKey(db);\n if (warnedDrainFailures.has(dbKey)) return;\n warnedDrainFailures.add(dbKey);\n logger.warn(\n 'Change feed: failed to sequence entries staged inside caller ' +\n 'transactions. Those entries are durable but stay invisible to cursor ' +\n 'readers until a drain succeeds (further failures for this database ' +\n 'are suppressed). A read-only handle cannot drain — schedule ' +\n 'drainChangeFeed() on a writable connection.',\n { error: error instanceof Error ? error.message : String(error) },\n );\n } catch {\n // Logging must never propagate into the read or 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 warnedDrainFailures.clear();\n // The append path's drain interval is process state too; a test that asserts\n // on drain-driven signals must not inherit a neighbour's timestamp.\n lastAppendDrain.clear();\n stagedSinceLastDrain.clear();\n deferredSignals.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 * ## Staged appends inside caller transactions (PostgreSQL, #2649)\n *\n * `MAX+1` allocation costs a *wait*: two appends that pick the same value\n * conflict on the primary key, and the loser waits for the winner's\n * transaction to end. Under autocommit that is one statement. Inside a\n * caller-managed transaction it is the whole transaction — and a long write\n * transaction that appends and then keeps taking row locks forms a genuine\n * lock cycle with any writer that took those row locks first and then\n * appended. PostgreSQL detects it (`40P01`) and aborts one side, so an\n * ordinary concurrent request could abort a legitimate long write.\n *\n * PostgreSQL appends issued inside a caller transaction are therefore\n * **staged**: `_smrt_append_change` inserts into `_smrt_changes_pending`,\n * whose identity key conflicts with nothing, so the append never waits on\n * another transaction and the cycle cannot form. The staged row is still\n * fate-shared with the caller (a rollback removes it). {@link drainChangeFeed}\n * — run by {@link getChangesSince} before it reads, and (throttled) by the\n * append path itself — moves *committed* staged rows into `_smrt_changes`\n * under a try-only advisory lock, numbering them `MAX(seq) + row_number()` in\n * staged order. Draining is driven from JavaScript rather than inside the\n * append helper so that every sequenced entry also gets its live `_events`\n * signal: an entry sequenced invisibly server-side would be skipped for good\n * by a subscriber whose `Last-Event-ID` came from a later, higher sequence.\n * Signals for drained entries are published only once the drain is proven\n * committed, so a rollback can never advertise a sequence another appender\n * then reuses.\n *\n * The cursor guarantee is unchanged, because sequences are still allocated by\n * exactly one `MAX+1` writer at a time and only ever for already-committed\n * work: committed sequences stay contiguous, and no entry can appear at or\n * below a horizon a reader already observed. What a staged append gives up is\n * *promptness*, not durability or order: its entry becomes visible one drain\n * after its transaction commits, and its position in the log is its drain\n * order rather than its statement order. An autocommit append drains before\n * allocating whenever its throttle allows, so staged work usually keeps its\n * place ahead of later writes.\n *\n * SQLite and DuckDB keep the direct `MAX+1` insert unchanged — SQLite\n * serializes writers outright, so the defect is unreachable there.\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 staged 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 { type ChangeSignal, 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_BATCH_FUNCTION_IDENTITY,\n POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_NAME,\n POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY,\n POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME,\n POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_IDENTITY,\n POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_NAME,\n POSTGRES_CHANGE_FEED_HELPER_MARKER,\n POSTGRES_CHANGE_FEED_PENDING_TABLE,\n REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION,\n RETIRED_SYSTEM_TABLES,\n} from './system/schema.js';\nimport { toSafeInteger } from './utils/safe-integer.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 // PostgreSQL-only staging table for deferred appends (#2649). It is created\n // by the change-feed ensure path rather than the portable system DDL, so it\n // is not covered by SYSTEM_TABLE_NAMES.\n POSTGRES_CHANGE_FEED_PENDING_TABLE,\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 /**\n * Whether to sequence staged entries before reading (default `true`). On\n * PostgreSQL that drain writes through `_smrt_drain_changes()`; a\n * diagnostics reader that must stay SELECT-only passes `false` and\n * accepts that entries still staged by an open caller transaction remain\n * invisible until a writer drains them.\n */\n drain?: boolean;\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/** A batch of mutations to append as one framework database statement. */\nexport type AppendChangeBatch = readonly AppendChangeInput[];\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\n/**\n * Maximum bounded drain batches one {@link drainChangeFeed} call sequences.\n * A cap rather than \"until empty\" so a pathological writer cannot make one\n * reader drain forever; the remainder is picked up by the next drain.\n */\nconst MAX_DRAIN_PASSES = 50;\n\n/** Cap on signals held for a handle that keeps draining without committing. */\nconst MAX_DEFERRED_SIGNALS = 5_000;\n\n/**\n * Per-handle low-water mark of sequences a drain allocated but has not proven\n * committed (#2649).\n *\n * The hold-back has to outlive the drain call that created it. A caller\n * transaction can drain in one statement and read in the next — the second\n * read's own drain allocates nothing (the server-side helper refuses once the\n * transaction has an id), yet the transaction still sees the first drain's\n * uncommitted rows and would serve them. Keyed on the handle object, which is\n * the transaction's identity for its whole life, so every later read on the\n * same transaction keeps holding back from the same mark.\n *\n * The mark is recorded unconditionally and *used* only while a transaction id\n * is still assigned at read time. Under autocommit the drain committed as its\n * own transaction, so the next read clears the mark and serves the rows.\n */\nconst uncommittedDrainMarks = new WeakMap<object, number>();\n\n/**\n * How often the append path drains on its own behalf, per database.\n *\n * The append helper deliberately does not drain server-side: sequences it\n * assigned there would never reach JavaScript, so no `_events` signal would be\n * published for them while this append's own signal carries a higher sequence\n * — and an EventSource resuming from that `Last-Event-ID` would skip them for\n * good. Draining from here instead keeps every sequenced entry signalled, in\n * ascending order, and keeps a deployment whose writers never read the feed\n * from accumulating staged entries indefinitely. Throttled because it is a\n * liveness convenience, not a correctness requirement: cursor readers drain\n * for themselves, and `getTableVersion()` already counts staged entries.\n */\nconst APPEND_DRAIN_INTERVAL_MS = 250;\n\n/** dbKey → last time the append path drained for it. */\nconst lastAppendDrain = new Map<string, number>();\n\n/** dbKeys that staged an append since this process last drained them. */\nconst stagedSinceLastDrain = new Set<string>();\n\nfunction noteStagedAppend(db: DatabaseInterface): void {\n try {\n stagedSinceLastDrain.add(resolveDbCacheKey(db));\n } catch {\n // Unkeyable handle — the interval below still drives the drain.\n }\n}\n\nasync function drainBeforeAppend(\n db: DatabaseInterface,\n): Promise<ChangeSignal[]> {\n if (getEngine(db) !== 'postgres') return [];\n let dbKey: string;\n try {\n dbKey = resolveDbCacheKey(db);\n } catch {\n return [];\n }\n const now = Date.now();\n const last = lastAppendDrain.get(dbKey);\n // A handle that staged an append since its last drain skips the interval:\n // that entry is known to be waiting for a sequence and a signal.\n if (\n !stagedSinceLastDrain.has(dbKey) &&\n last !== undefined &&\n now - last < APPEND_DRAIN_INTERVAL_MS\n ) {\n return [];\n }\n stagedSinceLastDrain.delete(dbKey);\n lastAppendDrain.set(dbKey, now);\n // Inside a caller transaction the drain helper refuses server-side, so this\n // costs one cheap statement and never sequences anything there. `settle:\n // false, maxPasses: 1` keep it to that ONE statement: the caller may own the\n // surrounding transaction, and any further statement issued here could fail\n // and abort it behind the feed's own error-swallowing (#2026). The drain\n // statement itself cannot — the helper catches everything, preflight\n // included, and returns failures as data — but a write path is no place to\n // loop over a backlog either; the rest drains on the next append or read.\n // The signals come back undecided; appendChange settles them without\n // spending a statement.\n try {\n return (await drainChangeFeedDetailed(db, { settle: false, maxPasses: 1 }))\n .unsettledSignals;\n } catch (error) {\n warnDrainFailureOnce(db, error);\n return [];\n }\n}\n\nfunction recordUncommittedDrainMark(\n db: DatabaseInterface,\n firstAllocatedSeq: number | null,\n): void {\n if (firstAllocatedSeq === null) return;\n const key = db as unknown as object;\n const existing = uncommittedDrainMarks.get(key);\n if (existing === undefined || firstAllocatedSeq < existing) {\n uncommittedDrainMarks.set(key, firstAllocatedSeq);\n }\n}\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 exportTable?: unknown;\n client?: { constructor?: { name?: string }; connection?: unknown };\n};\n\nfunction getEngine(\n db: DatabaseInterface,\n typeHint?: string,\n): ReturnType<typeof detectEngine> {\n const withConfig = db as DatabaseWithConfig;\n const engine = detectEngine(\n db.url || withConfig.config?.url || '',\n typeHint || withConfig.type || withConfig.config?.type,\n );\n // DuckDB can use ':memory:' like SQLite, so URL detection alone resolves it\n // to SQLite. The public connection identity survives transaction wrappers,\n // unlike root-only adapter methods; both native and JSON-on-DuckDB need\n // DuckDB's JSON string extraction.\n const clientName = withConfig.client?.constructor?.name?.toLowerCase() ?? '';\n const isDuckDbConnection =\n clientName.includes('duckdb') ||\n (withConfig.client !== undefined && 'connection' in withConfig.client);\n if (engine === 'sqlite' && isDuckDbConnection) {\n return 'duckdb';\n }\n return engine;\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\n/**\n * SQL fragment that reports a helper as present ONLY when its body is current.\n *\n * `to_regprocedure()` alone answers \"a function of this name and signature\n * exists\", which is not the same question: an install can hold a helper whose\n * body predates a fix, and an existence probe would call it current and never\n * replace it. Matching {@link POSTGRES_CHANGE_FEED_HELPER_MARKER} against\n * `pg_proc.prosrc` makes the probe version-aware, so bumping the marker is all\n * a future helper change needs to reach existing databases.\n */\nfunction currentHelperProbe(identity: string): string {\n return `(\n SELECT p.oid\n FROM pg_proc AS p\n WHERE p.oid = to_regprocedure('${identity}')\n AND p.prosrc LIKE '%${POSTGRES_CHANGE_FEED_HELPER_MARKER}%'\n )`;\n}\n\nasync function postgresChangeFeedHelpersCurrent(\n db: DatabaseInterface,\n): Promise<boolean> {\n const rows = getQueryRows(\n await db.query(\n `SELECT\n ${currentHelperProbe(POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY)} AS function_name,\n ${currentHelperProbe(POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_IDENTITY)} AS batch_function_name,\n ${currentHelperProbe(POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_IDENTITY)} AS drain_function_name,\n to_regclass('${POSTGRES_CHANGE_FEED_PENDING_TABLE}') AS pending_table_name`,\n ),\n );\n return Boolean(\n rows[0]?.function_name &&\n rows[0]?.batch_function_name &&\n rows[0]?.drain_function_name &&\n rows[0]?.pending_table_name,\n );\n}\n\nasync function getPostgresChangeFeedSchemaState(\n db: DatabaseInterface,\n): Promise<{\n tableExists: boolean;\n functionExists: boolean;\n batchFunctionExists: boolean;\n pendingTableExists: boolean;\n drainFunctionExists: 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 ${currentHelperProbe(POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY)} AS function_name,\n ${currentHelperProbe(POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_IDENTITY)} AS batch_function_name,\n to_regclass('${POSTGRES_CHANGE_FEED_PENDING_TABLE}') AS pending_table_name,\n ${currentHelperProbe(POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_IDENTITY)} AS drain_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 batchFunctionExists: Boolean(rows[0]?.batch_function_name),\n pendingTableExists: Boolean(rows[0]?.pending_table_name),\n drainFunctionExists: Boolean(rows[0]?.drain_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 postgresChangeFeedHelpersCurrent(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\n/**\n * Refresh the PostgreSQL change-feed helpers on an already-initialized\n * database (issue #2649).\n *\n * `bootstrapSystemTables()` installs the helpers only while applying a system\n * schema version, and #2649 changes the helper body without changing the\n * portable system DDL — so a database already stamped with the current version\n * would keep the pre-#2649 append function, and its `40P01` deadlock, until\n * some feed route happened to call {@link ensureChangeFeedTable}. Ordinary\n * model writes never do. Bootstrap therefore calls this before its\n * version fast-return; it is one catalog probe when the helpers are current.\n *\n * Databases whose `_smrt_changes.created_at` is still the legacy\n * timezone-naive column are left alone: they need their audited\n * `migratePostgresSystemTimestamps()` pass first, and failing bootstrap on\n * them here would be a new, unrelated break.\n *\n * @internal\n */\nexport async function ensurePostgresChangeFeedHelpers(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n if (getEngine(db, typeHint) !== 'postgres') return;\n const state = await getPostgresChangeFeedSchemaState(db);\n if (!state.tableExists) return;\n if (state.createdAtType !== 'timestamp with time zone') return;\n if (\n state.functionExists &&\n state.batchFunctionExists &&\n state.pendingTableExists &&\n state.drainFunctionExists\n ) {\n return;\n }\n await db.query(ENSURE_POSTGRES_CHANGE_FEED_SCHEMA);\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.batchFunctionExists &&\n state.pendingTableExists &&\n state.drainFunctionExists &&\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 * **Returns `null` for a staged append** (PostgreSQL, #2649): an append issued\n * inside a caller-managed transaction is written to `_smrt_changes_pending`\n * and receives its sequence from the next {@link drainChangeFeed} after that\n * transaction commits, so no sequence exists to return yet. The entry is\n * durable and ordered — it simply is not numbered at this instant. Callers\n * that need the sequence (an SSE event id, say) must treat `null` as \"not\n * available yet\", never as a failure: a failure still throws.\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 | null> {\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 // Sequence anything staged by a committed transaction before taking a\n // sequence of our own, so signals stay in ascending order and no staged\n // entry is left unsignalled beneath this one (see drainBeforeAppend). Their\n // signals are settled below, once this append tells us whether that drain\n // committed.\n const drainedSignals = await drainBeforeAppend(db);\n\n /**\n * Settle the pre-append drain's signals from what this append revealed.\n *\n * A drain that allocated anything assigned a transaction id, so if this\n * append then took the DIRECT path — `pg_current_xact_id_if_assigned()` was\n * still null at its entry — that id is already gone, which can only mean the\n * drain committed as its own autocommit transaction. Publishing here is\n * therefore proven safe AND correctly ordered: these sequences are below the\n * one this append is about to take, and the interceptor publishes that one\n * only after this call returns. A deferred append proves nothing, so its\n * signals go to the queue.\n */\n const settleDrainedSignals = (appended: number | null): void => {\n if (drainedSignals.length === 0) return;\n if (appended === null) {\n queueDeferredSignals(db, drainedSignals);\n return;\n }\n publishSignals(db, drainedSignals);\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 if (engine === 'postgres' && row.allocated_seq == null) {\n noteStagedAppend(db);\n settleDrainedSignals(null);\n // Staged, not sequenced (#2649): the append ran inside a\n // caller-managed transaction, so it went to `_smrt_changes_pending`\n // and receives its sequence from the next drain after that\n // transaction commits. Durable and ordered, just not numbered yet.\n return null;\n }\n const seq = toSeqNumber(\n engine === 'postgres' ? row.allocated_seq : row.seq,\n );\n settleDrainedSignals(seq);\n if (engine === 'postgres') {\n // A direct allocation made as a caller transaction's FIRST statement\n // is this transaction's uncommitted work, exactly like a drain's, and\n // a read on the same handle must not serve it or hand back a cursor\n // above it — a rollback frees that sequence for somebody else. Record\n // it unconditionally: an autocommit read clears the mark before using\n // it, because no transaction id remains by then.\n recordUncommittedDrainMark(db, seq);\n }\n return seq;\n } catch (error) {\n if (!isUniqueViolation(error) || attempt === MAX_APPEND_ATTEMPTS) {\n // Nothing was proven about the drain; hold its signals for a settling\n // drain rather than dropping or publishing them.\n queueDeferredSignals(db, drainedSignals);\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 | null>` contract structurally.\n throw new Error('appendChange exhausted retries without allocating a seq');\n}\n\n/**\n * Append a mutation batch with one feed row per input entry.\n *\n * The batch is deliberately one statement per supported dialect. In\n * particular, PostgreSQL enters the existing append helper from one\n * framework-owned function call, so a caller transaction still stages every\n * entry, preserves rollback/failure isolation, and never waits on the feed\n * head. Direct batches allocate contiguous sequences in input order.\n */\nexport async function appendChanges(\n db: DatabaseInterface,\n inputs: AppendChangeBatch,\n): Promise<Array<number | null>> {\n if (inputs.length === 0) return [];\n const entries = inputs.map((input) => {\n const table = input.table?.trim();\n if (!table)\n throw new Error('appendChanges requires a non-empty table name');\n const operation = input.operation ?? 'update';\n if (!VALID_OPERATIONS.has(operation)) {\n throw new Error(\n `appendChanges operation must be one of create/update/delete, got '${String(input.operation)}'`,\n );\n }\n return {\n table,\n rowId: input.rowId ?? null,\n operation,\n tenantId: input.tenantId ?? null,\n timestamp: new Date().toISOString(),\n };\n });\n const engine = getEngine(db);\n const p = placeholders(db);\n const payload = JSON.stringify(entries);\n const sql =\n engine === 'postgres'\n ? `SELECT entry_index, allocated_seq, error_code, error_message FROM ${POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_NAME}(${p(1)}::jsonb)`\n : engine === 'sqlite'\n ? `WITH input AS (SELECT CAST(key AS INTEGER) AS entry_index, json_extract(value, '$.table') AS table_name, json_extract(value, '$.rowId') AS row_id, json_extract(value, '$.operation') AS operation, json_extract(value, '$.tenantId') AS tenant_id, json_extract(value, '$.timestamp') AS created_at FROM json_each(${p(1)})), numbered AS (SELECT *, COALESCE((SELECT MAX(seq) FROM ${CHANGE_FEED_TABLE}), 0) + row_number() OVER (ORDER BY entry_index) AS seq FROM input) INSERT INTO ${CHANGE_FEED_TABLE} (seq, table_name, row_id, operation, tenant_id, created_at) SELECT seq, table_name, row_id, operation, tenant_id, created_at FROM numbered RETURNING seq`\n : `WITH input AS (SELECT CAST(key AS INTEGER) AS entry_index, json_extract_string(value, '$.table') AS table_name, json_extract_string(value, '$.rowId') AS row_id, json_extract_string(value, '$.operation') AS operation, json_extract_string(value, '$.tenantId') AS tenant_id, CAST(json_extract_string(value, '$.timestamp') AS TIMESTAMP) AS created_at FROM json_each(${p(1)})), numbered AS (SELECT *, COALESCE((SELECT MAX(seq) FROM ${CHANGE_FEED_TABLE}), 0) + row_number() OVER (ORDER BY entry_index) AS seq FROM input) INSERT INTO ${CHANGE_FEED_TABLE} (seq, table_name, row_id, operation, tenant_id, created_at) SELECT seq, table_name, row_id, operation, tenant_id, created_at FROM numbered RETURNING seq`;\n const drainedSignals = await drainBeforeAppend(db);\n const settleDrainedSignals = (sequenced: boolean): void => {\n if (drainedSignals.length === 0) return;\n if (sequenced) publishSignals(db, drainedSignals);\n else queueDeferredSignals(db, drainedSignals);\n };\n for (let attempt = 1; attempt <= MAX_APPEND_ATTEMPTS; attempt++) {\n try {\n const rows = getQueryRows(\n await withEmbeddedWriteQueue(db, isEmbeddedDatabase(db), () =>\n db.query(sql, payload),\n ),\n );\n if (engine === 'postgres') {\n const failure = rows.find((row) => row.error_code != null);\n if (failure) {\n const error = new Error(\n String(\n failure.error_message ||\n 'PostgreSQL change-feed batch append failed',\n ),\n ) as Error & { code: string };\n error.code = String(failure.error_code);\n throw error;\n }\n if (rows.length !== entries.length) {\n throw new Error(\n 'PostgreSQL change-feed batch append returned an incomplete result',\n );\n }\n const result = Array<number | null>(entries.length).fill(null);\n const seenIndexes = new Set<number>();\n for (const row of rows) {\n const index = Number(row.entry_index);\n if (\n !Number.isInteger(index) ||\n index < 0 ||\n index >= result.length ||\n seenIndexes.has(index)\n ) {\n throw new Error(\n 'PostgreSQL change-feed batch append returned invalid indexes',\n );\n }\n seenIndexes.add(index);\n result[index] =\n row.allocated_seq == null ? null : toSeqNumber(row.allocated_seq);\n }\n if (result.some((seq) => seq === null)) noteStagedAppend(db);\n settleDrainedSignals(!result.some((seq) => seq === null));\n for (const seq of result)\n if (seq != null) recordUncommittedDrainMark(db, seq);\n return result;\n }\n if (rows.length !== entries.length)\n throw new Error(\n 'Change feed batch append returned an incomplete result',\n );\n const result = rows\n .map((row) => toSeqNumber(row.seq))\n .sort((a, b) => a - b);\n settleDrainedSignals(true);\n return result;\n } catch (error) {\n if (!isUniqueViolation(error) || attempt === MAX_APPEND_ATTEMPTS) {\n queueDeferredSignals(db, drainedSignals);\n throw error;\n }\n }\n }\n throw new Error(\n 'appendChanges exhausted retries without allocating a sequence',\n );\n}\n\n/**\n * Sequence change-feed entries staged inside caller-managed transactions\n * (issue #2649). PostgreSQL only; a no-op on every other engine.\n *\n * A `save()`/`delete()`/{@link appendChange} issued inside a caller\n * transaction cannot allocate `MAX(seq) + 1` there: the allocation waits for\n * any competing appender's transaction to end, and a long write transaction\n * that goes on to take row locks the waiter holds closes a real lock cycle\n * (`40P01`). Such appends are staged instead, and this call moves every\n * **committed** staged entry into `_smrt_changes` with contiguous sequences,\n * in staged order, under a try-only advisory lock.\n *\n * It runs by itself on the paths that matter — every autocommit append drains\n * server-side before allocating its own sequence, and {@link getChangesSince}\n * drains before it reads — so applications do not normally need to call it. A\n * drain issued from inside a transaction that has already written is skipped\n * server-side: allocating there would hold sequences uncommitted for the rest\n * of that transaction, which is the wait this fix removes.\n * Call it explicitly from a scheduled job when a deployment writes *only*\n * through transactions and reads the feed from a connection that cannot write\n * (a read replica or a read-only role), because neither self-draining path is\n * then available.\n *\n * Best-effort by the feed's failure policy: a drain that cannot run leaves the\n * staged entries in place for the next attempt and never throws into the\n * caller's write path. Nothing is lost — staged entries are durable — they are\n * simply not yet visible to cursor readers.\n *\n * @returns How many staged entries were sequenced.\n */\nexport async function drainChangeFeed(db: DatabaseInterface): Promise<number> {\n return (await drainChangeFeedDetailed(db)).drained;\n}\n\n/**\n * {@link drainChangeFeed} plus the lowest sequence this call allocated.\n *\n * A drain runs on the caller's handle, so when that handle is inside a\n * caller-managed transaction the rows it just wrote are visible **to this\n * transaction only** and disappear if it rolls back. A reader that served them\n * would report entries that never committed and advance its cursor past\n * sequences a concurrent autocommit appender goes on to claim — a permanently\n * skipped change. {@link getChangesSince} therefore refuses to serve at or\n * above `firstAllocatedSeq`: everything below it was committed by somebody\n * else before this drain started, and the drain's own rows are served by the\n * next poll, once they are committed for everyone.\n *\n * @internal\n */\nasync function drainChangeFeedDetailed(\n db: DatabaseInterface,\n options: { settle?: boolean; maxPasses?: number } = {},\n): Promise<{\n drained: number;\n firstAllocatedSeq: number | null;\n unsettledSignals: ChangeSignal[];\n}> {\n if (getEngine(db) !== 'postgres')\n return { drained: 0, firstAllocatedSeq: null, unsettledSignals: [] };\n\n // `settle: false` is the write path (see drainBeforeAppend): it issues the\n // drain statement and NOTHING else, because every additional statement run\n // on the caller's handle could fail and abort a caller-owned transaction,\n // which the feed's failure policy would then swallow — leaving the caller in\n // PostgreSQL's aborted state (`25P02`), the exact #2026 hazard. The drain\n // statement itself is safe: the helper catches everything, preflight\n // included, and returns failures as data. Its signals are queued and settled\n // later, from a read path that is free to issue extra statements.\n const settle = options.settle !== false;\n\n // Anything a previous, unproven drain queued is settled first: its\n // transaction has ended by now, and each queued entry is published only if\n // its sequence still holds the row it was drained for.\n if (settle) await flushDeferredSignals(db);\n\n let firstAllocatedSeq: number | null = null;\n let drained = 0;\n const unsettledSignals: ChangeSignal[] = [];\n // Each call sequences one bounded batch; loop so a large backlog is not left\n // behind, with a hard iteration cap so a persistent failure cannot spin.\n const maxPasses = Math.max(1, options.maxPasses ?? MAX_DRAIN_PASSES);\n for (let pass = 0; pass < maxPasses; pass++) {\n const rows = getQueryRows(\n await db.query(\n `SELECT * FROM ${POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_NAME}()`,\n ),\n );\n const failure = rows.find((row) => row.error_code != null);\n if (failure) {\n const error = new Error(\n String(failure.error_message || 'PostgreSQL change-feed drain failed'),\n ) as Error & { code: string };\n error.code = String(failure.error_code);\n // A concurrent autocommit append can win the head between this drain's\n // MAX(seq) read and its insert. The whole batch rolled back, so the\n // staged rows are still there — recompute and try again, exactly like\n // the appender's own conflict retry.\n if (isUniqueViolation(error)) continue;\n throw error;\n }\n const sequenced = rows.filter((row) => row.drained_seq != null);\n drained += sequenced.length;\n for (const row of sequenced) {\n const seq = toSeqNumber(row.drained_seq);\n if (firstAllocatedSeq === null || seq < firstAllocatedSeq) {\n firstAllocatedSeq = seq;\n }\n }\n\n // A drained entry is cursor-visible once its sequence is committed, which\n // is the first honest moment to signal it. Publish ONLY after proving the\n // drain committed: a drain that ran as the first statement of a caller\n // transaction is still uncommitted, and a rollback would release the\n // sequences it just advertised for a concurrent appender to reuse — a\n // subscriber would then hold a `Last-Event-ID` naming somebody else's\n // entry and skip the real one.\n //\n // An unproven drain's entries are QUEUED, never dropped. Once its\n // transaction commits they are no longer staged, so no later drain would\n // ever return them again — and an entry that is never signalled while a\n // later one is is exactly the skip this is guarding against.\n const signals = sequenced.map((row) => ({\n table: String(row.drained_table ?? ''),\n operation: String(row.drained_operation ?? 'update') as ChangeOperation,\n rowId: row.drained_row_id == null ? null : String(row.drained_row_id),\n tenantId:\n row.drained_tenant_id == null ? null : String(row.drained_tenant_id),\n seq: toSeqNumber(row.drained_seq),\n }));\n if (signals.length > 0) {\n if (!settle) {\n // The write path decides: see appendChange, which can prove these\n // committed without spending a statement on it.\n unsettledSignals.push(...signals);\n } else if (await drainCommitted(db)) {\n publishSignals(db, signals);\n } else {\n queueDeferredSignals(db, signals);\n }\n }\n\n if (sequenced.length === 0) break;\n }\n recordUncommittedDrainMark(db, firstAllocatedSeq);\n return { drained, firstAllocatedSeq, unsettledSignals };\n}\n\n/** Signals a drain produced but could not yet prove committed. */\nconst deferredSignals = new Map<string, ChangeSignal[]>();\n\nfunction publishSignals(db: DatabaseInterface, signals: ChangeSignal[]): void {\n for (const signal of signals) {\n try {\n publishChangeSignal(db, signal);\n } catch (error) {\n warnSignalPublishFailureOnce(db, signal.table, error);\n }\n }\n}\n\nfunction queueDeferredSignals(\n db: DatabaseInterface,\n signals: ChangeSignal[],\n): void {\n try {\n const dbKey = resolveDbCacheKey(db);\n const queued = deferredSignals.get(dbKey);\n if (queued) {\n queued.push(...signals);\n // Bound the queue: a handle that only ever drains inside uncommitted\n // transactions must not grow it without limit. Dropped entries cost a\n // live signal, never a feed row — cursor readers still see them.\n if (queued.length > MAX_DEFERRED_SIGNALS) {\n queued.splice(0, queued.length - MAX_DEFERRED_SIGNALS);\n }\n } else {\n deferredSignals.set(dbKey, [...signals]);\n }\n } catch {\n // Unkeyable handle — the signal is best-effort, the feed row is not.\n }\n}\n\n/**\n * Publish queued signals whose sequences are now committed.\n *\n * A queued entry's transaction may have rolled back, which returns its row to\n * the staging table and frees its sequence for somebody else. Each queued\n * signal is therefore verified against the committed log before it is\n * published: the sequence must still carry the same table and row.\n *\n * An entry that does not verify is **kept, not dropped**. The queue is keyed\n * per database, and every connection to that database shares it — so a second\n * connection can reach this while the transaction that queued the entry is\n * still open. Its own commit probe says \"not in a transaction\", but it cannot\n * see the other connection's uncommitted rows, and discarding on that basis\n * would strand a signal nobody can ever republish once that transaction\n * commits. Unverified entries therefore wait for a later flush, bounded only\n * by {@link MAX_DEFERRED_SIGNALS}, which evicts oldest-first — an entry whose\n * transaction really did roll back is re-sequenced and re-signalled by a later\n * drain regardless.\n */\nasync function flushDeferredSignals(db: DatabaseInterface): Promise<void> {\n let dbKey: string;\n try {\n dbKey = resolveDbCacheKey(db);\n } catch {\n return;\n }\n const queued = deferredSignals.get(dbKey);\n if (!queued || queued.length === 0) return;\n\n // Claim the queue synchronously, before the first await. Two drains on the\n // same database run concurrently, and a claim taken after an await lets both\n // verify and publish the same entries — duplicate SSE event ids. Whatever\n // this flush does not publish is put back below.\n deferredSignals.delete(dbKey);\n const candidates = queued;\n let unpublished = candidates;\n try {\n if (!(await drainCommitted(db))) return;\n unpublished = await publishVerifiedSignals(db, candidates);\n } finally {\n requeueDeferredSignals(dbKey, unpublished);\n }\n}\n\n/**\n * Publish the queued signals whose sequences still carry the row they were\n * drained for, and return the ones that could not be verified.\n */\nasync function publishVerifiedSignals(\n db: DatabaseInterface,\n candidates: ChangeSignal[],\n): Promise<ChangeSignal[]> {\n const p = placeholders(db);\n const rows = getQueryRows(\n await db.query(\n `SELECT seq, table_name, row_id FROM ${CHANGE_FEED_TABLE} WHERE seq IN (${candidates\n .map((_, index) => p(index + 1))\n .join(', ')})`,\n ...candidates.map((signal) => signal.seq),\n ),\n );\n const identity = (table: string, rowId: string | null) =>\n `${table}\\u0000${rowId ?? ''}`;\n const committed = new Map(\n rows.map((row) => [\n toSeqNumber(row.seq),\n identity(\n String(row.table_name ?? ''),\n row.row_id == null ? null : String(row.row_id),\n ),\n ]),\n );\n\n const verified = candidates.filter(\n (signal) =>\n committed.get(signal.seq) === identity(signal.table, signal.rowId),\n );\n if (verified.length === 0) return candidates;\n\n const publishedSeqs = new Set(verified.map((signal) => signal.seq));\n publishSignals(db, verified);\n return candidates.filter((signal) => !publishedSeqs.has(signal.seq));\n}\n\n/** Put unpublished signals back, ahead of anything queued meanwhile. */\nfunction requeueDeferredSignals(dbKey: string, signals: ChangeSignal[]): void {\n if (signals.length === 0) return;\n const queuedSince = deferredSignals.get(dbKey) ?? [];\n const merged = [...signals, ...queuedSince];\n deferredSignals.set(\n dbKey,\n merged.length > MAX_DEFERRED_SIGNALS\n ? merged.slice(merged.length - MAX_DEFERRED_SIGNALS)\n : merged,\n );\n}\n\n/**\n * Whether the drain that just ran has committed.\n *\n * Under autocommit the drain was its own transaction and ended with its\n * statement, so no transaction id is assigned by the time this separate\n * statement runs. Inside a caller transaction the id the drain assigned is\n * still live and its rows are still uncommitted.\n */\nasync function drainCommitted(db: DatabaseInterface): Promise<boolean> {\n const rows = getQueryRows(\n await db.query(\n 'SELECT pg_current_xact_id_if_assigned() IS NULL AS committed',\n ),\n );\n const committed = rows[0]?.committed;\n return committed === true || committed === 't';\n}\n\n/**\n * Drain without ever failing the caller — the read-path and prune policy.\n *\n * Any sequences it allocates are recorded on the handle's uncommitted-drain\n * mark; see {@link uncommittedDrainMarks} for why a reader must not serve at\n * or above it while the caller's transaction is still open.\n */\nasync function drainChangeFeedBestEffort(db: DatabaseInterface): Promise<void> {\n try {\n await drainChangeFeedDetailed(db);\n } catch (error) {\n warnDrainFailureOnce(db, error);\n }\n}\n\n/**\n * Manual bump escape hatch for out-of-band writers.\n *\n * Framework mutation paths feed the log automatically, but raw SQL issued\n * outside `save()`/`delete()` is invisible to it (documented gap, shared\n * with the #1499 collection cache). Call this after such a write so feed\n * consumers observe the change. Omitting `rowId` records a table-level\n * change (`rowId: null`), which consumers should treat as \"anything in this\n * table may have changed\".\n *\n * @example\n * ```typescript\n * await db.query(`UPDATE products SET price = price * 1.1`);\n * await bumpChangeFeed(db, { table: 'products' });\n * ```\n */\nexport async function bumpChangeFeed(\n db: DatabaseInterface,\n input: AppendChangeInput,\n): Promise<void> {\n await ensureChangeFeedTable(db);\n await appendChange(db, input);\n}\n\n// ============================================================================\n// Read interface\n// ============================================================================\n\n/**\n * Read committed changes after a cursor.\n *\n * Returns every committed change with `since < seq <= cursor` that matches\n * the filters, ordered by ascending `seq`. The returned cursor is safe to\n * persist and poll with: committed sequences are contiguous (see module\n * docs), so nothing can commit at or below the observed horizon afterwards —\n * reads miss no committed change under concurrent writers and never return\n * the same change twice. When `since` is already at the horizon, returns an\n * empty page with `cursor: since`.\n *\n * ## Resync detection (pruned / foreign cursors)\n *\n * A cursor that cannot be served incrementally is flagged with\n * `resyncRequired: true` (empty `changes`, `cursor` echoed unchanged,\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 // Sequence anything staged by a caller transaction that has since committed\n // (#2649) so this read observes it. Best-effort: on a handle that cannot\n // write, staged entries simply stay invisible until some writer drains —\n // they are durable, never lost, and the horizon below is still a horizon.\n //\n // A drain writes on THIS handle, so if the caller wrapped this read in its\n // own transaction the drained rows are visible only here and vanish on a\n // rollback. Serving them would report changes that never committed and\n // advance the cursor past sequences a concurrent autocommit appender then\n // claims for its own entries. Anything this drain allocated is therefore\n // held back to the next poll.\n if (options.drain !== false) {\n await drainChangeFeedBestEffort(db);\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 // `in_transaction` decides whether the drain above is safe to serve. It is\n // read in a SEPARATE statement from the drain on purpose: under autocommit\n // the drain was its own transaction and has already committed, so no id is\n // assigned here and its rows are committed for everyone. Inside a caller\n // transaction the id the drain assigned is still live, and those same rows\n // are this transaction's uncommitted work.\n const boundsRows = getQueryRows(\n await db.query(\n getEngine(db) === 'postgres'\n ? `SELECT MIN(seq) AS floor, MAX(seq) AS horizon, pg_current_xact_id_if_assigned() IS NOT NULL AS in_transaction FROM ${CHANGE_FEED_TABLE}`\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 // Everything below the handle's uncommitted-drain mark was committed by\n // another transaction before this read's transaction began; at or above it\n // is this transaction's own uncommitted work. The mark spans the whole\n // transaction, so a second read on the same handle holds back what the\n // first read's drain wrote even though this read drained nothing.\n const inCallerTransaction =\n boundsRows[0]?.in_transaction === true ||\n boundsRows[0]?.in_transaction === 't';\n const handleKey = db as unknown as object;\n if (!inCallerTransaction) {\n // The drain committed as its own transaction; the mark has served out.\n uncommittedDrainMarks.delete(handleKey);\n }\n const drainMark = inCallerTransaction\n ? uncommittedDrainMarks.get(handleKey)\n : undefined;\n const servedHorizon =\n drainMark === undefined ? horizon : Math.min(horizon, drainMark - 1);\n\n if (servedHorizon === 0) {\n // Nothing committed that this read may serve. A zero cursor is simply \"no\n // changes ever\"; any other cursor came from a different database (or a\n // reset feed) and cannot be served incrementally. Decided on the SERVED\n // horizon, not the raw one: inside a caller transaction the raw horizon\n // includes this transaction's own uncommitted rows, and accepting a cursor\n // against those would wave through a foreign cursor that a rollback then\n // makes point at somebody else's entry.\n return since === 0\n ? { changes: [], cursor: 0 }\n : { changes: [], cursor: since, resyncRequired: true, resyncCursor: 0 };\n }\n\n if (since > servedHorizon) {\n // Foreign or reset cursor — ahead of anything this database allocated.\n return {\n changes: [],\n cursor: since,\n resyncRequired: true,\n resyncCursor: servedHorizon,\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: servedHorizon,\n };\n }\n\n if (servedHorizon <= 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(servedHorizon);\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 : servedHorizon;\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\n // Staged-but-unsequenced entries for this table (#2649) still have to move\n // the version, or a client holding the pre-write ETag would be answered 304\n // against data that has changed. Adding the table's own staged count is\n // monotonic: draining `n` staged rows for this table raises its MAX(seq) by\n // at least `n` (drained sequences start above the whole log's horizon) while\n // the count drops by exactly `n`, so the sum never goes backwards.\n //\n // The two terms MUST come from one statement, i.e. one snapshot. Read\n // separately, a drain landing between them is counted twice — the staged row\n // in the first read and its sequence in the second — so the version jumps,\n // then falls back on the next call, and a later write can re-mint the value\n // a client already cached. That is a false 304 against changed data.\n const versionRows = getQueryRows(\n await db.query(\n getEngine(db) === 'postgres'\n ? `SELECT\n (SELECT MAX(seq) FROM ${CHANGE_FEED_TABLE} WHERE table_name = ${p(1)}) AS version,\n (SELECT MAX(seq) FROM ${CHANGE_FEED_TABLE}) AS horizon,\n (SELECT COUNT(*) FROM ${POSTGRES_CHANGE_FEED_PENDING_TABLE} WHERE table_name = ${p(1)}) AS staged`\n : `SELECT\n (SELECT MAX(seq) FROM ${CHANGE_FEED_TABLE} WHERE table_name = ${p(1)}) AS version,\n (SELECT MAX(seq) FROM ${CHANGE_FEED_TABLE}) AS horizon,\n 0 AS staged`,\n name,\n ),\n );\n const row = versionRows[0];\n const staged = toSeqNumber(row?.staged);\n const tableVersion = row?.version;\n if (tableVersion != null) {\n return toSeqNumber(tableVersion) + staged;\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 return toSeqNumber(row?.horizon) + staged;\n}\n\nfunction toSeqNumber(value: unknown): number {\n // PostgreSQL surfaces BIGINT aggregates as strings and DuckDB can return\n // BigInt. Change-feed cursors must never advance on a rounded value.\n return toSafeInteger(value ?? 0, 'Change-feed sequence');\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 // Sequence staged entries first (#2649) so retention prunes a complete log\n // rather than leaving newer staged work outside the window it just sized.\n await drainChangeFeedBestEffort(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\n // Prune by age as a PREFIX, not as a predicate. Deleting every row with\n // `created_at < cutoff` assumes `created_at` and `seq` are co-monotonic;\n // they are not. `created_at` is stamped by the writer's clock (so two\n // processes can skew) and, since #2649, a staged entry carries its\n // stage-time stamp into the sequence the drain assigns later. Either way an\n // old timestamp can sit at a high sequence, and deleting it would punch a\n // hole in the MIDDLE of the retained run — which `getChangesSince` cannot\n // detect: its pruned-cursor proof (`since < floor - 1`) only sees the\n // floor move, so a reader below that hole is silently, permanently short\n // one committed change.\n //\n // Retaining the whole run from the oldest entry that is still inside the\n // window keeps deletion oldest-first and the retained sequences contiguous\n // — the invariant the resync signal rests on. The cost is that a stale\n // entry sequenced after a fresh one survives until the fresh one ages out.\n const retainRows = getQueryRows(\n await db.query(\n `SELECT MIN(seq) AS first_retained FROM ${CHANGE_FEED_TABLE} WHERE created_at >= ${p(1)}`,\n cutoff,\n ),\n );\n const firstRetained = toSeqNumber(retainRows[0]?.first_retained);\n // Nothing is inside the window → everything but the newest entry may go.\n const ageThrough = Math.min(\n firstRetained > 0 ? firstRetained - 1 : horizon - 1,\n horizon - 1,\n );\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 if (ageThrough > prunedThrough) {\n pruned += await deleteCounted(\n db,\n `seq <= ${p(1)} AND seq > ${p(2)}`,\n [ageThrough, prunedThrough],\n dryRun,\n );\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\nexport const CHANGE_FEED_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/** Databases we already warned about after a failed feed drain (#2649). */\nconst warnedDrainFailures = 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 [CHANGE_FEED_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 =\n context.metadata?.[CHANGE_FEED_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 bulkMutation: {\n compatible: () => true,\n async afterSave(entries): Promise<void> {\n await recordInstanceChanges(\n entries.map(({ instance, context }) => ({\n instance,\n operation:\n context.metadata?.[CHANGE_FEED_WAS_PERSISTED_KEY] === true\n ? 'update'\n : 'create',\n })),\n );\n },\n async afterDelete(entries): Promise<void> {\n await recordInstanceChanges(\n entries.map(({ instance }) => ({ instance, operation: 'delete' })),\n );\n },\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 // A staged append (#2649) has no sequence yet, so there is no cursor to\n // signal with. The signal is published by the drain that sequences it,\n // which is also the first moment it is honest — the pre-commit signal this\n // path used to publish for a transaction-wrapped write could describe work\n // a later rollback undid. Live subscribers see such writes one drain\n // later; cursor polling, the documented fallback, is unaffected.\n if (seq == null) return;\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\n/**\n * Record a collection mutation through one batch append and publish its direct\n * entries in feed order. The collection lifecycle supplies one database\n * context; malformed or framework-owned instances are skipped just as the\n * single-instance writer does.\n */\nexport async function recordInstanceChanges(\n entries: readonly { instance: SmrtObject; operation: ChangeOperation }[],\n): Promise<void> {\n const prepared: Array<{\n db: DatabaseInterface;\n input: AppendChangeInput;\n }> = [];\n for (const entry of entries) {\n try {\n const table = entry.instance.tableName;\n if (!isChangeFeedObservableTable(table)) continue;\n const id = (entry.instance as { id?: unknown }).id;\n const tenantId = (entry.instance as unknown as Record<string, unknown>)\n .tenantId;\n prepared.push({\n db: entry.instance.db,\n input: {\n table,\n rowId: typeof id === 'string' && id ? id : null,\n operation: entry.operation,\n tenantId: typeof tenantId === 'string' && tenantId ? tenantId : null,\n },\n });\n } catch {\n // Preserve the best-effort behavior of appendForInstance for test\n // doubles and partially initialized instances.\n }\n }\n if (prepared.length === 0) return;\n const db = prepared[0].db;\n if (prepared.some((entry) => entry.db !== db)) {\n await Promise.all(\n prepared.map(({ input, db: entryDb }) => appendChange(entryDb, input)),\n );\n return;\n }\n try {\n const sequences = await appendChanges(\n db,\n prepared.map(({ input }) => input),\n );\n for (let index = 0; index < prepared.length; index++) {\n const seq = sequences[index];\n if (seq == null) continue;\n const input = prepared[index].input;\n try {\n publishChangeSignal(db, {\n table: input.table,\n operation: input.operation ?? 'update',\n rowId: input.rowId ?? null,\n tenantId: input.tenantId ?? null,\n seq,\n });\n } catch (error) {\n warnSignalPublishFailureOnce(db, input.table, error);\n }\n }\n } catch (error) {\n warnAppendFailureOnce(db, prepared[0].input.table, error);\n }\n}\n\n/** Record a framework-owned mutation that intentionally bypasses save hooks. */\nexport async function recordInstanceChange(\n instance: SmrtObject,\n operation: ChangeOperation = 'update',\n): Promise<void> {\n await appendForInstance(instance, operation);\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 warnDrainFailureOnce(db: DatabaseInterface, error: unknown): void {\n try {\n const dbKey = resolveDbCacheKey(db);\n if (warnedDrainFailures.has(dbKey)) return;\n warnedDrainFailures.add(dbKey);\n logger.warn(\n 'Change feed: failed to sequence entries staged inside caller ' +\n 'transactions. Those entries are durable but stay invisible to cursor ' +\n 'readers until a drain succeeds (further failures for this database ' +\n 'are suppressed). A read-only handle cannot drain — schedule ' +\n 'drainChangeFeed() on a writable connection.',\n { error: error instanceof Error ? error.message : String(error) },\n );\n } catch {\n // Logging must never propagate into the read or 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 warnedDrainFailures.clear();\n // The append path's drain interval is process state too; a test that asserts\n // on drain-driven signals must not inherit a neighbour's timestamp.\n lastAppendDrain.clear();\n stagedSinceLastDrain.clear();\n deferredSignals.clear();\n}\n"],"mappings":"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"}
package/dist/class.js CHANGED
@@ -11,8 +11,8 @@ import { SignalBus } from "./signals/bus.js";
11
11
  import { ensureDeferredSystemTableCompatibility, tableExists } from "./system/compatibility.js";
12
12
  import { ensureSystemTables } from "./system/bootstrap.js";
13
13
  import { createLogger } from "@happyvertical/logger";
14
- import { getAI } from "@happyvertical/ai";
15
14
  import { getDatabase } from "@happyvertical/sql";
15
+ import { getAI } from "@happyvertical/ai";
16
16
  //#region src/class.ts
17
17
  /**
18
18
  * `_smrt_migrations.version` suffix marking that the deferred (manifest-created)
@@ -39,6 +39,12 @@ export declare const CACHE_INVALIDATION_CHANNEL = "smrt_collection_cache";
39
39
  * one per-process identity, keeping echo-avoidance consistent across channels.
40
40
  */
41
41
  export declare const PROCESS_ID: `${string}-${string}-${string}-${string}-${string}`;
42
+ /**
43
+ * Share an in-progress cache miss with callers for the same database, table,
44
+ * and final query key. Rejected reads are removed too, so a later caller can
45
+ * retry rather than inheriting a permanently failed promise.
46
+ */
47
+ export declare function getOrCreateInFlightRead(dbKey: string, tableName: string, queryKey: string, generation: number, read: () => Promise<Record<string, unknown>[]>): Promise<Record<string, unknown>[]>;
42
48
  /**
43
49
  * Current invalidation generation for a table (0 if never invalidated).
44
50
  * Capture this before a DB read and pass it to `setCachedRows`.
@@ -58,8 +64,11 @@ export declare function resolveDbCacheKey(db: DatabaseInterface): string;
58
64
  * Build the cache key for a query. The final SQL and bound parameters fully
59
65
  * normalize the query shape — they already include STI discriminator
60
66
  * filters, interceptor-injected tenant filters, ORDER BY, LIMIT and OFFSET.
67
+ * Collection reads also supply their concrete executor so neither pending
68
+ * reads nor completed rows cross connection/transaction boundaries. The
69
+ * surrounding URL/table scope remains shared for invalidation generations.
61
70
  */
62
- export declare function buildQueryCacheKey(sql: string, params: unknown[]): string;
71
+ export declare function buildQueryCacheKey(sql: string, params: unknown[], db?: DatabaseInterface): string;
63
72
  /**
64
73
  * Read cached rows for a query, or undefined on miss/expiry.
65
74
  *
@@ -1 +1 @@
1
- {"version":3,"file":"collection-cache.d.ts","sourceRoot":"","sources":["../src/collection-cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAI5D;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB;IACpC;;OAEG;IACH,GAAG,EAAE,MAAM,CAAC;IAEZ;;;;;;;;;;;;;;OAcG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAcD;;GAEG;AACH,eAAO,MAAM,0BAA0B,0BAA0B,CAAC;AAElE;;;;;;GAMG;AACH,eAAO,MAAM,UAAU,qDAAsB,CAAC;AAsB9C;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAE3E;AAQD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,iBAAiB,GAAG,MAAM,CAa/D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,MAAM,CAEzE;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,SAAS,CAQvC;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAC/B,GAAG,EAAE,MAAM,EACX,kBAAkB,CAAC,EAAE,MAAM,GAC1B,IAAI,CAqCN;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,GAChB,IAAI,CAMN;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,IAAI,IAAI,CAK3C;AAaD;;GAEG;AACH,wBAAgB,iCAAiC,CAC/C,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,GAChB,IAAI,CAON;AAED;;GAEG;AACH,wBAAgB,4BAA4B,CAC1C,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,GAChB,OAAO,CAET;AAcD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,iBAAiB,GAGhD;IACE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC3D,MAAM,CACJ,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,aAAa,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACzD,GACD,SAAS,CACd;AAYD;;;;;GAKG;AACH,wBAAsB,0BAA0B,CAC9C,EAAE,EAAE,iBAAiB,EACrB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAkBf;AAED;;;;;;;GAOG;AACH,wBAAgB,+BAA+B,CAAC,EAAE,EAAE,iBAAiB,GAAG,IAAI,CAsD3E;AAED;;;GAGG;AACH,wBAAgB,8BAA8B,IAAI,IAAI,CAOrD"}
1
+ {"version":3,"file":"collection-cache.d.ts","sourceRoot":"","sources":["../src/collection-cache.ts"],"names":[],"mappings":"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"}