@happyvertical/smrt-core 0.41.0 → 0.42.1
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.
- package/AGENTS.md +1 -1
- package/agents/schema-paths.md +79 -8
- package/dist/change-feed.d.ts.map +1 -1
- package/dist/change-feed.js +2 -2
- package/dist/change-feed.js.map +1 -1
- package/dist/change-signals.d.ts.map +1 -1
- package/dist/change-signals.js +8 -1
- package/dist/change-signals.js.map +1 -1
- package/dist/class.d.ts.map +1 -1
- package/dist/class.js +10 -9
- package/dist/class.js.map +1 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +2 -2
- package/dist/collection.js.map +1 -1
- package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
- package/dist/dispatch/collections/Dispatches.js +5 -5
- package/dist/dispatch/collections/Dispatches.js.map +1 -1
- package/dist/dispatch/models/Dispatch.d.ts +1 -1
- package/dist/dispatch/models/Dispatch.d.ts.map +1 -1
- package/dist/dispatch/models/Dispatch.js +3 -2
- package/dist/dispatch/models/Dispatch.js.map +1 -1
- package/dist/dispatch/models/DispatchSubscription.d.ts +1 -1
- package/dist/dispatch/models/DispatchSubscription.d.ts.map +1 -1
- package/dist/dispatch/models/DispatchSubscription.js +2 -1
- package/dist/dispatch/models/DispatchSubscription.js.map +1 -1
- package/dist/embeddings/storage.d.ts.map +1 -1
- package/dist/embeddings/storage.js +2 -1
- package/dist/embeddings/storage.js.map +1 -1
- package/dist/learning/memory.d.ts.map +1 -1
- package/dist/learning/memory.js +5 -4
- package/dist/learning/memory.js.map +1 -1
- package/dist/manifest/static-manifest.js +1 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/migrations/differ.js +2 -2
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/migrations/index.js +2 -1
- package/dist/migrations/integer-width.d.ts +99 -0
- package/dist/migrations/integer-width.d.ts.map +1 -0
- package/dist/migrations/integer-width.js +237 -0
- package/dist/migrations/integer-width.js.map +1 -0
- package/dist/migrations/minor-units.d.ts +2 -2
- package/dist/migrations/minor-units.d.ts.map +1 -1
- package/dist/migrations/minor-units.js +16 -14
- package/dist/migrations/minor-units.js.map +1 -1
- package/dist/migrations/tracker.d.ts.map +1 -1
- package/dist/migrations/tracker.js +28 -20
- package/dist/migrations/tracker.js.map +1 -1
- package/dist/migrations.js +2 -1
- package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/duckdb-strategy.js +1 -0
- package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
- package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/postgres-strategy.js +2 -0
- package/dist/schema/ddl/postgres-strategy.js.map +1 -1
- package/dist/schema/generator.d.ts +1 -33
- package/dist/schema/generator.d.ts.map +1 -1
- package/dist/schema/generator.js +1 -150
- package/dist/schema/generator.js.map +1 -1
- package/dist/schema/index.d.ts +1 -2
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +1 -2
- package/dist/schema/live-parity.d.ts +3 -1
- package/dist/schema/live-parity.d.ts.map +1 -1
- package/dist/schema/live-parity.js +19 -0
- package/dist/schema/live-parity.js.map +1 -1
- package/dist/schema/types.d.ts +17 -10
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema.js +1 -2
- package/dist/smrt-knowledge.json +7 -7
- package/dist/system/retention.d.ts.map +1 -1
- package/dist/system/retention.js +3 -3
- package/dist/system/retention.js.map +1 -1
- package/dist/system/schema.d.ts +2 -1
- package/dist/system/schema.d.ts.map +1 -1
- package/dist/system/schema.js +4 -2
- package/dist/system/schema.js.map +1 -1
- package/dist/utils/safe-integer.d.ts +19 -0
- package/dist/utils/safe-integer.d.ts.map +1 -0
- package/dist/utils/safe-integer.js +31 -0
- package/dist/utils/safe-integer.js.map +1 -0
- package/dist/utils.d.ts +2 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +6 -3
- package/dist/utils.js.map +1 -1
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +0 -53
- package/dist/vite-plugin/index.js.map +1 -1
- package/package.json +4 -4
- package/dist/schema/override-system.d.ts +0 -43
- package/dist/schema/override-system.d.ts.map +0 -1
- package/dist/schema/override-system.js +0 -205
- package/dist/schema/override-system.js.map +0 -1
package/AGENTS.md
CHANGED
|
@@ -17,7 +17,7 @@ subsystem you are editing. This file keeps what holds across all of them.
|
|
|
17
17
|
| `src/change-feed.ts` | the adapter-agnostic change-observation spine — `_smrt_changes`, cursors, table versions, generated `_changes` routes, retention | [agents/change-feed.md](agents/change-feed.md) |
|
|
18
18
|
| `src/change-signals.ts` + the generated `_events` SSE route | the push companion to the change feed — the signal bus, cross-replica fan-out, the SSE route, and its documented gaps | [agents/change-signals.md](agents/change-signals.md) |
|
|
19
19
|
| `src/generators/` + `src/vite-plugin/web-collections.ts` | REST/CLI/MCP/web-collection generation, the `manifestHash` emission sites, and generated conditional-GET / ETag v2 semantics | [agents/generators.md](agents/generators.md) |
|
|
20
|
-
| `src/schema/` | the
|
|
20
|
+
| `src/schema/` | the four `SchemaGenerator` entry points, which two reach production, why schema drift stayed invisible, and the #2382 index/tenancy rules | [agents/schema-paths.md](agents/schema-paths.md) |
|
|
21
21
|
|
|
22
22
|
## SmrtObject Lifecycle
|
|
23
23
|
|
package/agents/schema-paths.md
CHANGED
|
@@ -10,9 +10,9 @@ Written from the 2026-08-17 database-layer gap assessment (epic #2382). Symbol
|
|
|
10
10
|
names here are stable; the line numbers the assessment quotes are not, so trust
|
|
11
11
|
this call graph and re-grep before citing a location.
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## Four entry points, two of which ship
|
|
14
14
|
|
|
15
|
-
`src/schema/generator.ts` exposes
|
|
15
|
+
`src/schema/generator.ts` exposes four index-emitting entry points. They do not
|
|
16
16
|
produce the same schema for the same class.
|
|
17
17
|
|
|
18
18
|
| Entry point | Selected by | Status |
|
|
@@ -21,7 +21,15 @@ produce the same schema for the same class.
|
|
|
21
21
|
| `generateCTISchemaFromManifest` | `src/scanner/manifest-generator.ts` | **production** |
|
|
22
22
|
| `generateSTISchemaFromRegistry` | `src/testing/database.ts` (`getTestDatabase()`), `src/schema/utils.ts` (`generateSchema`; `ensureSchema` only as a fallback) | tests + runtime helpers |
|
|
23
23
|
| `generateSchemaFromRegistry` | the same two callers | tests + runtime helpers |
|
|
24
|
-
|
|
24
|
+
|
|
25
|
+
A fifth entry point, the build-time AST `generateSchema(objectDef)`, existed
|
|
26
|
+
until #2380: it fed only the `smrt:schema` virtual module, which had no
|
|
27
|
+
consumer, had rotted relative to the four paths above (an `idx_`-prefixed
|
|
28
|
+
naming scheme none of the others use, and no conflict-index emission at all),
|
|
29
|
+
and was deleted rather than wired up. `SchemaOverrideSystem`
|
|
30
|
+
(`schema/override-system.ts`) — unwired, and its two non-generic methods
|
|
31
|
+
hard-coded a schema extension for a project outside this monorepo — was
|
|
32
|
+
deleted alongside it. See rule 9 and the new rule at the end of this file.
|
|
25
33
|
|
|
26
34
|
Production DDL takes the manifest route:
|
|
27
35
|
|
|
@@ -313,7 +321,7 @@ scans a btree either way, so an ascending index also serves the matching
|
|
|
313
321
|
`ORDER BY ... DESC` as an ordered scan with no Sort node. `unique` and `where`
|
|
314
322
|
(partial index) are honoured.
|
|
315
323
|
|
|
316
|
-
`appendDeclaredIndexes()` runs first on all
|
|
324
|
+
`appendDeclaredIndexes()` runs first on all four entry points, ahead of
|
|
317
325
|
`ensureDefaultListOrderingIndex()` (rule 18) and `ensureReferenceColumnIndexes()`,
|
|
318
326
|
so a declared composite leading with the tenant column (or any reference column)
|
|
319
327
|
replaces the automatic standalone index rather than duplicating it.
|
|
@@ -484,10 +492,10 @@ child-first/base-first equality test.
|
|
|
484
492
|
|
|
485
493
|
Every generated list surface — REST, MCP, the SvelteKit list route — pages with
|
|
486
494
|
`ORDER BY created_at DESC, <pk> ASC` (`DEFAULT_LIST_ORDER_BY`, #2367), and
|
|
487
|
-
until #2363 no schema path indexed `created_at` (the
|
|
488
|
-
`updated_at`), so the framework's own default page was a
|
|
489
|
-
top-N sort. `ensureDefaultListOrderingIndex()` now runs
|
|
490
|
-
emits:
|
|
495
|
+
until #2363 no schema path indexed `created_at` (the AST path, deleted in
|
|
496
|
+
#2380, indexed `updated_at`), so the framework's own default page was a
|
|
497
|
+
sequential scan plus a top-N sort. `ensureDefaultListOrderingIndex()` now runs
|
|
498
|
+
on all four entry points and emits:
|
|
491
499
|
|
|
492
500
|
- `(<tenant column>, created_at)` on a tenant-scoped table — the tenancy
|
|
493
501
|
interceptor puts `tenant_id = ?` in front of every list, so the tenant column
|
|
@@ -784,3 +792,66 @@ enforced) and `_smrt_dispatch` (an operator-only `dispatch:cleanup`).
|
|
|
784
792
|
their documented "expiry is not applied at read time" contract — changing it
|
|
785
793
|
would change read semantics for existing callers, which is a different issue
|
|
786
794
|
from bounding storage. `LearningMemory` filters expired rows itself.
|
|
795
|
+
|
|
796
|
+
### 23. Dead generation surfaces were deleted, not wired (#2380)
|
|
797
|
+
|
|
798
|
+
Rule 9 named three surfaces that read as canonical but were not: the AST
|
|
799
|
+
`generateSchema(objectDef)` entry point, `SchemaOverrideSystem`, and the
|
|
800
|
+
never-emitted `triggers: []`. Resolution, so a future agent does not re-open
|
|
801
|
+
what was deliberately decided:
|
|
802
|
+
|
|
803
|
+
- **The AST path is gone.** `SchemaGenerator.generateSchema(objectDef)` and its
|
|
804
|
+
AST-only private helpers (`generateIndexes`, `generateTriggers`,
|
|
805
|
+
`extractDependencies`, `generateVersion`, `getTableName`,
|
|
806
|
+
`extractPackageName`) were deleted from `schema/generator.ts`, along with
|
|
807
|
+
their sole caller, `generateSchemaModule()` in `vite-plugin/index.ts`, and the
|
|
808
|
+
`smrt:schema` / `@happyvertical/smrt-virt-schema` virtual module registration
|
|
809
|
+
that fed. Nothing else called it — grep the deleted method's exact name
|
|
810
|
+
before assuming a caller was missed; the path-parity fixture and every other
|
|
811
|
+
rule above already speak only of the four surviving entry points.
|
|
812
|
+
- **`SchemaOverrideSystem` is gone**, file and all
|
|
813
|
+
(`schema/override-system.ts` no longer exists). It was never called from
|
|
814
|
+
anywhere in this repository outside its own now-deleted exports, and two of
|
|
815
|
+
its five public methods (`createPraecoContentOverride`,
|
|
816
|
+
`createPraecoMeetingOverride`) hard-coded a schema extension for a
|
|
817
|
+
consuming project outside this monorepo — scaffolding that never belonged in
|
|
818
|
+
the framework, not a generic feature with a missing caller. `SchemaOverride`
|
|
819
|
+
(the type) went with it; `ColumnDefinition`/`IndexDefinition`/
|
|
820
|
+
`TriggerDefinition`, which it merely referenced, did not.
|
|
821
|
+
- **The DDL-strategy trigger machinery was kept, not deleted.**
|
|
822
|
+
`TriggerDefinition`, `SchemaDefinition.triggers`, and every DDL strategy's
|
|
823
|
+
`generateTriggers()` / `generateTriggerStatement()` / `supportsTriggers()`
|
|
824
|
+
(`schema/ddl/*.ts`) are real, engine-uniform, directly-tested rendering code
|
|
825
|
+
that runs on **every** table creation via `strategy.generateTriggers(schema)`
|
|
826
|
+
— unlike the AST path, this is not an orphaned call graph. It is kept for the
|
|
827
|
+
same reason rule 16 keeps the cached `schema.ddl` string: `SchemaDefinition`
|
|
828
|
+
is part of the shape third-party tooling and published manifests may already
|
|
829
|
+
depend on, and `EngineSpecificDDL`/`MultiEngineDDL` (`schema/ddl/types.ts`)
|
|
830
|
+
carry `triggers` as part of that same contract. Deleting a published field is
|
|
831
|
+
a different (and unjustified) risk from deleting a virtual module nothing
|
|
832
|
+
ever imported.
|
|
833
|
+
- **What changed is what is documented, not what runs.** `schema.triggers` is
|
|
834
|
+
now explicitly documented (`schema/types.ts`) as always `[]` on every schema
|
|
835
|
+
a `@smrt()` class can produce, and why: there is no `@smrt()`/`@field()`
|
|
836
|
+
option that populates it (unlike `indexes`, #2357), `updated_at` is
|
|
837
|
+
maintained at the application layer (`SmrtObject.save()`), and
|
|
838
|
+
`migrations/differ.ts` never diffs triggers — so even a hand-populated one
|
|
839
|
+
would only apply to a newly `CREATE TABLE`d table and never retrofit an
|
|
840
|
+
existing one. Wiring live trigger emission was considered and rejected for
|
|
841
|
+
this issue: it is a migration-rollout feature (retrofitting 238+ existing
|
|
842
|
+
production tables needs the same `SMRT_SCHEMA_VERSION`-replay or differ
|
|
843
|
+
support rule 21/rule 22's system-table work required), not a cleanup, and
|
|
844
|
+
nothing in the epic depended on it the way #2359 depended on FK indexes
|
|
845
|
+
actually shipping.
|
|
846
|
+
- **`_smrt_signals` and `ObjectRegistry.persistToDatabase()`/`loadFromDatabase()`**
|
|
847
|
+
— named in the original finding alongside triggers — were already handled by
|
|
848
|
+
#2376 before this issue landed: see rule 21 and `system/schema.ts`'s
|
|
849
|
+
`RETIRED_SYSTEM_TABLES`. Nothing further to do there.
|
|
850
|
+
- **The two config-rebuild-site comments** (`schema/utils.ts`,
|
|
851
|
+
`testing/database.ts`) rule 8 requires were already in place, added by
|
|
852
|
+
#2357/#2360; the `testing/database.ts` "same as migrations" overclaim rule 1
|
|
853
|
+
quotes was already corrected by #2359, and doctor's `experimentalDecorators`
|
|
854
|
+
check was already fixed by #2368/#2399 (see `packages/cli/AGENTS.md`
|
|
855
|
+
Gotchas). Re-verify against current source before repeating any of these —
|
|
856
|
+
the epic's PRs landed across one evening and a stale assessment line is not
|
|
857
|
+
proof a fix is still needed.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"change-feed.d.ts","sourceRoot":"","sources":["../src/change-feed.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0FG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"change-feed.d.ts","sourceRoot":"","sources":["../src/change-feed.ts"],"names":[],"mappings":"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"}
|
package/dist/change-feed.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveDbCacheKey } from "./collection-cache.js";
|
|
2
|
+
import { toSafeInteger } from "./utils/safe-integer.js";
|
|
2
3
|
import { publishChangeSignal } from "./change-signals.js";
|
|
3
4
|
import { resolveDispatchTenantScope } from "./dispatch/tenant-resolver.js";
|
|
4
5
|
import { isEmbeddedDatabase, withEmbeddedWriteQueue } from "./embedded-write-queue.js";
|
|
@@ -497,8 +498,7 @@ async function getTableVersion(db, table) {
|
|
|
497
498
|
return toSeqNumber(getQueryRows(await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`))[0]?.horizon);
|
|
498
499
|
}
|
|
499
500
|
function toSeqNumber(value) {
|
|
500
|
-
|
|
501
|
-
return Number.isFinite(parsed) ? parsed : 0;
|
|
501
|
+
return toSafeInteger(value ?? 0, "Change-feed sequence");
|
|
502
502
|
}
|
|
503
503
|
function rowToEntry(row) {
|
|
504
504
|
return {
|
package/dist/change-feed.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"change-feed.js","names":[],"sources":["../src/change-feed.ts"],"sourcesContent":["/**\n * Adapter-agnostic change feed — the framework's change-observation spine\n * (issue #1758, parent PRD #1755).\n *\n * Every framework `save()`/`delete()` appends exactly one row to the\n * `_smrt_changes` system table (monotonic per-database sequence, table name,\n * row id, operation, tenant id, timestamp). Deletes are recorded as\n * tombstones (`operation: 'delete'`), distinguishable from updates. One read\n * interface — {@link getChangesSince} — returns changes after a cursor,\n * filterable by table and tenant, and serves three eventual consumers:\n * client delta pull, the SSE push channel, and the per-table version source\n * backing ETags.\n *\n * ## Cursor semantics (the precise guarantee)\n *\n * Sequences are allocated *inside* the append statement as\n * `COALESCE(MAX(seq), 0) + 1` over the feed table itself, with a retry on\n * primary-key conflict. `MAX(seq)` only observes committed rows, so a row\n * with sequence `N` can only be inserted while every row with sequence\n * `< N` is already committed (a conflicting in-flight allocation of the same\n * value blocks, then retries). Committed rows therefore always form a\n * contiguous run ending at `MAX(seq)` — the **committed horizon**. Sequence\n * order equals commit order; out-of-order commit visibility (the classic\n * MVCC race that makes native identity/serial columns unsafe as cursors\n * under concurrent writers) cannot occur.\n *\n * {@link getChangesSince} reads the committed horizon `H = MAX(seq)`, then\n * returns matching rows with `since < seq <= H` (bounded by `limit`), and a\n * `cursor` that is either `H` (page exhaustive) or the last returned `seq`\n * (page limited). Because no change can ever commit at or below an observed\n * horizon after it was observed, polling with returned cursors misses no\n * committed change and never returns the same change twice — under any\n * number of concurrent writers, identically on SQLite, Postgres and DuckDB.\n * This is the design reason the allocator is `MAX+1` rather than a native\n * AUTOINCREMENT/identity column: identity values are allocated before\n * commit, so a reader on Postgres could observe seq 101 while seq 100 is\n * still uncommitted and advance its cursor past it. (No shared\n * auto-increment mechanism exists in the system-table schema path either;\n * see `system/schema.ts`.)\n *\n * Contention note: appends serialize on the head of the log. Each append is\n * one small INSERT (issued from the write path *after* the user's row was\n * written), so the serialization window is one statement; conflicts resolve\n * with a bounded retry loop and are impossible on single-writer engines\n * (SQLite). PostgreSQL invokes that INSERT through the framework-owned\n * `_smrt_append_change` function so failure isolation remains one statement.\n *\n * ## Failure semantics\n *\n * A feed-write failure must never fail the user's write. The interceptor\n * wraps the append in a try/catch: on failure it logs a warning (deduped per\n * database) and continues. The trade-off is availability of the user's\n * write over completeness of the feed — consumers already need a\n * full-resync path for cursors older than the retention window, and the\n * same path covers a (rare) dropped feed row. PostgreSQL runs the INSERT in a\n * PL/pgSQL exception subtransaction and returns a caught SQLSTATE as data;\n * JavaScript only throws/logs after PostgreSQL has restored the caller's\n * transaction, so a swallowed append failure cannot surface later as 25P02.\n * The append still joins a caller-managed transaction on the same handle and\n * shares its fate (a rollback removes the change row with the data row).\n *\n * ## Known gaps (documented in the PRD)\n *\n * - Writes that bypass framework mutation paths (raw SQL) are invisible to\n * the feed — the same accepted gap as the #1499 collection cache.\n * {@link bumpChangeFeed} is the manual escape hatch: out-of-band writers\n * append a synthetic change row for the affected table.\n * - **Spurious `update` entries**: `SmrtObject.save()` has no dirty-check,\n * so a field-unchanged `.save()` still appends an `update` row. This is\n * by design — the writer observes writes, not diffs (it has no old-row\n * access), so the feed faithfully mirrors the write path. Diff-aware\n * paths (`getOrUpsert()`'s diff guard, the sync-apply endpoint's no-op\n * detection) short-circuit before `save()` and append nothing.\n * Subscribers must tolerate spurious entries; they are convergent — a\n * re-fetch returns identical data.\n *\n * ## Retention\n *\n * The log is append-only and grows with write volume. {@link pruneChangeFeed}\n * bounds it by age (`maxAgeMs`) and/or row count (`maxRows`); call it from a\n * scheduled job sized so the retention window comfortably exceeds the\n * slowest consumer's polling interval. Pruning deletes oldest-first and\n * always retains the newest entry, so retained sequences stay a contiguous\n * `[floor..horizon]` run — which is how {@link getChangesSince} *detects* a\n * consumer whose cursor predates the retained window and answers it with\n * `resyncRequired: true` plus a fresh resume cursor instead of silently\n * skipping the pruned changes.\n *\n * @see https://github.com/happyvertical/smrt/issues/1758\n * @packageDocumentation\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { publishChangeSignal } from './change-signals.js';\nimport { resolveDbCacheKey } from './collection-cache.js';\nimport { resolveDispatchTenantScope } from './dispatch/tenant-resolver.js';\nimport {\n isEmbeddedDatabase,\n withEmbeddedWriteQueue,\n} from './embedded-write-queue.js';\nimport { GlobalInterceptors, type InterceptorContext } from './interceptors.js';\nimport type { SmrtObject } from './object.js';\nimport { detectEngine } from './schema/ddl/index.js';\nimport { SYSTEM_TABLE_NAMES } from './schema/system-table-shapes.js';\nimport {\n CREATE_SMRT_CHANGES_TABLE,\n ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION,\n ENSURE_POSTGRES_CHANGE_FEED_SCHEMA,\n FRAMEWORK_OPERATIONAL_TABLES,\n POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY,\n POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME,\n REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION,\n RETIRED_SYSTEM_TABLES,\n} from './system/schema.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/** Name of the append-only change-feed system table. */\nexport const CHANGE_FEED_TABLE = '_smrt_changes';\n\n/**\n * Tables the change-feed writer never records (issue #2376).\n *\n * The writer used to skip every table whose name started with `_smrt_`. That\n * prefix stopped meaning \"framework bookkeeping\" once packages began naming\n * their *domain* tables `_smrt_feature_overrides`, `_smrt_prompt_overrides`,\n * `_smrt_subscription_plans`, `_smrt_report_schedules`, `_smrt_field_policies`\n * and ~20 more: clients syncing those through `_changes` never saw an update.\n *\n * The exclusion is now an allowlist of tables the framework actually owns —\n * the hand-written bookkeeping DDL ({@link SYSTEM_TABLE_NAMES}, which includes\n * the feed's own table so it can never observe itself), the model-backed\n * operational plumbing ({@link FRAMEWORK_OPERATIONAL_TABLES}), and the retired\n * system tables that may still exist on older databases.\n */\nexport const CHANGE_FEED_EXCLUDED_TABLES: ReadonlySet<string> = new Set([\n ...SYSTEM_TABLE_NAMES,\n ...FRAMEWORK_OPERATIONAL_TABLES,\n ...RETIRED_SYSTEM_TABLES,\n]);\n\n/**\n * Whether framework writes to `tableName` are recorded in the change feed.\n *\n * Exported so tooling and tests can reason about feed coverage without\n * re-deriving the rule. See {@link CHANGE_FEED_EXCLUDED_TABLES}.\n */\nexport function isChangeFeedObservableTable(tableName: string): boolean {\n return Boolean(tableName) && !CHANGE_FEED_EXCLUDED_TABLES.has(tableName);\n}\n\n/** Interceptor name of the framework's change-feed writer. */\nexport const CHANGE_FEED_INTERCEPTOR_NAME = 'smrt-change-feed';\n\n/**\n * Change operations recorded in the feed. Deletes are tombstones —\n * consumers can distinguish \"row changed\" from \"row is gone\" without\n * consulting the source table.\n */\nexport type ChangeOperation = 'create' | 'update' | 'delete';\n\n/** One entry of the change feed. */\nexport interface ChangeFeedEntry {\n /** Strictly monotonic per-database sequence (the cursor dimension). */\n seq: number;\n /** Physical table the change happened in (STI children report the shared base table). */\n table: string;\n /**\n * Primary key of the changed row, or `null` for table-level synthetic\n * bumps recorded via {@link bumpChangeFeed} without a row id.\n */\n rowId: string | null;\n /** What happened. `'delete'` entries double as tombstones. */\n operation: ChangeOperation;\n /** Tenant the changed row belongs to, or `null` for global/non-tenant rows. */\n tenantId: string | null;\n /** ISO-8601 timestamp recorded when the change was appended. */\n timestamp: string;\n}\n\n/** Options for {@link getChangesSince}. */\nexport interface GetChangesOptions {\n /**\n * Cursor to read after. Only rows with `seq` strictly greater than `since`\n * are returned; pass a previously returned {@link ChangeFeedPage.cursor} to\n * poll.\n *\n * `0` reads from the start of the log only while it has not been pruned past\n * the beginning. Once retention has raised the retained floor above the\n * start, `since: 0` (like any cursor older than the retained window) can no\n * longer be served incrementally — the read returns\n * {@link ChangeFeedPage.resyncRequired} and the caller must do a full\n * resync before resuming from {@link ChangeFeedPage.resyncCursor}.\n */\n since: number;\n /** Restrict to these physical table names. Empty/omitted → all tables. */\n tables?: string[];\n /**\n * Tenant visibility filter:\n * - omitted/`undefined` → no tenant filter (all rows).\n * - `null` → only global rows (`tenant_id IS NULL`).\n * - `'<tenantId>'` → that tenant's rows **plus** global rows, matching the\n * DispatchBus read rule (`tenant_id = T OR tenant_id IS NULL`). A tenant\n * never sees another tenant's changes.\n */\n tenantId?: string | null;\n /**\n * Page size (default {@link DEFAULT_CHANGES_LIMIT}, capped at\n * {@link MAX_CHANGES_LIMIT}). When a page fills up, the returned cursor\n * stops at the last returned row so the next poll continues seamlessly.\n */\n limit?: number;\n}\n\n/** Result page of {@link getChangesSince}. */\nexport interface ChangeFeedPage {\n /** Matching changes ordered by ascending `seq`. */\n changes: ChangeFeedEntry[];\n /**\n * The next cursor. Monotonic: never lower than the `since` it was derived\n * from. Equal to the committed horizon when the page was exhaustive, or to\n * the last returned `seq` when the page hit `limit`. Feed the value back\n * as `since` to observe every later change exactly once.\n */\n cursor: number;\n /**\n * Present (and `true`) when the supplied cursor cannot be served\n * incrementally and the consumer must fall back to a full resync:\n *\n * - the cursor predates the retained window (entries at or below it were\n * pruned away — the changes between it and the retained floor are gone\n * for good), or\n * - the cursor is ahead of the committed horizon / unknown to this\n * database (a foreign or reset cursor).\n *\n * When set, `changes` is empty and `cursor` echoes `since` unchanged.\n * After its full data refetch, the consumer should resume polling from\n * {@link resyncCursor}, the committed horizon observed by this read.\n * Detection is computed on the **unfiltered** log: `tables`/`tenantId`\n * filters legitimately hide rows and never trigger (or mask) a resync\n * signal.\n */\n resyncRequired?: boolean;\n /**\n * Current committed horizon to use after handling a resync. Present with\n * {@link resyncRequired}; separated from `cursor` so old callers that rely\n * on `cursor` echoing the rejected value keep their monotonic-cursor\n * invariant.\n */\n resyncCursor?: number;\n}\n\n/** Input for {@link appendChange} / {@link bumpChangeFeed}. */\nexport interface AppendChangeInput {\n /** Physical table name the change refers to. */\n table: string;\n /** Changed row's primary key; `null`/omitted records a table-level change. */\n rowId?: string | null;\n /** Operation to record (default `'update'`). */\n operation?: ChangeOperation;\n /** Tenant the change belongs to (default `null` = global). */\n tenantId?: string | null;\n}\n\n/** Retention bounds for {@link pruneChangeFeed}. At least one is required. */\nexport interface ChangeFeedRetention {\n /** Prune entries older than this many milliseconds. */\n maxAgeMs?: number;\n /** Keep at most this many newest entries (by sequence). */\n maxRows?: number;\n /**\n * Count the entries the bounds select without deleting them (#2375).\n *\n * Lets the retention sweep and `smrt db:prune --dry-run` preview the exact\n * same predicate the real prune would execute.\n */\n dryRun?: boolean;\n}\n\n/** Default page size for {@link getChangesSince}. */\nexport const DEFAULT_CHANGES_LIMIT = 500;\n\n/** Hard cap on the page size for {@link getChangesSince}. */\nexport const MAX_CHANGES_LIMIT = 5_000;\n\n/**\n * Maximum append attempts under sequence contention. Conflicts only occur\n * with concurrent writers on MVCC engines and resolve as soon as the\n * blocking transaction commits, so a small bound is ample.\n */\nconst MAX_APPEND_ATTEMPTS = 20;\n\nconst VALID_OPERATIONS: ReadonlySet<string> = new Set([\n 'create',\n 'update',\n 'delete',\n]);\n\n// ============================================================================\n// Engine / SQL helpers (mirrors system/compatibility.ts conventions)\n// ============================================================================\n\ntype DatabaseWithConfig = DatabaseInterface & {\n config?: { type?: string; url?: string };\n type?: string;\n};\n\nfunction getEngine(\n db: DatabaseInterface,\n typeHint?: string,\n): ReturnType<typeof detectEngine> {\n const withConfig = db as DatabaseWithConfig;\n return detectEngine(\n db.url || withConfig.config?.url || '',\n typeHint || withConfig.type || withConfig.config?.type,\n );\n}\n\n/**\n * Positional placeholder factory: Postgres uses `$n`, SQLite/DuckDB use `?`.\n */\nfunction placeholders(db: DatabaseInterface): (index: number) => string {\n const engine = getEngine(db);\n return engine === 'postgres' ? (index) => `$${index}` : () => '?';\n}\n\nfunction getQueryRows(result: unknown): Record<string, unknown>[] {\n if (Array.isArray(result)) {\n return result as Record<string, unknown>[];\n }\n if (result && typeof result === 'object' && 'rows' in result) {\n const rows = (result as { rows?: unknown }).rows;\n if (Array.isArray(rows)) {\n return rows as Record<string, unknown>[];\n }\n }\n return [];\n}\n\nfunction isUniqueViolation(error: unknown): boolean {\n const signals: string[] = [];\n const pending: unknown[] = [error];\n const seen = new Set<object>();\n\n while (pending.length > 0 && seen.size < 5) {\n const candidate = pending.shift();\n if (typeof candidate === 'string') {\n signals.push(candidate);\n continue;\n }\n if (!candidate || typeof candidate !== 'object' || seen.has(candidate)) {\n continue;\n }\n seen.add(candidate);\n\n const shaped = candidate as {\n cause?: unknown;\n code?: unknown;\n context?: unknown;\n message?: unknown;\n };\n if (typeof shaped.message === 'string') signals.push(shaped.message);\n if (typeof shaped.code === 'string') signals.push(shaped.code);\n if (shaped.cause !== undefined) pending.push(shaped.cause);\n\n if (shaped.context && typeof shaped.context === 'object') {\n const originalError = (shaped.context as { originalError?: unknown })\n .originalError;\n if (originalError !== undefined) pending.push(originalError);\n }\n }\n\n const message = signals.join(', ');\n return (\n /\\b23505\\b/.test(message) ||\n /unique constraint/i.test(message) ||\n /duplicate key/i.test(message) ||\n /primary key constraint/i.test(message) ||\n /constraint error/i.test(message)\n );\n}\n\n/**\n * Ensure the `_smrt_changes` system table exists on a database handle that\n * may not have passed through framework initialization (e.g. a raw handle\n * given to the REST generator). Idempotent (`CREATE ... IF NOT EXISTS`) and\n * guarded to run once per handle. Databases initialized through the\n * framework already have the table via the system-table bootstrap.\n */\nconst ensuredHandles = new WeakSet<object>();\n\nasync function postgresChangeFeedAppendFunctionExists(\n db: DatabaseInterface,\n): Promise<boolean> {\n const rows = getQueryRows(\n await db.query(\n `SELECT to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}') AS function_name`,\n ),\n );\n return Boolean(rows[0]?.function_name);\n}\n\nasync function getPostgresChangeFeedSchemaState(\n db: DatabaseInterface,\n): Promise<{\n tableExists: boolean;\n functionExists: boolean;\n createdAtType: string | null;\n}> {\n const rows = getQueryRows(\n await db.query(\n `SELECT\n to_regclass('${CHANGE_FEED_TABLE}') AS table_name,\n to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}') AS function_name,\n (\n SELECT data_type\n FROM information_schema.columns\n WHERE table_schema = current_schema()\n AND table_name = '${CHANGE_FEED_TABLE}'\n AND column_name = 'created_at'\n ) AS created_at_type`,\n ),\n );\n return {\n tableExists: Boolean(rows[0]?.table_name),\n functionExists: Boolean(rows[0]?.function_name),\n createdAtType: rows[0]?.created_at_type\n ? String(rows[0].created_at_type)\n : null,\n };\n}\n\nfunction assertPostgresChangeFeedTimestampCurrent(\n state: Awaited<ReturnType<typeof getPostgresChangeFeedSchemaState>>,\n): void {\n if (state.createdAtType === 'timestamp without time zone') {\n throw new Error(\n 'Legacy _smrt_changes.created_at requires an explicit audited migratePostgresSystemTimestamps() call before change-feed initialization',\n );\n }\n}\n\n/**\n * Install/refresh the PostgreSQL exception-subtransaction append boundary.\n *\n * Framework bootstrap calls this while applying the system-schema version that\n * introduced the helper, so upgraded databases acquire it before the migration\n * is recorded. Raw-handle initialization passes `replaceExisting: false` so a\n * read route does not require function ownership when the installed helper is\n * already current. A missing helper is installed by one server-side statement\n * that locks and rechecks before DDL. Non-PostgreSQL adapters are a no-op.\n *\n * @internal\n */\nexport async function ensurePostgresChangeFeedAppendFunction(\n db: DatabaseInterface,\n options: {\n replaceExisting?: boolean;\n typeHint?: string;\n } = {},\n): Promise<void> {\n if (getEngine(db, options.typeHint) !== 'postgres') return;\n\n assertPostgresChangeFeedTimestampCurrent(\n await getPostgresChangeFeedSchemaState(db),\n );\n\n if (options.replaceExisting === false) {\n if (await postgresChangeFeedAppendFunctionExists(db)) return;\n await db.query(ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION);\n return;\n }\n\n await db.query(REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION);\n}\n\nexport async function ensureChangeFeedTable(\n db: DatabaseInterface,\n): Promise<void> {\n if (ensuredHandles.has(db)) return;\n if (getEngine(db) === 'postgres') {\n const state = await getPostgresChangeFeedSchemaState(db);\n assertPostgresChangeFeedTimestampCurrent(state);\n if (\n state.tableExists &&\n state.functionExists &&\n state.createdAtType === 'timestamp with time zone'\n ) {\n ensuredHandles.add(db);\n return;\n }\n await db.query(ENSURE_POSTGRES_CHANGE_FEED_SCHEMA);\n } else {\n const statements = CREATE_SMRT_CHANGES_TABLE.split(';')\n .map((statement) => statement.trim())\n .filter((statement) => statement.length > 0);\n for (const statement of statements) {\n await db.query(statement);\n }\n }\n ensuredHandles.add(db);\n}\n\n// ============================================================================\n// Append (writer primitive + manual bump escape hatch)\n// ============================================================================\n\n/**\n * Append one change entry with a database-allocated, strictly monotonic\n * sequence.\n *\n * The sequence is allocated inside the INSERT itself\n * (`COALESCE(MAX(seq), 0) + 1`) and retried on primary-key conflict, which\n * keeps committed sequences contiguous and makes commit order equal\n * sequence order — the property the cursor guarantee rests on (see the\n * module docs). Throws after {@link MAX_APPEND_ATTEMPTS} consecutive\n * conflicts or on any non-conflict database error; the framework's\n * interceptor catches and logs instead of failing the user's write.\n *\n * **PostgreSQL transaction safety (#2026).** The INSERT runs inside the\n * framework-owned `_smrt_append_change` PL/pgSQL function. Its exception\n * handler is a PostgreSQL subtransaction: a failed attempt is rolled back\n * before the function returns `{ error_code, error_message }`. This method\n * then throws in JavaScript, where the existing retry/swallow policy applies\n * without aborting a caller-managed transaction. Keeping isolation inside one\n * database statement also prevents concurrent work on the same transaction\n * handle from interleaving inside a manual SAVEPOINT scope.\n */\nexport async function appendChange(\n db: DatabaseInterface,\n input: AppendChangeInput,\n): Promise<number> {\n const table = input.table?.trim();\n if (!table) {\n throw new Error('appendChange requires a non-empty table name');\n }\n const operation = input.operation ?? 'update';\n if (!VALID_OPERATIONS.has(operation)) {\n throw new Error(\n `appendChange operation must be one of create/update/delete, got '${String(\n input.operation,\n )}'`,\n );\n }\n\n const engine = getEngine(db);\n const p = placeholders(db);\n // The INSERT yields the ACTUAL sequence it allocated in the SAME statement\n // (directly via RETURNING on portable engines, through the function on\n // PostgreSQL). A separate follow-up `SELECT MAX(seq)` is racy under concurrent\n // appends (a peer can commit a higher seq in between), which would hand two\n // distinct changes the same SSE `id` and let a client's `Last-Event-ID`\n // overshoot a change it never received. The allocator stays `MAX+1` under the\n // unique-PK retry, so committed sequences remain contiguous (the cursor\n // guarantee — see module docs).\n const sql =\n engine === 'postgres'\n ? `SELECT allocated_seq, error_code, error_message FROM ` +\n `${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME}(` +\n `${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)})`\n : `INSERT INTO ${CHANGE_FEED_TABLE} ` +\n '(seq, table_name, row_id, operation, tenant_id, created_at) ' +\n `SELECT COALESCE(MAX(seq), 0) + 1, ${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)} ` +\n `FROM ${CHANGE_FEED_TABLE} RETURNING seq`;\n const params = [\n table,\n input.rowId ?? null,\n operation,\n input.tenantId ?? null,\n new Date().toISOString(),\n ];\n\n for (let attempt = 1; attempt <= MAX_APPEND_ATTEMPTS; attempt++) {\n try {\n // The append is a root-connection write; on embedded engines it goes\n // through the per-database write queue so it can never overlap a\n // null-aware upsert's second-connection transaction (#2360 — the pair\n // livelocked into SQLITE_BUSY under concurrent NULL-tenant creates).\n const rows = getQueryRows(\n await withEmbeddedWriteQueue(db, isEmbeddedDatabase(db), () =>\n db.query(sql, ...params),\n ),\n );\n const row = rows[0];\n if (!row) {\n throw new Error('Change feed append returned no result row');\n }\n if (engine === 'postgres' && row.error_code != null) {\n const error = new Error(\n String(row.error_message || 'PostgreSQL change-feed append failed'),\n ) as Error & { code: string };\n error.code = String(row.error_code);\n throw error;\n }\n return toSeqNumber(engine === 'postgres' ? row.allocated_seq : row.seq);\n } catch (error) {\n if (!isUniqueViolation(error) || attempt === MAX_APPEND_ATTEMPTS) {\n throw error;\n }\n // Sequence head contention: another append won the value. Re-running\n // recomputes MAX(seq) against the now-committed head.\n }\n }\n\n // Unreachable: the loop returns a seq or throws on the final attempt. Present\n // so the function satisfies its `Promise<number>` contract structurally.\n throw new Error('appendChange exhausted retries without allocating a seq');\n}\n\n/**\n * Manual bump escape hatch for out-of-band writers.\n *\n * Framework mutation paths feed the log automatically, but raw SQL issued\n * outside `save()`/`delete()` is invisible to it (documented gap, shared\n * with the #1499 collection cache). Call this after such a write so feed\n * consumers observe the change. Omitting `rowId` records a table-level\n * change (`rowId: null`), which consumers should treat as \"anything in this\n * table may have changed\".\n *\n * @example\n * ```typescript\n * await db.query(`UPDATE products SET price = price * 1.1`);\n * await bumpChangeFeed(db, { table: 'products' });\n * ```\n */\nexport async function bumpChangeFeed(\n db: DatabaseInterface,\n input: AppendChangeInput,\n): Promise<void> {\n await ensureChangeFeedTable(db);\n await appendChange(db, input);\n}\n\n// ============================================================================\n// Read interface\n// ============================================================================\n\n/**\n * Read committed changes after a cursor.\n *\n * Returns every committed change with `since < seq <= cursor` that matches\n * the filters, ordered by ascending `seq`. The returned cursor is safe to\n * persist and poll with: committed sequences are contiguous (see module\n * docs), so nothing can commit at or below the observed horizon afterwards —\n * reads miss no committed change under concurrent writers and never return\n * the same change twice. When `since` is already at the horizon, returns an\n * empty page with `cursor: since`.\n *\n * ## Resync detection (pruned / foreign cursors)\n *\n * A cursor that cannot be served incrementally is flagged with\n * `resyncRequired: true` (empty `changes`, `cursor` echoed unchanged,\n * `resyncCursor` set to the current horizon) so pollers never go silently,\n * permanently stale:\n *\n * - **Pruned gap**: retained sequences always form a contiguous run\n * `[floor..horizon]` and {@link pruneChangeFeed} deletes oldest-first\n * while always retaining the newest entry, so `since < floor - 1` proves\n * changes between the cursor and the retained window were pruned away.\n * - **Foreign/reset cursor**: `since > horizon` (ahead of anything this\n * database ever allocated), including any `since > 0` against a feed\n * with no entries.\n *\n * Detection runs on the **unfiltered** log — `tables`/`tenantId` filters\n * legitimately hide rows and never trigger (or mask) the signal. A caught-up\n * consumer (`since === horizon`) is never asked to resync, even when\n * retention has pruned everything older.\n *\n * Filters (`tables`, `tenantId`) affect which rows are *returned*, never how\n * the cursor advances — an exhausted filtered page still advances to the\n * horizon so pollers do not rescan filtered-out rows.\n */\nexport async function getChangesSince(\n db: DatabaseInterface,\n options: GetChangesOptions,\n): Promise<ChangeFeedPage> {\n const { since } = options;\n if (!Number.isFinite(since) || since < 0) {\n throw new Error(\n `getChangesSince requires a non-negative numeric cursor, got '${String(since)}'`,\n );\n }\n const limit = Math.min(\n Math.max(Math.floor(options.limit ?? DEFAULT_CHANGES_LIMIT), 1),\n MAX_CHANGES_LIMIT,\n );\n\n const p = placeholders(db);\n\n // The committed horizon: every seq <= horizon is committed and immutable\n // (append-only + contiguous allocation), so the page below is stable even\n // though it runs as a separate statement. The floor bounds the retained\n // window for pruned-cursor detection; both are computed UNFILTERED so\n // table/tenant filters can neither trigger nor mask a resync signal.\n const boundsRows = getQueryRows(\n await db.query(\n `SELECT MIN(seq) AS floor, MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`,\n ),\n );\n const floor = toSeqNumber(boundsRows[0]?.floor);\n const horizon = toSeqNumber(boundsRows[0]?.horizon);\n\n if (horizon === 0) {\n // No entries at all. A zero cursor is simply \"no changes ever\"; any\n // other cursor came from a different database (or a reset feed) and\n // cannot be served incrementally.\n return since === 0\n ? { changes: [], cursor: 0 }\n : { changes: [], cursor: since, resyncRequired: true, resyncCursor: 0 };\n }\n\n if (since > horizon) {\n // Foreign or reset cursor — ahead of anything this database allocated.\n return {\n changes: [],\n cursor: since,\n resyncRequired: true,\n resyncCursor: horizon,\n };\n }\n\n if (since < floor - 1) {\n // Pruned gap — the changes with seq in (since, floor) are gone for good.\n return {\n changes: [],\n cursor: since,\n resyncRequired: true,\n resyncCursor: horizon,\n };\n }\n\n if (horizon === since) {\n return { changes: [], cursor: since };\n }\n\n const conditions: string[] = [];\n const params: unknown[] = [];\n let index = 0;\n const next = () => p(++index);\n\n conditions.push(`seq > ${next()}`);\n params.push(since);\n conditions.push(`seq <= ${next()}`);\n params.push(horizon);\n\n const tables = options.tables?.filter((table) => table.trim().length > 0);\n if (tables && tables.length > 0) {\n conditions.push(`table_name IN (${tables.map(() => next()).join(', ')})`);\n params.push(...tables);\n }\n\n if (options.tenantId === null) {\n conditions.push('tenant_id IS NULL');\n } else if (typeof options.tenantId === 'string') {\n conditions.push(`(tenant_id = ${next()} OR tenant_id IS NULL)`);\n params.push(options.tenantId);\n }\n\n const sql =\n 'SELECT seq, table_name, row_id, operation, tenant_id, created_at ' +\n `FROM ${CHANGE_FEED_TABLE} WHERE ${conditions.join(' AND ')} ` +\n `ORDER BY seq ASC LIMIT ${next()}`;\n params.push(limit);\n\n const rows = getQueryRows(await db.query(sql, ...params));\n const changes = rows.map(rowToEntry);\n\n // Page limited → resume after the last returned row. Page exhaustive →\n // everything up to the horizon (matching or filtered out) has been\n // observed, so advance all the way.\n const cursor =\n changes.length === limit ? changes[changes.length - 1].seq : horizon;\n\n return { changes, cursor };\n}\n\n/**\n * {@link getChangesSince} scoped by the active tenant context.\n *\n * Resolves the tenant through the same dependency-inversion hook the\n * DispatchBus uses ({@link resolveDispatchTenantScope}), so it works without\n * core depending on `@happyvertical/smrt-tenancy`:\n *\n * - Tenancy disabled (no resolver registered) → no tenant filter.\n * - Tenancy enabled with an active tenant `T` → `T`'s rows plus global rows.\n * - Tenancy enabled with **no** active tenant → global rows only\n * (**fail-closed**: a missing context never widens visibility to all\n * tenants).\n *\n * This is the read the generated `_changes` routes call after establishing\n * tenant context from the authenticated principal.\n */\nexport async function getTenantScopedChangesSince(\n db: DatabaseInterface,\n options: Omit<GetChangesOptions, 'tenantId'>,\n): Promise<ChangeFeedPage> {\n const scope = resolveDispatchTenantScope();\n if (!scope.enforced) {\n return getChangesSince(db, options);\n }\n return getChangesSince(db, { ...options, tenantId: scope.tenantId });\n}\n\n/**\n * The per-table change version — the ETag source for zero-query conditional\n * GETs (#1765).\n *\n * Returns `MAX(seq)` over the feed rows for `table`: a monotonic number that\n * advances on every framework write to that table (create/update/delete, and\n * writes through the sync-apply endpoint, which all `save()`/`delete()`).\n * Because sequences are the change feed's globally-monotonic cursor dimension\n * (allocated `MAX+1` at commit time, never a native identity — see the module\n * docs), the value is **replica-stable**: two processes reading the same\n * committed database compute the same version, with no per-process divergence.\n * That is what lets a generated read route derive an ETag that short-circuits a\n * matching `If-None-Match` into a `304` before the collection query runs — an\n * unchanged table costs one indexed `MAX(seq)` lookup (backed by\n * `idx_smrt_changes_table_seq`) to revalidate, not a table scan.\n *\n * ## Why the fallback to the global horizon (and not 0)\n *\n * A table with no *retained* feed entry falls back to the global horizon\n * (`MAX(seq)` across all tables), returning 0 only when the whole feed is\n * empty. Retention prunes oldest-first and always keeps the newest entry, so a\n * quiet table can lose all of its own entries while busier tables advance. If\n * such a table reported 0, a client that cached it while it was empty (version\n * 0) could, after a change→prune→change→prune cycle returned the lookup to 0,\n * be wrongly answered `304` against data that has since changed — a false-304.\n *\n * The horizon fallback closes that hole: any write to the table appends a new\n * sequence strictly greater than every previously-observed value (its own or\n * the horizon), so the version — and therefore the ETag — strictly exceeds any\n * value a client already holds, forcing a fresh `200`. The only cost is that a\n * table with no retained entries of its own revalidates whenever the global\n * horizon moves; a table with a retained entry uses its own stable `MAX(seq)`\n * and is unaffected by writes to sibling tables. A persistent per-table\n * high-water mark that survives pruning would remove even that cost; it is a\n * deliberate follow-up, out of scope for this slice.\n *\n * Idempotently ensures the feed table exists first, so it is safe to call from\n * a read route on a raw handle that has never been written to.\n */\nexport async function getTableVersion(\n db: DatabaseInterface,\n table: string,\n): Promise<number> {\n const name = table?.trim();\n if (!name) {\n throw new Error('getTableVersion requires a non-empty table name');\n }\n await ensureChangeFeedTable(db);\n\n const p = placeholders(db);\n const tableRows = getQueryRows(\n await db.query(\n `SELECT MAX(seq) AS version FROM ${CHANGE_FEED_TABLE} WHERE table_name = ${p(1)}`,\n name,\n ),\n );\n const tableVersion = tableRows[0]?.version;\n if (tableVersion != null) {\n return toSeqNumber(tableVersion);\n }\n\n // No retained entry for this table — fall back to the global horizon so an\n // all-pruned (or never-written) table never reports a resettable low value\n // that could false-304 a stale client. 0 only when the feed is empty.\n const horizonRows = getQueryRows(\n await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`),\n );\n return toSeqNumber(horizonRows[0]?.horizon);\n}\n\nfunction toSeqNumber(value: unknown): number {\n // Postgres adapters may surface BIGINT aggregates as strings.\n const parsed = typeof value === 'number' ? value : Number(value ?? 0);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction rowToEntry(row: Record<string, unknown>): ChangeFeedEntry {\n return {\n seq: toSeqNumber(row.seq),\n table: String(row.table_name ?? ''),\n rowId: row.row_id == null ? null : String(row.row_id),\n operation: String(row.operation ?? 'update') as ChangeOperation,\n tenantId: row.tenant_id == null ? null : String(row.tenant_id),\n timestamp: normalizeTimestamp(row.created_at),\n };\n}\n\nfunction normalizeTimestamp(value: unknown): string {\n if (value instanceof Date) return value.toISOString();\n return String(value ?? '');\n}\n\n// ============================================================================\n// Retention / compaction\n// ============================================================================\n\n/**\n * Prune the change feed to bound its growth.\n *\n * Applies whichever bounds are provided (at least one is required):\n * - `maxRows`: keep only the newest N entries by sequence.\n * - `maxAgeMs`: drop entries older than the cutoff.\n *\n * Pruning deletes oldest-first, never renumbers surviving entries, and\n * **always retains the newest entry** (a non-empty feed is never emptied,\n * whatever the bounds say). That invariant anchors pruned-cursor detection:\n * retained sequences stay a contiguous run `[floor..horizon]`, so\n * {@link getChangesSince} can prove a cursor predates the retained window\n * (`resyncRequired`) — and a fully caught-up consumer keeps polling\n * normally even after everything older was pruned.\n *\n * Cursors within the retained window keep working. Schedule pruning (e.g.\n * via `@happyvertical/smrt-jobs`) with a retention window comfortably\n * larger than the slowest consumer's polling interval; consumers whose\n * cursor falls out of it are told to full-resync via `resyncRequired`.\n *\n * @returns The number of entries pruned — or, with `dryRun`, the number the\n * same bounds would prune (approximate under concurrent prunes).\n */\nexport async function pruneChangeFeed(\n db: DatabaseInterface,\n retention: ChangeFeedRetention,\n): Promise<{ pruned: number }> {\n const { maxAgeMs, maxRows, dryRun = false } = retention;\n if (maxAgeMs == null && maxRows == null) {\n throw new Error('pruneChangeFeed requires maxAgeMs and/or maxRows');\n }\n if (maxAgeMs != null && (!Number.isFinite(maxAgeMs) || maxAgeMs < 0)) {\n throw new Error(`pruneChangeFeed maxAgeMs must be >= 0, got ${maxAgeMs}`);\n }\n if (maxRows != null && (!Number.isFinite(maxRows) || maxRows < 0)) {\n throw new Error(`pruneChangeFeed maxRows must be >= 0, got ${maxRows}`);\n }\n\n const p = placeholders(db);\n\n // Snapshot the horizon once: both bounds prune strictly below it so the\n // newest entry always survives (see resync-detection contract above).\n const horizonRows = getQueryRows(\n await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`),\n );\n const horizon = toSeqNumber(horizonRows[0]?.horizon);\n if (horizon === 0) {\n return { pruned: 0 };\n }\n\n let pruned = 0;\n let prunedThrough = 0;\n\n if (maxRows != null) {\n const pruneThrough = Math.min(horizon - Math.floor(maxRows), horizon - 1);\n if (pruneThrough > 0) {\n prunedThrough = pruneThrough;\n pruned += await deleteCounted(\n db,\n `seq <= ${p(1)}`,\n [pruneThrough],\n dryRun,\n );\n }\n }\n\n if (maxAgeMs != null) {\n const cutoff = new Date(Date.now() - maxAgeMs).toISOString();\n // `seq > prunedThrough` excludes what the row bound already accounted for.\n // Redundant when the rows were really deleted, load-bearing under\n // `dryRun`, where nothing was — without it overlapping entries would be\n // counted by both bounds.\n pruned += await deleteCounted(\n db,\n `created_at < ${p(1)} AND seq < ${p(2)} AND seq > ${p(3)}`,\n [cutoff, horizon, prunedThrough],\n dryRun,\n );\n }\n\n return { pruned };\n}\n\nasync function deleteCounted(\n db: DatabaseInterface,\n condition: string,\n params: unknown[],\n dryRun = false,\n): Promise<number> {\n const countRows = getQueryRows(\n await db.query(\n `SELECT COUNT(*) AS total FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`,\n ...params,\n ),\n );\n const total = toSeqNumber(countRows[0]?.total);\n if (total > 0 && !dryRun) {\n await db.query(\n `DELETE FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`,\n ...params,\n );\n }\n return total;\n}\n\n// ============================================================================\n// Framework writer (GlobalInterceptors registration)\n// ============================================================================\n\nconst WAS_PERSISTED_KEY = '_smrtChangeFeedWasPersisted';\n\n/** Databases we already warned about after a failed feed append. */\nconst warnedAppendFailures = new Set<string>();\n\n/** Databases we already warned about after a failed signal publish (#1763). */\nconst warnedSignalPublishFailures = new Set<string>();\n\n/**\n * Register the change-feed writer with {@link GlobalInterceptors}.\n *\n * Called automatically during framework initialization (every\n * `SmrtClass.initialize()` passes through it), so applications never need\n * to call it directly; it is exported for tests and for re-registering\n * after `GlobalInterceptors.clear()`. Idempotent — a second call while the\n * writer is registered is a no-op.\n *\n * The writer observes the same hooks the reports scheduler and tenancy\n * interceptors use:\n * - `beforeSave` stashes whether the instance was already persisted (this\n * is what distinguishes `create` from `update` in the feed).\n * - `afterSave`/`afterDelete` append exactly one change entry per framework\n * save/delete. Framework-owned tables listed in\n * {@link CHANGE_FEED_EXCLUDED_TABLES} are skipped — the feed observes\n * application data, not framework bookkeeping (and never itself).\n *\n * Failure policy: appends run after the user's write succeeded and must not\n * un-succeed it — failures are logged (deduped per database) and swallowed.\n */\nexport function registerChangeFeedWriter(): void {\n if (\n GlobalInterceptors.getAll().some(\n (interceptor) => interceptor.name === CHANGE_FEED_INTERCEPTOR_NAME,\n )\n ) {\n return;\n }\n\n GlobalInterceptors.register({\n name: CHANGE_FEED_INTERCEPTOR_NAME,\n // Below tenancy (100) so tenantId auto-population precedes the stash;\n // above the reports refresh interceptor (-10) so a triggered refresh\n // can already observe the appended change entry.\n priority: 0,\n\n beforeSave(instance: SmrtObject, context: InterceptorContext): void {\n try {\n context.metadata = {\n ...context.metadata,\n [WAS_PERSISTED_KEY]: instance.isPersisted === true,\n };\n } catch {\n // Never let feed bookkeeping block a save.\n }\n },\n\n async afterSave(\n instance: SmrtObject,\n context: InterceptorContext,\n ): Promise<void> {\n const wasPersisted = context.metadata?.[WAS_PERSISTED_KEY] === true;\n await appendForInstance(instance, wasPersisted ? 'update' : 'create');\n },\n\n async afterDelete(instance: SmrtObject): Promise<void> {\n await appendForInstance(instance, 'delete');\n },\n });\n}\n\n/** Unregister the change-feed writer (test helper). */\nexport function unregisterChangeFeedWriter(): boolean {\n return GlobalInterceptors.unregister(CHANGE_FEED_INTERCEPTOR_NAME);\n}\n\nasync function appendForInstance(\n instance: SmrtObject,\n operation: ChangeOperation,\n): Promise<void> {\n let db: DatabaseInterface;\n let table: string;\n try {\n table = instance.tableName;\n // Framework-owned tables are bookkeeping, not client-syncable data —\n // recording them would let the feed observe (and re-observe) itself. The\n // test is an allowlist, not the `_smrt_` prefix: ~25 domain tables carry\n // that prefix and must be observed (issue #2376).\n if (!isChangeFeedObservableTable(table)) return;\n db = instance.db;\n } catch {\n // Not a fully initialized SmrtObject (e.g. plain-object doubles in\n // tests) — nothing to record.\n return;\n }\n\n try {\n const id = (instance as { id?: unknown }).id;\n const tenantId = (instance as unknown as Record<string, unknown>).tenantId;\n const rowId = typeof id === 'string' && id ? id : null;\n const rowTenantId =\n typeof tenantId === 'string' && tenantId ? tenantId : null;\n const seq = await appendChange(db, {\n table,\n rowId,\n operation,\n tenantId: rowTenantId,\n });\n\n // Publish a coarse live signal for the SSE `_events` route (#1763). This\n // runs only after the durable feed append SUCCEEDED (same try block, so a\n // failed append never emits a signal — \"no signal without a durable feed\n // row\"). Its own try/catch (distinct dedup key) keeps a signal-publish\n // problem from failing the user's write or masking the append's own\n // failure semantics above.\n try {\n publishChangeSignal(db, {\n table,\n operation,\n rowId,\n tenantId: rowTenantId,\n seq,\n });\n } catch (error) {\n warnSignalPublishFailureOnce(db, table, error);\n }\n } catch (error) {\n warnAppendFailureOnce(db, table, error);\n }\n}\n\nfunction warnAppendFailureOnce(\n db: DatabaseInterface,\n table: string,\n error: unknown,\n): void {\n try {\n const dbKey = resolveDbCacheKey(db);\n if (warnedAppendFailures.has(dbKey)) return;\n warnedAppendFailures.add(dbKey);\n logger.warn(\n `Change feed: failed to append a change entry for '${table}'. The ` +\n 'write itself succeeded; the feed is missing this change (further ' +\n 'failures for this database are suppressed). Consumers recover on ' +\n 'full resync.',\n { error: error instanceof Error ? error.message : String(error) },\n );\n } catch {\n // Logging must never propagate into the write path.\n }\n}\n\nfunction warnSignalPublishFailureOnce(\n db: DatabaseInterface,\n table: string,\n error: unknown,\n): void {\n try {\n const dbKey = resolveDbCacheKey(db);\n if (warnedSignalPublishFailures.has(dbKey)) return;\n warnedSignalPublishFailures.add(dbKey);\n logger.warn(\n `Change feed: failed to publish a live change signal for '${table}'. ` +\n 'The write and its durable feed row are unaffected; live SSE ' +\n 'subscribers miss this signal but recover via cursor catch-up ' +\n '(further failures for this database are suppressed).',\n { error: error instanceof Error ? error.message : String(error) },\n );\n } catch {\n // Logging must never propagate into the write path.\n }\n}\n\n/**\n * Reset the append-failure and signal-publish warning dedups (test helper).\n */\nexport function resetChangeFeedWarnings(): void {\n warnedAppendFailures.clear();\n warnedSignalPublishFailures.clear();\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"change-feed.js","names":[],"sources":["../src/change-feed.ts"],"sourcesContent":["/**\n * Adapter-agnostic change feed — the framework's change-observation spine\n * (issue #1758, parent PRD #1755).\n *\n * Every framework `save()`/`delete()` appends exactly one row to the\n * `_smrt_changes` system table (monotonic per-database sequence, table name,\n * row id, operation, tenant id, timestamp). Deletes are recorded as\n * tombstones (`operation: 'delete'`), distinguishable from updates. One read\n * interface — {@link getChangesSince} — returns changes after a cursor,\n * filterable by table and tenant, and serves three eventual consumers:\n * client delta pull, the SSE push channel, and the per-table version source\n * backing ETags.\n *\n * ## Cursor semantics (the precise guarantee)\n *\n * Sequences are allocated *inside* the append statement as\n * `COALESCE(MAX(seq), 0) + 1` over the feed table itself, with a retry on\n * primary-key conflict. `MAX(seq)` only observes committed rows, so a row\n * with sequence `N` can only be inserted while every row with sequence\n * `< N` is already committed (a conflicting in-flight allocation of the same\n * value blocks, then retries). Committed rows therefore always form a\n * contiguous run ending at `MAX(seq)` — the **committed horizon**. Sequence\n * order equals commit order; out-of-order commit visibility (the classic\n * MVCC race that makes native identity/serial columns unsafe as cursors\n * under concurrent writers) cannot occur.\n *\n * {@link getChangesSince} reads the committed horizon `H = MAX(seq)`, then\n * returns matching rows with `since < seq <= H` (bounded by `limit`), and a\n * `cursor` that is either `H` (page exhaustive) or the last returned `seq`\n * (page limited). Because no change can ever commit at or below an observed\n * horizon after it was observed, polling with returned cursors misses no\n * committed change and never returns the same change twice — under any\n * number of concurrent writers, identically on SQLite, Postgres and DuckDB.\n * This is the design reason the allocator is `MAX+1` rather than a native\n * AUTOINCREMENT/identity column: identity values are allocated before\n * commit, so a reader on Postgres could observe seq 101 while seq 100 is\n * still uncommitted and advance its cursor past it. (No shared\n * auto-increment mechanism exists in the system-table schema path either;\n * see `system/schema.ts`.)\n *\n * Contention note: appends serialize on the head of the log. Each append is\n * one small INSERT (issued from the write path *after* the user's row was\n * written), so the serialization window is one statement; conflicts resolve\n * with a bounded retry loop and are impossible on single-writer engines\n * (SQLite). PostgreSQL invokes that INSERT through the framework-owned\n * `_smrt_append_change` function so failure isolation remains one statement.\n *\n * ## Failure semantics\n *\n * A feed-write failure must never fail the user's write. The interceptor\n * wraps the append in a try/catch: on failure it logs a warning (deduped per\n * database) and continues. The trade-off is availability of the user's\n * write over completeness of the feed — consumers already need a\n * full-resync path for cursors older than the retention window, and the\n * same path covers a (rare) dropped feed row. PostgreSQL runs the INSERT in a\n * PL/pgSQL exception subtransaction and returns a caught SQLSTATE as data;\n * JavaScript only throws/logs after PostgreSQL has restored the caller's\n * transaction, so a swallowed append failure cannot surface later as 25P02.\n * The append still joins a caller-managed transaction on the same handle and\n * shares its fate (a rollback removes the change row with the data row).\n *\n * ## Known gaps (documented in the PRD)\n *\n * - Writes that bypass framework mutation paths (raw SQL) are invisible to\n * the feed — the same accepted gap as the #1499 collection cache.\n * {@link bumpChangeFeed} is the manual escape hatch: out-of-band writers\n * append a synthetic change row for the affected table.\n * - **Spurious `update` entries**: `SmrtObject.save()` has no dirty-check,\n * so a field-unchanged `.save()` still appends an `update` row. This is\n * by design — the writer observes writes, not diffs (it has no old-row\n * access), so the feed faithfully mirrors the write path. Diff-aware\n * paths (`getOrUpsert()`'s diff guard, the sync-apply endpoint's no-op\n * detection) short-circuit before `save()` and append nothing.\n * Subscribers must tolerate spurious entries; they are convergent — a\n * re-fetch returns identical data.\n *\n * ## Retention\n *\n * The log is append-only and grows with write volume. {@link pruneChangeFeed}\n * bounds it by age (`maxAgeMs`) and/or row count (`maxRows`); call it from a\n * scheduled job sized so the retention window comfortably exceeds the\n * slowest consumer's polling interval. Pruning deletes oldest-first and\n * always retains the newest entry, so retained sequences stay a contiguous\n * `[floor..horizon]` run — which is how {@link getChangesSince} *detects* a\n * consumer whose cursor predates the retained window and answers it with\n * `resyncRequired: true` plus a fresh resume cursor instead of silently\n * skipping the pruned changes.\n *\n * @see https://github.com/happyvertical/smrt/issues/1758\n * @packageDocumentation\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { publishChangeSignal } from './change-signals.js';\nimport { resolveDbCacheKey } from './collection-cache.js';\nimport { resolveDispatchTenantScope } from './dispatch/tenant-resolver.js';\nimport {\n isEmbeddedDatabase,\n withEmbeddedWriteQueue,\n} from './embedded-write-queue.js';\nimport { GlobalInterceptors, type InterceptorContext } from './interceptors.js';\nimport type { SmrtObject } from './object.js';\nimport { detectEngine } from './schema/ddl/index.js';\nimport { SYSTEM_TABLE_NAMES } from './schema/system-table-shapes.js';\nimport {\n CREATE_SMRT_CHANGES_TABLE,\n ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION,\n ENSURE_POSTGRES_CHANGE_FEED_SCHEMA,\n FRAMEWORK_OPERATIONAL_TABLES,\n POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY,\n POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME,\n REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION,\n RETIRED_SYSTEM_TABLES,\n} from './system/schema.js';\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 ...FRAMEWORK_OPERATIONAL_TABLES,\n ...RETIRED_SYSTEM_TABLES,\n]);\n\n/**\n * Whether framework writes to `tableName` are recorded in the change feed.\n *\n * Exported so tooling and tests can reason about feed coverage without\n * re-deriving the rule. See {@link CHANGE_FEED_EXCLUDED_TABLES}.\n */\nexport function isChangeFeedObservableTable(tableName: string): boolean {\n return Boolean(tableName) && !CHANGE_FEED_EXCLUDED_TABLES.has(tableName);\n}\n\n/** Interceptor name of the framework's change-feed writer. */\nexport const CHANGE_FEED_INTERCEPTOR_NAME = 'smrt-change-feed';\n\n/**\n * Change operations recorded in the feed. Deletes are tombstones —\n * consumers can distinguish \"row changed\" from \"row is gone\" without\n * consulting the source table.\n */\nexport type ChangeOperation = 'create' | 'update' | 'delete';\n\n/** One entry of the change feed. */\nexport interface ChangeFeedEntry {\n /** Strictly monotonic per-database sequence (the cursor dimension). */\n seq: number;\n /** Physical table the change happened in (STI children report the shared base table). */\n table: string;\n /**\n * Primary key of the changed row, or `null` for table-level synthetic\n * bumps recorded via {@link bumpChangeFeed} without a row id.\n */\n rowId: string | null;\n /** What happened. `'delete'` entries double as tombstones. */\n operation: ChangeOperation;\n /** Tenant the changed row belongs to, or `null` for global/non-tenant rows. */\n tenantId: string | null;\n /** ISO-8601 timestamp recorded when the change was appended. */\n timestamp: string;\n}\n\n/** Options for {@link getChangesSince}. */\nexport interface GetChangesOptions {\n /**\n * Cursor to read after. Only rows with `seq` strictly greater than `since`\n * are returned; pass a previously returned {@link ChangeFeedPage.cursor} to\n * poll.\n *\n * `0` reads from the start of the log only while it has not been pruned past\n * the beginning. Once retention has raised the retained floor above the\n * start, `since: 0` (like any cursor older than the retained window) can no\n * longer be served incrementally — the read returns\n * {@link ChangeFeedPage.resyncRequired} and the caller must do a full\n * resync before resuming from {@link ChangeFeedPage.resyncCursor}.\n */\n since: number;\n /** Restrict to these physical table names. Empty/omitted → all tables. */\n tables?: string[];\n /**\n * Tenant visibility filter:\n * - omitted/`undefined` → no tenant filter (all rows).\n * - `null` → only global rows (`tenant_id IS NULL`).\n * - `'<tenantId>'` → that tenant's rows **plus** global rows, matching the\n * DispatchBus read rule (`tenant_id = T OR tenant_id IS NULL`). A tenant\n * never sees another tenant's changes.\n */\n tenantId?: string | null;\n /**\n * Page size (default {@link DEFAULT_CHANGES_LIMIT}, capped at\n * {@link MAX_CHANGES_LIMIT}). When a page fills up, the returned cursor\n * stops at the last returned row so the next poll continues seamlessly.\n */\n limit?: number;\n}\n\n/** Result page of {@link getChangesSince}. */\nexport interface ChangeFeedPage {\n /** Matching changes ordered by ascending `seq`. */\n changes: ChangeFeedEntry[];\n /**\n * The next cursor. Monotonic: never lower than the `since` it was derived\n * from. Equal to the committed horizon when the page was exhaustive, or to\n * the last returned `seq` when the page hit `limit`. Feed the value back\n * as `since` to observe every later change exactly once.\n */\n cursor: number;\n /**\n * Present (and `true`) when the supplied cursor cannot be served\n * incrementally and the consumer must fall back to a full resync:\n *\n * - the cursor predates the retained window (entries at or below it were\n * pruned away — the changes between it and the retained floor are gone\n * for good), or\n * - the cursor is ahead of the committed horizon / unknown to this\n * database (a foreign or reset cursor).\n *\n * When set, `changes` is empty and `cursor` echoes `since` unchanged.\n * After its full data refetch, the consumer should resume polling from\n * {@link resyncCursor}, the committed horizon observed by this read.\n * Detection is computed on the **unfiltered** log: `tables`/`tenantId`\n * filters legitimately hide rows and never trigger (or mask) a resync\n * signal.\n */\n resyncRequired?: boolean;\n /**\n * Current committed horizon to use after handling a resync. Present with\n * {@link resyncRequired}; separated from `cursor` so old callers that rely\n * on `cursor` echoing the rejected value keep their monotonic-cursor\n * invariant.\n */\n resyncCursor?: number;\n}\n\n/** Input for {@link appendChange} / {@link bumpChangeFeed}. */\nexport interface AppendChangeInput {\n /** Physical table name the change refers to. */\n table: string;\n /** Changed row's primary key; `null`/omitted records a table-level change. */\n rowId?: string | null;\n /** Operation to record (default `'update'`). */\n operation?: ChangeOperation;\n /** Tenant the change belongs to (default `null` = global). */\n tenantId?: string | null;\n}\n\n/** Retention bounds for {@link pruneChangeFeed}. At least one is required. */\nexport interface ChangeFeedRetention {\n /** Prune entries older than this many milliseconds. */\n maxAgeMs?: number;\n /** Keep at most this many newest entries (by sequence). */\n maxRows?: number;\n /**\n * Count the entries the bounds select without deleting them (#2375).\n *\n * Lets the retention sweep and `smrt db:prune --dry-run` preview the exact\n * same predicate the real prune would execute.\n */\n dryRun?: boolean;\n}\n\n/** Default page size for {@link getChangesSince}. */\nexport const DEFAULT_CHANGES_LIMIT = 500;\n\n/** Hard cap on the page size for {@link getChangesSince}. */\nexport const MAX_CHANGES_LIMIT = 5_000;\n\n/**\n * Maximum append attempts under sequence contention. Conflicts only occur\n * with concurrent writers on MVCC engines and resolve as soon as the\n * blocking transaction commits, so a small bound is ample.\n */\nconst MAX_APPEND_ATTEMPTS = 20;\n\nconst VALID_OPERATIONS: ReadonlySet<string> = new Set([\n 'create',\n 'update',\n 'delete',\n]);\n\n// ============================================================================\n// Engine / SQL helpers (mirrors system/compatibility.ts conventions)\n// ============================================================================\n\ntype DatabaseWithConfig = DatabaseInterface & {\n config?: { type?: string; url?: string };\n type?: string;\n};\n\nfunction getEngine(\n db: DatabaseInterface,\n typeHint?: string,\n): ReturnType<typeof detectEngine> {\n const withConfig = db as DatabaseWithConfig;\n return detectEngine(\n db.url || withConfig.config?.url || '',\n typeHint || withConfig.type || withConfig.config?.type,\n );\n}\n\n/**\n * Positional placeholder factory: Postgres uses `$n`, SQLite/DuckDB use `?`.\n */\nfunction placeholders(db: DatabaseInterface): (index: number) => string {\n const engine = getEngine(db);\n return engine === 'postgres' ? (index) => `$${index}` : () => '?';\n}\n\nfunction getQueryRows(result: unknown): Record<string, unknown>[] {\n if (Array.isArray(result)) {\n return result as Record<string, unknown>[];\n }\n if (result && typeof result === 'object' && 'rows' in result) {\n const rows = (result as { rows?: unknown }).rows;\n if (Array.isArray(rows)) {\n return rows as Record<string, unknown>[];\n }\n }\n return [];\n}\n\nfunction isUniqueViolation(error: unknown): boolean {\n const signals: string[] = [];\n const pending: unknown[] = [error];\n const seen = new Set<object>();\n\n while (pending.length > 0 && seen.size < 5) {\n const candidate = pending.shift();\n if (typeof candidate === 'string') {\n signals.push(candidate);\n continue;\n }\n if (!candidate || typeof candidate !== 'object' || seen.has(candidate)) {\n continue;\n }\n seen.add(candidate);\n\n const shaped = candidate as {\n cause?: unknown;\n code?: unknown;\n context?: unknown;\n message?: unknown;\n };\n if (typeof shaped.message === 'string') signals.push(shaped.message);\n if (typeof shaped.code === 'string') signals.push(shaped.code);\n if (shaped.cause !== undefined) pending.push(shaped.cause);\n\n if (shaped.context && typeof shaped.context === 'object') {\n const originalError = (shaped.context as { originalError?: unknown })\n .originalError;\n if (originalError !== undefined) pending.push(originalError);\n }\n }\n\n const message = signals.join(', ');\n return (\n /\\b23505\\b/.test(message) ||\n /unique constraint/i.test(message) ||\n /duplicate key/i.test(message) ||\n /primary key constraint/i.test(message) ||\n /constraint error/i.test(message)\n );\n}\n\n/**\n * Ensure the `_smrt_changes` system table exists on a database handle that\n * may not have passed through framework initialization (e.g. a raw handle\n * given to the REST generator). Idempotent (`CREATE ... IF NOT EXISTS`) and\n * guarded to run once per handle. Databases initialized through the\n * framework already have the table via the system-table bootstrap.\n */\nconst ensuredHandles = new WeakSet<object>();\n\nasync function postgresChangeFeedAppendFunctionExists(\n db: DatabaseInterface,\n): Promise<boolean> {\n const rows = getQueryRows(\n await db.query(\n `SELECT to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}') AS function_name`,\n ),\n );\n return Boolean(rows[0]?.function_name);\n}\n\nasync function getPostgresChangeFeedSchemaState(\n db: DatabaseInterface,\n): Promise<{\n tableExists: boolean;\n functionExists: boolean;\n createdAtType: string | null;\n}> {\n const rows = getQueryRows(\n await db.query(\n `SELECT\n to_regclass('${CHANGE_FEED_TABLE}') AS table_name,\n to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}') AS function_name,\n (\n SELECT data_type\n FROM information_schema.columns\n WHERE table_schema = current_schema()\n AND table_name = '${CHANGE_FEED_TABLE}'\n AND column_name = 'created_at'\n ) AS created_at_type`,\n ),\n );\n return {\n tableExists: Boolean(rows[0]?.table_name),\n functionExists: Boolean(rows[0]?.function_name),\n createdAtType: rows[0]?.created_at_type\n ? String(rows[0].created_at_type)\n : null,\n };\n}\n\nfunction assertPostgresChangeFeedTimestampCurrent(\n state: Awaited<ReturnType<typeof getPostgresChangeFeedSchemaState>>,\n): void {\n if (state.createdAtType === 'timestamp without time zone') {\n throw new Error(\n 'Legacy _smrt_changes.created_at requires an explicit audited migratePostgresSystemTimestamps() call before change-feed initialization',\n );\n }\n}\n\n/**\n * Install/refresh the PostgreSQL exception-subtransaction append boundary.\n *\n * Framework bootstrap calls this while applying the system-schema version that\n * introduced the helper, so upgraded databases acquire it before the migration\n * is recorded. Raw-handle initialization passes `replaceExisting: false` so a\n * read route does not require function ownership when the installed helper is\n * already current. A missing helper is installed by one server-side statement\n * that locks and rechecks before DDL. Non-PostgreSQL adapters are a no-op.\n *\n * @internal\n */\nexport async function ensurePostgresChangeFeedAppendFunction(\n db: DatabaseInterface,\n options: {\n replaceExisting?: boolean;\n typeHint?: string;\n } = {},\n): Promise<void> {\n if (getEngine(db, options.typeHint) !== 'postgres') return;\n\n assertPostgresChangeFeedTimestampCurrent(\n await getPostgresChangeFeedSchemaState(db),\n );\n\n if (options.replaceExisting === false) {\n if (await postgresChangeFeedAppendFunctionExists(db)) return;\n await db.query(ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION);\n return;\n }\n\n await db.query(REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION);\n}\n\nexport async function ensureChangeFeedTable(\n db: DatabaseInterface,\n): Promise<void> {\n if (ensuredHandles.has(db)) return;\n if (getEngine(db) === 'postgres') {\n const state = await getPostgresChangeFeedSchemaState(db);\n assertPostgresChangeFeedTimestampCurrent(state);\n if (\n state.tableExists &&\n state.functionExists &&\n state.createdAtType === 'timestamp with time zone'\n ) {\n ensuredHandles.add(db);\n return;\n }\n await db.query(ENSURE_POSTGRES_CHANGE_FEED_SCHEMA);\n } else {\n const statements = CREATE_SMRT_CHANGES_TABLE.split(';')\n .map((statement) => statement.trim())\n .filter((statement) => statement.length > 0);\n for (const statement of statements) {\n await db.query(statement);\n }\n }\n ensuredHandles.add(db);\n}\n\n// ============================================================================\n// Append (writer primitive + manual bump escape hatch)\n// ============================================================================\n\n/**\n * Append one change entry with a database-allocated, strictly monotonic\n * sequence.\n *\n * The sequence is allocated inside the INSERT itself\n * (`COALESCE(MAX(seq), 0) + 1`) and retried on primary-key conflict, which\n * keeps committed sequences contiguous and makes commit order equal\n * sequence order — the property the cursor guarantee rests on (see the\n * module docs). Throws after {@link MAX_APPEND_ATTEMPTS} consecutive\n * conflicts or on any non-conflict database error; the framework's\n * interceptor catches and logs instead of failing the user's write.\n *\n * **PostgreSQL transaction safety (#2026).** The INSERT runs inside the\n * framework-owned `_smrt_append_change` PL/pgSQL function. Its exception\n * handler is a PostgreSQL subtransaction: a failed attempt is rolled back\n * before the function returns `{ error_code, error_message }`. This method\n * then throws in JavaScript, where the existing retry/swallow policy applies\n * without aborting a caller-managed transaction. Keeping isolation inside one\n * database statement also prevents concurrent work on the same transaction\n * handle from interleaving inside a manual SAVEPOINT scope.\n */\nexport async function appendChange(\n db: DatabaseInterface,\n input: AppendChangeInput,\n): Promise<number> {\n const table = input.table?.trim();\n if (!table) {\n throw new Error('appendChange requires a non-empty table name');\n }\n const operation = input.operation ?? 'update';\n if (!VALID_OPERATIONS.has(operation)) {\n throw new Error(\n `appendChange operation must be one of create/update/delete, got '${String(\n input.operation,\n )}'`,\n );\n }\n\n const engine = getEngine(db);\n const p = placeholders(db);\n // The INSERT yields the ACTUAL sequence it allocated in the SAME statement\n // (directly via RETURNING on portable engines, through the function on\n // PostgreSQL). A separate follow-up `SELECT MAX(seq)` is racy under concurrent\n // appends (a peer can commit a higher seq in between), which would hand two\n // distinct changes the same SSE `id` and let a client's `Last-Event-ID`\n // overshoot a change it never received. The allocator stays `MAX+1` under the\n // unique-PK retry, so committed sequences remain contiguous (the cursor\n // guarantee — see module docs).\n const sql =\n engine === 'postgres'\n ? `SELECT allocated_seq, error_code, error_message FROM ` +\n `${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME}(` +\n `${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)})`\n : `INSERT INTO ${CHANGE_FEED_TABLE} ` +\n '(seq, table_name, row_id, operation, tenant_id, created_at) ' +\n `SELECT COALESCE(MAX(seq), 0) + 1, ${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)} ` +\n `FROM ${CHANGE_FEED_TABLE} RETURNING seq`;\n const params = [\n table,\n input.rowId ?? null,\n operation,\n input.tenantId ?? null,\n new Date().toISOString(),\n ];\n\n for (let attempt = 1; attempt <= MAX_APPEND_ATTEMPTS; attempt++) {\n try {\n // The append is a root-connection write; on embedded engines it goes\n // through the per-database write queue so it can never overlap a\n // null-aware upsert's second-connection transaction (#2360 — the pair\n // livelocked into SQLITE_BUSY under concurrent NULL-tenant creates).\n const rows = getQueryRows(\n await withEmbeddedWriteQueue(db, isEmbeddedDatabase(db), () =>\n db.query(sql, ...params),\n ),\n );\n const row = rows[0];\n if (!row) {\n throw new Error('Change feed append returned no result row');\n }\n if (engine === 'postgres' && row.error_code != null) {\n const error = new Error(\n String(row.error_message || 'PostgreSQL change-feed append failed'),\n ) as Error & { code: string };\n error.code = String(row.error_code);\n throw error;\n }\n return toSeqNumber(engine === 'postgres' ? row.allocated_seq : row.seq);\n } catch (error) {\n if (!isUniqueViolation(error) || attempt === MAX_APPEND_ATTEMPTS) {\n throw error;\n }\n // Sequence head contention: another append won the value. Re-running\n // recomputes MAX(seq) against the now-committed head.\n }\n }\n\n // Unreachable: the loop returns a seq or throws on the final attempt. Present\n // so the function satisfies its `Promise<number>` contract structurally.\n throw new Error('appendChange exhausted retries without allocating a seq');\n}\n\n/**\n * Manual bump escape hatch for out-of-band writers.\n *\n * Framework mutation paths feed the log automatically, but raw SQL issued\n * outside `save()`/`delete()` is invisible to it (documented gap, shared\n * with the #1499 collection cache). Call this after such a write so feed\n * consumers observe the change. Omitting `rowId` records a table-level\n * change (`rowId: null`), which consumers should treat as \"anything in this\n * table may have changed\".\n *\n * @example\n * ```typescript\n * await db.query(`UPDATE products SET price = price * 1.1`);\n * await bumpChangeFeed(db, { table: 'products' });\n * ```\n */\nexport async function bumpChangeFeed(\n db: DatabaseInterface,\n input: AppendChangeInput,\n): Promise<void> {\n await ensureChangeFeedTable(db);\n await appendChange(db, input);\n}\n\n// ============================================================================\n// Read interface\n// ============================================================================\n\n/**\n * Read committed changes after a cursor.\n *\n * Returns every committed change with `since < seq <= cursor` that matches\n * the filters, ordered by ascending `seq`. The returned cursor is safe to\n * persist and poll with: committed sequences are contiguous (see module\n * docs), so nothing can commit at or below the observed horizon afterwards —\n * reads miss no committed change under concurrent writers and never return\n * the same change twice. When `since` is already at the horizon, returns an\n * empty page with `cursor: since`.\n *\n * ## Resync detection (pruned / foreign cursors)\n *\n * A cursor that cannot be served incrementally is flagged with\n * `resyncRequired: true` (empty `changes`, `cursor` echoed unchanged,\n * `resyncCursor` set to the current horizon) so pollers never go silently,\n * permanently stale:\n *\n * - **Pruned gap**: retained sequences always form a contiguous run\n * `[floor..horizon]` and {@link pruneChangeFeed} deletes oldest-first\n * while always retaining the newest entry, so `since < floor - 1` proves\n * changes between the cursor and the retained window were pruned away.\n * - **Foreign/reset cursor**: `since > horizon` (ahead of anything this\n * database ever allocated), including any `since > 0` against a feed\n * with no entries.\n *\n * Detection runs on the **unfiltered** log — `tables`/`tenantId` filters\n * legitimately hide rows and never trigger (or mask) the signal. A caught-up\n * consumer (`since === horizon`) is never asked to resync, even when\n * retention has pruned everything older.\n *\n * Filters (`tables`, `tenantId`) affect which rows are *returned*, never how\n * the cursor advances — an exhausted filtered page still advances to the\n * horizon so pollers do not rescan filtered-out rows.\n */\nexport async function getChangesSince(\n db: DatabaseInterface,\n options: GetChangesOptions,\n): Promise<ChangeFeedPage> {\n const { since } = options;\n if (!Number.isFinite(since) || since < 0) {\n throw new Error(\n `getChangesSince requires a non-negative numeric cursor, got '${String(since)}'`,\n );\n }\n const limit = Math.min(\n Math.max(Math.floor(options.limit ?? DEFAULT_CHANGES_LIMIT), 1),\n MAX_CHANGES_LIMIT,\n );\n\n const p = placeholders(db);\n\n // The committed horizon: every seq <= horizon is committed and immutable\n // (append-only + contiguous allocation), so the page below is stable even\n // though it runs as a separate statement. The floor bounds the retained\n // window for pruned-cursor detection; both are computed UNFILTERED so\n // table/tenant filters can neither trigger nor mask a resync signal.\n const boundsRows = getQueryRows(\n await db.query(\n `SELECT MIN(seq) AS floor, MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`,\n ),\n );\n const floor = toSeqNumber(boundsRows[0]?.floor);\n const horizon = toSeqNumber(boundsRows[0]?.horizon);\n\n if (horizon === 0) {\n // No entries at all. A zero cursor is simply \"no changes ever\"; any\n // other cursor came from a different database (or a reset feed) and\n // cannot be served incrementally.\n return since === 0\n ? { changes: [], cursor: 0 }\n : { changes: [], cursor: since, resyncRequired: true, resyncCursor: 0 };\n }\n\n if (since > horizon) {\n // Foreign or reset cursor — ahead of anything this database allocated.\n return {\n changes: [],\n cursor: since,\n resyncRequired: true,\n resyncCursor: horizon,\n };\n }\n\n if (since < floor - 1) {\n // Pruned gap — the changes with seq in (since, floor) are gone for good.\n return {\n changes: [],\n cursor: since,\n resyncRequired: true,\n resyncCursor: horizon,\n };\n }\n\n if (horizon === since) {\n return { changes: [], cursor: since };\n }\n\n const conditions: string[] = [];\n const params: unknown[] = [];\n let index = 0;\n const next = () => p(++index);\n\n conditions.push(`seq > ${next()}`);\n params.push(since);\n conditions.push(`seq <= ${next()}`);\n params.push(horizon);\n\n const tables = options.tables?.filter((table) => table.trim().length > 0);\n if (tables && tables.length > 0) {\n conditions.push(`table_name IN (${tables.map(() => next()).join(', ')})`);\n params.push(...tables);\n }\n\n if (options.tenantId === null) {\n conditions.push('tenant_id IS NULL');\n } else if (typeof options.tenantId === 'string') {\n conditions.push(`(tenant_id = ${next()} OR tenant_id IS NULL)`);\n params.push(options.tenantId);\n }\n\n const sql =\n 'SELECT seq, table_name, row_id, operation, tenant_id, created_at ' +\n `FROM ${CHANGE_FEED_TABLE} WHERE ${conditions.join(' AND ')} ` +\n `ORDER BY seq ASC LIMIT ${next()}`;\n params.push(limit);\n\n const rows = getQueryRows(await db.query(sql, ...params));\n const changes = rows.map(rowToEntry);\n\n // Page limited → resume after the last returned row. Page exhaustive →\n // everything up to the horizon (matching or filtered out) has been\n // observed, so advance all the way.\n const cursor =\n changes.length === limit ? changes[changes.length - 1].seq : horizon;\n\n return { changes, cursor };\n}\n\n/**\n * {@link getChangesSince} scoped by the active tenant context.\n *\n * Resolves the tenant through the same dependency-inversion hook the\n * DispatchBus uses ({@link resolveDispatchTenantScope}), so it works without\n * core depending on `@happyvertical/smrt-tenancy`:\n *\n * - Tenancy disabled (no resolver registered) → no tenant filter.\n * - Tenancy enabled with an active tenant `T` → `T`'s rows plus global rows.\n * - Tenancy enabled with **no** active tenant → global rows only\n * (**fail-closed**: a missing context never widens visibility to all\n * tenants).\n *\n * This is the read the generated `_changes` routes call after establishing\n * tenant context from the authenticated principal.\n */\nexport async function getTenantScopedChangesSince(\n db: DatabaseInterface,\n options: Omit<GetChangesOptions, 'tenantId'>,\n): Promise<ChangeFeedPage> {\n const scope = resolveDispatchTenantScope();\n if (!scope.enforced) {\n return getChangesSince(db, options);\n }\n return getChangesSince(db, { ...options, tenantId: scope.tenantId });\n}\n\n/**\n * The per-table change version — the ETag source for zero-query conditional\n * GETs (#1765).\n *\n * Returns `MAX(seq)` over the feed rows for `table`: a monotonic number that\n * advances on every framework write to that table (create/update/delete, and\n * writes through the sync-apply endpoint, which all `save()`/`delete()`).\n * Because sequences are the change feed's globally-monotonic cursor dimension\n * (allocated `MAX+1` at commit time, never a native identity — see the module\n * docs), the value is **replica-stable**: two processes reading the same\n * committed database compute the same version, with no per-process divergence.\n * That is what lets a generated read route derive an ETag that short-circuits a\n * matching `If-None-Match` into a `304` before the collection query runs — an\n * unchanged table costs one indexed `MAX(seq)` lookup (backed by\n * `idx_smrt_changes_table_seq`) to revalidate, not a table scan.\n *\n * ## Why the fallback to the global horizon (and not 0)\n *\n * A table with no *retained* feed entry falls back to the global horizon\n * (`MAX(seq)` across all tables), returning 0 only when the whole feed is\n * empty. Retention prunes oldest-first and always keeps the newest entry, so a\n * quiet table can lose all of its own entries while busier tables advance. If\n * such a table reported 0, a client that cached it while it was empty (version\n * 0) could, after a change→prune→change→prune cycle returned the lookup to 0,\n * be wrongly answered `304` against data that has since changed — a false-304.\n *\n * The horizon fallback closes that hole: any write to the table appends a new\n * sequence strictly greater than every previously-observed value (its own or\n * the horizon), so the version — and therefore the ETag — strictly exceeds any\n * value a client already holds, forcing a fresh `200`. The only cost is that a\n * table with no retained entries of its own revalidates whenever the global\n * horizon moves; a table with a retained entry uses its own stable `MAX(seq)`\n * and is unaffected by writes to sibling tables. A persistent per-table\n * high-water mark that survives pruning would remove even that cost; it is a\n * deliberate follow-up, out of scope for this slice.\n *\n * Idempotently ensures the feed table exists first, so it is safe to call from\n * a read route on a raw handle that has never been written to.\n */\nexport async function getTableVersion(\n db: DatabaseInterface,\n table: string,\n): Promise<number> {\n const name = table?.trim();\n if (!name) {\n throw new Error('getTableVersion requires a non-empty table name');\n }\n await ensureChangeFeedTable(db);\n\n const p = placeholders(db);\n const tableRows = getQueryRows(\n await db.query(\n `SELECT MAX(seq) AS version FROM ${CHANGE_FEED_TABLE} WHERE table_name = ${p(1)}`,\n name,\n ),\n );\n const tableVersion = tableRows[0]?.version;\n if (tableVersion != null) {\n return toSeqNumber(tableVersion);\n }\n\n // No retained entry for this table — fall back to the global horizon so an\n // all-pruned (or never-written) table never reports a resettable low value\n // that could false-304 a stale client. 0 only when the feed is empty.\n const horizonRows = getQueryRows(\n await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`),\n );\n return toSeqNumber(horizonRows[0]?.horizon);\n}\n\nfunction toSeqNumber(value: unknown): number {\n // 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 // Snapshot the horizon once: both bounds prune strictly below it so the\n // newest entry always survives (see resync-detection contract above).\n const horizonRows = getQueryRows(\n await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`),\n );\n const horizon = toSeqNumber(horizonRows[0]?.horizon);\n if (horizon === 0) {\n return { pruned: 0 };\n }\n\n let pruned = 0;\n let prunedThrough = 0;\n\n if (maxRows != null) {\n const pruneThrough = Math.min(horizon - Math.floor(maxRows), horizon - 1);\n if (pruneThrough > 0) {\n prunedThrough = pruneThrough;\n pruned += await deleteCounted(\n db,\n `seq <= ${p(1)}`,\n [pruneThrough],\n dryRun,\n );\n }\n }\n\n if (maxAgeMs != null) {\n const cutoff = new Date(Date.now() - maxAgeMs).toISOString();\n // `seq > prunedThrough` excludes what the row bound already accounted for.\n // Redundant when the rows were really deleted, load-bearing under\n // `dryRun`, where nothing was — without it overlapping entries would be\n // counted by both bounds.\n pruned += await deleteCounted(\n db,\n `created_at < ${p(1)} AND seq < ${p(2)} AND seq > ${p(3)}`,\n [cutoff, horizon, prunedThrough],\n dryRun,\n );\n }\n\n return { pruned };\n}\n\nasync function deleteCounted(\n db: DatabaseInterface,\n condition: string,\n params: unknown[],\n dryRun = false,\n): Promise<number> {\n const countRows = getQueryRows(\n await db.query(\n `SELECT COUNT(*) AS total FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`,\n ...params,\n ),\n );\n const total = toSeqNumber(countRows[0]?.total);\n if (total > 0 && !dryRun) {\n await db.query(\n `DELETE FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`,\n ...params,\n );\n }\n return total;\n}\n\n// ============================================================================\n// Framework writer (GlobalInterceptors registration)\n// ============================================================================\n\nconst WAS_PERSISTED_KEY = '_smrtChangeFeedWasPersisted';\n\n/** Databases we already warned about after a failed feed append. */\nconst warnedAppendFailures = new Set<string>();\n\n/** Databases we already warned about after a failed signal publish (#1763). */\nconst warnedSignalPublishFailures = new Set<string>();\n\n/**\n * Register the change-feed writer with {@link GlobalInterceptors}.\n *\n * Called automatically during framework initialization (every\n * `SmrtClass.initialize()` passes through it), so applications never need\n * to call it directly; it is exported for tests and for re-registering\n * after `GlobalInterceptors.clear()`. Idempotent — a second call while the\n * writer is registered is a no-op.\n *\n * The writer observes the same hooks the reports scheduler and tenancy\n * interceptors use:\n * - `beforeSave` stashes whether the instance was already persisted (this\n * is what distinguishes `create` from `update` in the feed).\n * - `afterSave`/`afterDelete` append exactly one change entry per framework\n * save/delete. Framework-owned tables listed in\n * {@link CHANGE_FEED_EXCLUDED_TABLES} are skipped — the feed observes\n * application data, not framework bookkeeping (and never itself).\n *\n * Failure policy: appends run after the user's write succeeded and must not\n * un-succeed it — failures are logged (deduped per database) and swallowed.\n */\nexport function registerChangeFeedWriter(): void {\n if (\n GlobalInterceptors.getAll().some(\n (interceptor) => interceptor.name === CHANGE_FEED_INTERCEPTOR_NAME,\n )\n ) {\n return;\n }\n\n GlobalInterceptors.register({\n name: CHANGE_FEED_INTERCEPTOR_NAME,\n // Below tenancy (100) so tenantId auto-population precedes the stash;\n // above the reports refresh interceptor (-10) so a triggered refresh\n // can already observe the appended change entry.\n priority: 0,\n\n beforeSave(instance: SmrtObject, context: InterceptorContext): void {\n try {\n context.metadata = {\n ...context.metadata,\n [WAS_PERSISTED_KEY]: instance.isPersisted === true,\n };\n } catch {\n // Never let feed bookkeeping block a save.\n }\n },\n\n async afterSave(\n instance: SmrtObject,\n context: InterceptorContext,\n ): Promise<void> {\n const wasPersisted = context.metadata?.[WAS_PERSISTED_KEY] === true;\n await appendForInstance(instance, wasPersisted ? 'update' : 'create');\n },\n\n async afterDelete(instance: SmrtObject): Promise<void> {\n await appendForInstance(instance, 'delete');\n },\n });\n}\n\n/** Unregister the change-feed writer (test helper). */\nexport function unregisterChangeFeedWriter(): boolean {\n return GlobalInterceptors.unregister(CHANGE_FEED_INTERCEPTOR_NAME);\n}\n\nasync function appendForInstance(\n instance: SmrtObject,\n operation: ChangeOperation,\n): Promise<void> {\n let db: DatabaseInterface;\n let table: string;\n try {\n table = instance.tableName;\n // Framework-owned tables are bookkeeping, not client-syncable data —\n // recording them would let the feed observe (and re-observe) itself. The\n // test is an allowlist, not the `_smrt_` prefix: ~25 domain tables carry\n // that prefix and must be observed (issue #2376).\n if (!isChangeFeedObservableTable(table)) return;\n db = instance.db;\n } catch {\n // Not a fully initialized SmrtObject (e.g. plain-object doubles in\n // tests) — nothing to record.\n return;\n }\n\n try {\n const id = (instance as { id?: unknown }).id;\n const tenantId = (instance as unknown as Record<string, unknown>).tenantId;\n const rowId = typeof id === 'string' && id ? id : null;\n const rowTenantId =\n typeof tenantId === 'string' && tenantId ? tenantId : null;\n const seq = await appendChange(db, {\n table,\n rowId,\n operation,\n tenantId: rowTenantId,\n });\n\n // Publish a coarse live signal for the SSE `_events` route (#1763). This\n // runs only after the durable feed append SUCCEEDED (same try block, so a\n // failed append never emits a signal — \"no signal without a durable feed\n // row\"). Its own try/catch (distinct dedup key) keeps a signal-publish\n // problem from failing the user's write or masking the append's own\n // failure semantics above.\n try {\n publishChangeSignal(db, {\n table,\n operation,\n rowId,\n tenantId: rowTenantId,\n seq,\n });\n } catch (error) {\n warnSignalPublishFailureOnce(db, table, error);\n }\n } catch (error) {\n warnAppendFailureOnce(db, table, error);\n }\n}\n\nfunction warnAppendFailureOnce(\n db: DatabaseInterface,\n table: string,\n error: unknown,\n): void {\n try {\n const dbKey = resolveDbCacheKey(db);\n if (warnedAppendFailures.has(dbKey)) return;\n warnedAppendFailures.add(dbKey);\n logger.warn(\n `Change feed: failed to append a change entry for '${table}'. The ` +\n 'write itself succeeded; the feed is missing this change (further ' +\n 'failures for this database are suppressed). Consumers recover on ' +\n 'full resync.',\n { error: error instanceof Error ? error.message : String(error) },\n );\n } catch {\n // Logging must never propagate into the write path.\n }\n}\n\nfunction warnSignalPublishFailureOnce(\n db: DatabaseInterface,\n table: string,\n error: unknown,\n): void {\n try {\n const dbKey = resolveDbCacheKey(db);\n if (warnedSignalPublishFailures.has(dbKey)) return;\n warnedSignalPublishFailures.add(dbKey);\n logger.warn(\n `Change feed: failed to publish a live change signal for '${table}'. ` +\n 'The write and its durable feed row are unaffected; live SSE ' +\n 'subscribers miss this signal but recover via cursor catch-up ' +\n '(further failures for this database are suppressed).',\n { error: error instanceof Error ? error.message : String(error) },\n );\n } catch {\n // Logging must never propagate into the write path.\n }\n}\n\n/**\n * Reset the append-failure and signal-publish warning dedups (test helper).\n */\nexport function resetChangeFeedWarnings(): void {\n warnedAppendFailures.clear();\n warnedSignalPublishFailures.clear();\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"change-signals.d.ts","sourceRoot":"","sources":["../src/change-signals.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8DG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"change-signals.d.ts","sourceRoot":"","sources":["../src/change-signals.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8DG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAU5D;;;;;GAKG;AACH,MAAM,MAAM,qBAAqB,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEnE;;GAEG;AACH,MAAM,WAAW,YAAY;IAE3B,6CAA6C;IAC7C,KAAK,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,SAAS,EAAE,qBAAqB,CAAC;IACjC,yEAAyE;IACzE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,oEAAoE;IACpE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB;;;OAGG;IACH,GAAG,EAAE,MAAM,CAAC;CACb;AAED,2EAA2E;AAC3E,MAAM,MAAM,oBAAoB,GAAG,CAAC,MAAM,EAAE,YAAY,KAAK,IAAI,CAAC;AAElE;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,wBAAwB,CAAC;AA4B3D;;;;;;;;;;;;;GAaG;AACH,wBAAgB,wBAAwB,CACtC,EAAE,EAAE,iBAAiB,EACrB,QAAQ,EAAE,oBAAoB,GAC7B,MAAM,IAAI,CA2BZ;AAED;;;;;;;;;GASG;AACH,wBAAgB,oCAAoC,CAClD,EAAE,EAAE,iBAAiB,EACrB,cAAc,EAAE,MAAM,GAAG,IAAI,GAC5B,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAuBrB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,EAAE,EAAE,iBAAiB,EACrB,MAAM,EAAE,YAAY,GACnB,IAAI,CAKN;AAwBD;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,EAAE,EAAE,iBAAiB,EACrB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,IAAI,CAAC,CAkBf;AA4ED;;;GAGG;AACH,wBAAgB,yBAAyB,IAAI,IAAI,CAOhD;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,IAAI,IAAI,CAIzC;AAED;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CAAC,EAAE,EAAE,iBAAiB,GAAG,MAAM,CAEzE"}
|
package/dist/change-signals.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PROCESS_ID, getNotifications, resolveDbCacheKey } from "./collection-cache.js";
|
|
2
|
+
import { toSafeInteger } from "./utils/safe-integer.js";
|
|
2
3
|
import { createLogger } from "@happyvertical/logger";
|
|
3
4
|
//#region src/change-signals.ts
|
|
4
5
|
/**
|
|
@@ -296,12 +297,18 @@ function toSignal(record) {
|
|
|
296
297
|
const { table, operation, rowId, tenantId, seq } = record;
|
|
297
298
|
if (typeof table !== "string" || !table) return void 0;
|
|
298
299
|
if (operation !== "create" && operation !== "update" && operation !== "delete") return;
|
|
300
|
+
let normalizedSeq;
|
|
301
|
+
try {
|
|
302
|
+
normalizedSeq = toSafeInteger(seq ?? 0, "Change-signal sequence");
|
|
303
|
+
} catch {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
299
306
|
return {
|
|
300
307
|
table,
|
|
301
308
|
operation,
|
|
302
309
|
rowId: typeof rowId === "string" ? rowId : null,
|
|
303
310
|
tenantId: typeof tenantId === "string" ? tenantId : null,
|
|
304
|
-
seq:
|
|
311
|
+
seq: normalizedSeq
|
|
305
312
|
};
|
|
306
313
|
}
|
|
307
314
|
function safeParse(value) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"change-signals.js","names":[],"sources":["../src/change-signals.ts"],"sourcesContent":["/**\n * Change-signal bus — the live push spine for the generated `_events` SSE\n * route (issue #1763, parent PRD #1755).\n *\n * The change feed (#1758) is a durable, cursor-addressable log; this bus is\n * its ephemeral companion. Every framework `save()`/`delete()` that appends a\n * feed row also publishes a coarse {@link ChangeSignal} here, which fans out\n * synchronously to in-process subscribers (the SSE controllers of connected\n * `_events` clients) and, when the database adapter exposes a notification\n * capability, to peer replicas over the same channel. Absence of that\n * capability degrades gracefully — no cross-replica push, never an error, and\n * it never blocks the write.\n *\n * ## What a signal carries (and deliberately does not)\n *\n * A signal is `{ table, operation, rowId, tenantId, seq }` — never any row\n * payload. Authorization stays entirely on the read path: a subscriber learns\n * *that* something changed and its cursor (`seq`), then re-reads through the\n * authorized collection routes to catch up. This is why the bus can broadcast\n * a tenant's writes to peer replicas without leaking data across a trust\n * boundary. The `seq` is the same monotonic cursor dimension the change feed\n * allocates, so a reconnecting client can resume via `getChangesSince`.\n *\n * ## Delivery model\n *\n * - Local delivery is a synchronous fan-out ({@link deliverLocally}) into each\n * listener callback, wrapped in a try/catch **per listener** so one throwing\n * listener (e.g. a closed SSE controller's `enqueue`) never blocks the\n * others. There is no per-subscriber queue — backpressure is delegated to\n * each platform `ReadableStream`.\n * - Cross-replica delivery reuses the same {@link deliverLocally} helper on\n * receipt, so locally-published and peer-received signals travel one code\n * path. Notifications this process published are skipped by `source` id\n * (echo-avoidance), exactly as the collection cache does.\n *\n * This mirrors `collection-cache.ts`'s notify/listen structure; study that\n * module for the shared cross-process conventions (`resolveDbCacheKey`,\n * `getNotifications`, the lazy listener and finally-retract).\n *\n * ## Known gaps\n *\n * - **Subscriber cap lives at the route boundary**: the bus tracks subscribers,\n * but generated `_events` routes decide whether to reject a new connection\n * before opening a stream (#1860).\n * - **Raw-SQL writes are invisible**: signals originate from the framework\n * write path (same accepted gap as the #1758 feed and #1498 cache). A\n * `bumpChangeFeed` escape-hatch write appends a feed row but does not\n * publish a signal.\n * - **Caller-managed transactions are best-effort**: the append + signal fire\n * from `afterSave`/`afterDelete`, i.e. *before* a caller-wrapped transaction\n * commits (the autocommit default path — save()/delete() as independent\n * statements — is exact). Inside such a transaction, a signal may fire for a\n * change that a later rollback undoes, and the rolled-back seq is then reused\n * by the next append — so a client trusting a pre-commit `Last-Event-ID`\n * could skip the reuse via catch-up. This inherits the change feed's\n * documented transaction caveat (see `change-feed.ts`); no generic\n * post-commit hook exists to close it. Clients reconcile via full catch-up /\n * resync, so convergence still holds — live delivery is just best-effort for\n * transaction-wrapped writes.\n *\n * @see https://github.com/happyvertical/smrt/issues/1763\n * @packageDocumentation\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport {\n getNotifications,\n PROCESS_ID,\n resolveDbCacheKey,\n} from './collection-cache.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Operation carried by a change signal. Kept as a local string union — this\n * module deliberately does NOT import `./change-feed.js` (which imports this\n * one via the writer) so there is no import cycle. It must stay in sync with\n * `ChangeOperation` in `change-feed.ts`.\n */\nexport type ChangeSignalOperation = 'create' | 'update' | 'delete';\n\n/**\n * A coarse change notification. Carries no row payload by design.\n */\nexport interface ChangeSignal {\n // NEVER add row-payload fields — #1763 AC: authorization stays on the read path\n /** Physical table the change happened in. */\n table: string;\n /** What happened (`'delete'` doubles as a tombstone signal). */\n operation: ChangeSignalOperation;\n /** Primary key of the changed row, or `null` for table-level changes. */\n rowId: string | null;\n /** Tenant the changed row belongs to, or `null` for global rows. */\n tenantId: string | null;\n /**\n * The change feed sequence allocated for this change — the cursor a\n * reconnecting client resumes from via `getChangesSince`.\n */\n seq: number;\n}\n\n/** A subscriber invoked synchronously for every locally-visible signal. */\nexport type ChangeSignalListener = (signal: ChangeSignal) => void;\n\n/**\n * Notification channel for cross-replica change-signal broadcasts. A distinct\n * channel from the collection cache's — the two buses carry different payloads\n * and evolve independently.\n */\nexport const CHANGE_SIGNAL_CHANNEL = 'smrt_change_signals';\n\n/**\n * dbKey → local listeners. Keyed via `resolveDbCacheKey` so `:memory:` and\n * URL-less handles are scoped per instance (never cross-deliver between two\n * independent in-memory databases).\n */\nconst localListeners = new Map<string, Set<ChangeSignalListener>>();\n\n/**\n * dbKey -> pending subscriber slots claimed by `_events` before a stream has\n * reached its `start()` callback. This closes the check-then-subscribe race for\n * concurrent connection opens: capacity considers active listeners plus these\n * in-flight claims.\n */\nconst reservedListenerSlots = new Map<string, number>();\n\ninterface ListenerHandle {\n iterator: AsyncIterator<unknown> | null;\n stopped: boolean;\n}\n\n/** dbKey → background cross-replica listener handle. */\nconst crossReplicaListeners = new Map<string, ListenerHandle>();\n\n/** dbKeys we already warned about for a missing notification capability. */\nconst warnedNoNotifications = new Set<string>();\n\n/**\n * Subscribe to change signals for a database.\n *\n * Registers `listener` for the database's signal scope and, on the first\n * subscriber for that scope, lazily starts the cross-replica listener (a no-op\n * when the adapter has no notification capability). Returns an unsubscribe\n * function that removes the listener and, when the scope's last subscriber\n * leaves, retracts the cross-replica listener so its refcount reaches 0.\n *\n * Delivery is synchronous: `listener` is invoked from the write path (or the\n * cross-replica loop) inside a per-listener try/catch, so it must not assume\n * an active request or tenant context — capture what it needs at subscribe\n * time.\n */\nexport function subscribeToChangeSignals(\n db: DatabaseInterface,\n listener: ChangeSignalListener,\n): () => void {\n const dbKey = resolveDbCacheKey(db);\n\n let set = localListeners.get(dbKey);\n if (!set) {\n set = new Set();\n localListeners.set(dbKey, set);\n }\n set.add(listener);\n\n // Lazily start the cross-replica listener on first interest for this scope.\n ensureChangeSignalListener(db);\n\n let unsubscribed = false;\n return () => {\n if (unsubscribed) return;\n unsubscribed = true;\n const current = localListeners.get(dbKey);\n if (!current) return;\n current.delete(listener);\n if (current.size === 0) {\n localListeners.delete(dbKey);\n // Last local subscriber gone — retract the cross-replica listener so its\n // refcount reaches 0 (mirrors collection-cache's finally-retract).\n retractChangeSignalListener(dbKey);\n }\n };\n}\n\n/**\n * Atomically reserve one local subscriber slot for a database scope.\n *\n * The `_events` route calls this before returning a streaming response so\n * concurrent opens cannot all pass a stale count and exceed the configured cap.\n * The returned release function is idempotent; callers must release it when the\n * stream either converts the reservation into a real subscription or tears down\n * before subscribing. `maxSubscribers === null` means unlimited and returns a\n * no-op reservation.\n */\nexport function tryReserveChangeSignalSubscriberSlot(\n db: DatabaseInterface,\n maxSubscribers: number | null,\n): (() => void) | null {\n if (maxSubscribers === null) {\n return () => {};\n }\n\n const dbKey = resolveDbCacheKey(db);\n if (changeSignalSubscriberCountForKey(dbKey) >= maxSubscribers) {\n return null;\n }\n\n reservedListenerSlots.set(dbKey, (reservedListenerSlots.get(dbKey) ?? 0) + 1);\n\n let released = false;\n return () => {\n if (released) return;\n released = true;\n const current = reservedListenerSlots.get(dbKey) ?? 0;\n if (current <= 1) {\n reservedListenerSlots.delete(dbKey);\n } else {\n reservedListenerSlots.set(dbKey, current - 1);\n }\n };\n}\n\n/**\n * Publish a change signal: synchronous local fan-out, then fire-and-forget\n * cross-replica broadcast. Never throws to the caller — a signal problem must\n * never fail the user's write.\n */\nexport function publishChangeSignal(\n db: DatabaseInterface,\n signal: ChangeSignal,\n): void {\n const dbKey = resolveDbCacheKey(db);\n deliverLocally(dbKey, signal);\n // Fire-and-forget: broadcast failures are swallowed inside broadcast.\n void broadcastChangeSignal(db, signal);\n}\n\n/**\n * Synchronous local fan-out to every subscriber for a scope. Each listener is\n * wrapped in its own try/catch so one throwing listener (a closed SSE\n * controller) never blocks the rest. Locally-published and peer-received\n * signals both flow through here — the single delivery path.\n */\nfunction deliverLocally(dbKey: string, signal: ChangeSignal): void {\n const set = localListeners.get(dbKey);\n if (!set || set.size === 0) return;\n // Snapshot so a listener that unsubscribes during delivery can't mutate the\n // set mid-iteration.\n for (const listener of [...set]) {\n try {\n listener(signal);\n } catch (error) {\n logger.warn('Change signal: a subscriber threw during local delivery', {\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n}\n\n/**\n * Broadcast a signal to peer replicas over the adapter's notification\n * capability. Resolves normally (never throws) whether or not a capability\n * exists — a missing capability warns once per scope and is not an error.\n *\n * Fire-and-forget from the write path: a broadcast failure must never fail the\n * write that triggered it.\n */\nexport async function broadcastChangeSignal(\n db: DatabaseInterface,\n signal: ChangeSignal,\n): Promise<void> {\n const notifications = getNotifications(db);\n const dbKey = resolveDbCacheKey(db);\n if (!notifications) {\n warnOnceNoNotifications(dbKey);\n return;\n }\n try {\n await notifications.notify(CHANGE_SIGNAL_CHANNEL, {\n ...signal,\n source: PROCESS_ID,\n });\n } catch (error) {\n logger.warn(\n `Change signal: failed to broadcast a signal for '${signal.table}'`,\n { error: error instanceof Error ? error.message : String(error) },\n );\n }\n}\n\n/**\n * Ensure a background listener consumes cross-replica broadcasts for this\n * database and delivers them locally. Started lazily by the first subscriber\n * for a scope; a no-op (with a one-time warning) when the adapter exposes no\n * notification capability. Notifications this process published are skipped by\n * `source` id (echo-avoidance): the local fan-out already delivered them.\n */\nfunction ensureChangeSignalListener(db: DatabaseInterface): void {\n const dbKey = resolveDbCacheKey(db);\n if (crossReplicaListeners.has(dbKey)) return;\n\n const notifications = getNotifications(db);\n if (!notifications) {\n warnOnceNoNotifications(dbKey);\n return;\n }\n\n const handle: ListenerHandle = { iterator: null, stopped: false };\n crossReplicaListeners.set(dbKey, handle);\n\n void (async () => {\n try {\n const iterable = notifications.listen(CHANGE_SIGNAL_CHANNEL);\n const iterator = iterable[Symbol.asyncIterator]();\n handle.iterator = iterator;\n\n while (!handle.stopped) {\n const { value, done } = await iterator.next();\n if (done || handle.stopped) break;\n\n const notification = value as { payload?: unknown };\n const payload: unknown =\n typeof notification?.payload === 'string'\n ? safeParse(notification.payload)\n : notification?.payload;\n\n if (!payload || typeof payload !== 'object') continue;\n const record = payload as Record<string, unknown>;\n // Echo-avoidance: skip signals this process published (already\n // delivered locally on the write path).\n if (record.source === PROCESS_ID) continue;\n\n const signal = toSignal(record);\n if (!signal) continue;\n deliverLocally(dbKey, signal);\n }\n } catch (error) {\n if (!handle.stopped) {\n logger.warn(\n 'Change signal: cross-replica listener terminated unexpectedly; ' +\n 'peer signals are no longer delivered for this database (local ' +\n 'delivery and cursor catch-up still work)',\n { error: error instanceof Error ? error.message : String(error) },\n );\n }\n } finally {\n // Only retract our own handle — a concurrent restart may have installed\n // a replacement; deleting unconditionally would orphan it.\n if (crossReplicaListeners.get(dbKey) === handle) {\n crossReplicaListeners.delete(dbKey);\n }\n }\n })();\n}\n\n/** Retract the cross-replica listener for a scope (last subscriber left). */\nfunction retractChangeSignalListener(dbKey: string): void {\n const handle = crossReplicaListeners.get(dbKey);\n if (!handle) return;\n handle.stopped = true;\n void handle.iterator?.return?.(undefined);\n crossReplicaListeners.delete(dbKey);\n}\n\n/**\n * Stop all cross-replica change-signal listeners. Used by tests and during\n * shutdown; safe to call when none are active.\n */\nexport function stopChangeSignalListeners(): void {\n for (const handle of crossReplicaListeners.values()) {\n handle.stopped = true;\n void handle.iterator?.return?.(undefined);\n }\n crossReplicaListeners.clear();\n warnedNoNotifications.clear();\n}\n\n/**\n * Clear all local subscribers, stop cross-replica listeners, and reset the\n * no-capability warning dedup. Call in test setup for isolation between files.\n */\nexport function resetChangeSignals(): void {\n localListeners.clear();\n reservedListenerSlots.clear();\n stopChangeSignalListeners();\n}\n\n/**\n * Number of active or reserved local subscribers for a database scope.\n *\n * Used by the `_events` route boundary to enforce the per-process subscriber\n * cap (#1860), and by integration tests to assert teardown — a leaked SSE\n * subscription or stale reservation would keep this above 0 after a client\n * disconnects.\n */\nexport function changeSignalSubscriberCount(db: DatabaseInterface): number {\n return changeSignalSubscriberCountForKey(resolveDbCacheKey(db));\n}\n\nfunction changeSignalSubscriberCountForKey(dbKey: string): number {\n return (\n (localListeners.get(dbKey)?.size ?? 0) +\n (reservedListenerSlots.get(dbKey) ?? 0)\n );\n}\n\nfunction warnOnceNoNotifications(dbKey: string): void {\n if (warnedNoNotifications.has(dbKey)) return;\n warnedNoNotifications.add(dbKey);\n logger.warn(\n 'Change signal: the database adapter exposes no notification capability, ' +\n 'so change signals are delivered in-process only. Cross-replica live ' +\n 'updates require an adapter with notifications (e.g. Postgres ' +\n 'LISTEN/NOTIFY); subscribers on other replicas fall back to cursor ' +\n 'polling.',\n );\n}\n\n/**\n * Coerce a received notification payload into a {@link ChangeSignal}, or\n * `undefined` if it is malformed. Guards the cross-replica path against\n * garbage on the channel.\n */\nfunction toSignal(record: Record<string, unknown>): ChangeSignal | undefined {\n const { table, operation, rowId, tenantId, seq } = record;\n if (typeof table !== 'string' || !table) return undefined;\n if (\n operation !== 'create' &&\n operation !== 'update' &&\n operation !== 'delete'\n ) {\n return undefined;\n }\n return {\n table,\n operation,\n rowId: typeof rowId === 'string' ? rowId : null,\n tenantId: typeof tenantId === 'string' ? tenantId : null,\n seq: typeof seq === 'number' ? seq : Number(seq ?? 0) || 0,\n };\n}\n\nfunction safeParse(value: string): unknown {\n try {\n return JSON.parse(value);\n } catch {\n return undefined;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"change-signals.js","names":[],"sources":["../src/change-signals.ts"],"sourcesContent":["/**\n * Change-signal bus — the live push spine for the generated `_events` SSE\n * route (issue #1763, parent PRD #1755).\n *\n * The change feed (#1758) is a durable, cursor-addressable log; this bus is\n * its ephemeral companion. Every framework `save()`/`delete()` that appends a\n * feed row also publishes a coarse {@link ChangeSignal} here, which fans out\n * synchronously to in-process subscribers (the SSE controllers of connected\n * `_events` clients) and, when the database adapter exposes a notification\n * capability, to peer replicas over the same channel. Absence of that\n * capability degrades gracefully — no cross-replica push, never an error, and\n * it never blocks the write.\n *\n * ## What a signal carries (and deliberately does not)\n *\n * A signal is `{ table, operation, rowId, tenantId, seq }` — never any row\n * payload. Authorization stays entirely on the read path: a subscriber learns\n * *that* something changed and its cursor (`seq`), then re-reads through the\n * authorized collection routes to catch up. This is why the bus can broadcast\n * a tenant's writes to peer replicas without leaking data across a trust\n * boundary. The `seq` is the same monotonic cursor dimension the change feed\n * allocates, so a reconnecting client can resume via `getChangesSince`.\n *\n * ## Delivery model\n *\n * - Local delivery is a synchronous fan-out ({@link deliverLocally}) into each\n * listener callback, wrapped in a try/catch **per listener** so one throwing\n * listener (e.g. a closed SSE controller's `enqueue`) never blocks the\n * others. There is no per-subscriber queue — backpressure is delegated to\n * each platform `ReadableStream`.\n * - Cross-replica delivery reuses the same {@link deliverLocally} helper on\n * receipt, so locally-published and peer-received signals travel one code\n * path. Notifications this process published are skipped by `source` id\n * (echo-avoidance), exactly as the collection cache does.\n *\n * This mirrors `collection-cache.ts`'s notify/listen structure; study that\n * module for the shared cross-process conventions (`resolveDbCacheKey`,\n * `getNotifications`, the lazy listener and finally-retract).\n *\n * ## Known gaps\n *\n * - **Subscriber cap lives at the route boundary**: the bus tracks subscribers,\n * but generated `_events` routes decide whether to reject a new connection\n * before opening a stream (#1860).\n * - **Raw-SQL writes are invisible**: signals originate from the framework\n * write path (same accepted gap as the #1758 feed and #1498 cache). A\n * `bumpChangeFeed` escape-hatch write appends a feed row but does not\n * publish a signal.\n * - **Caller-managed transactions are best-effort**: the append + signal fire\n * from `afterSave`/`afterDelete`, i.e. *before* a caller-wrapped transaction\n * commits (the autocommit default path — save()/delete() as independent\n * statements — is exact). Inside such a transaction, a signal may fire for a\n * change that a later rollback undoes, and the rolled-back seq is then reused\n * by the next append — so a client trusting a pre-commit `Last-Event-ID`\n * could skip the reuse via catch-up. This inherits the change feed's\n * documented transaction caveat (see `change-feed.ts`); no generic\n * post-commit hook exists to close it. Clients reconcile via full catch-up /\n * resync, so convergence still holds — live delivery is just best-effort for\n * transaction-wrapped writes.\n *\n * @see https://github.com/happyvertical/smrt/issues/1763\n * @packageDocumentation\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport {\n getNotifications,\n PROCESS_ID,\n resolveDbCacheKey,\n} from './collection-cache.js';\nimport { toSafeInteger } from './utils/safe-integer.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Operation carried by a change signal. Kept as a local string union — this\n * module deliberately does NOT import `./change-feed.js` (which imports this\n * one via the writer) so there is no import cycle. It must stay in sync with\n * `ChangeOperation` in `change-feed.ts`.\n */\nexport type ChangeSignalOperation = 'create' | 'update' | 'delete';\n\n/**\n * A coarse change notification. Carries no row payload by design.\n */\nexport interface ChangeSignal {\n // NEVER add row-payload fields — #1763 AC: authorization stays on the read path\n /** Physical table the change happened in. */\n table: string;\n /** What happened (`'delete'` doubles as a tombstone signal). */\n operation: ChangeSignalOperation;\n /** Primary key of the changed row, or `null` for table-level changes. */\n rowId: string | null;\n /** Tenant the changed row belongs to, or `null` for global rows. */\n tenantId: string | null;\n /**\n * The change feed sequence allocated for this change — the cursor a\n * reconnecting client resumes from via `getChangesSince`.\n */\n seq: number;\n}\n\n/** A subscriber invoked synchronously for every locally-visible signal. */\nexport type ChangeSignalListener = (signal: ChangeSignal) => void;\n\n/**\n * Notification channel for cross-replica change-signal broadcasts. A distinct\n * channel from the collection cache's — the two buses carry different payloads\n * and evolve independently.\n */\nexport const CHANGE_SIGNAL_CHANNEL = 'smrt_change_signals';\n\n/**\n * dbKey → local listeners. Keyed via `resolveDbCacheKey` so `:memory:` and\n * URL-less handles are scoped per instance (never cross-deliver between two\n * independent in-memory databases).\n */\nconst localListeners = new Map<string, Set<ChangeSignalListener>>();\n\n/**\n * dbKey -> pending subscriber slots claimed by `_events` before a stream has\n * reached its `start()` callback. This closes the check-then-subscribe race for\n * concurrent connection opens: capacity considers active listeners plus these\n * in-flight claims.\n */\nconst reservedListenerSlots = new Map<string, number>();\n\ninterface ListenerHandle {\n iterator: AsyncIterator<unknown> | null;\n stopped: boolean;\n}\n\n/** dbKey → background cross-replica listener handle. */\nconst crossReplicaListeners = new Map<string, ListenerHandle>();\n\n/** dbKeys we already warned about for a missing notification capability. */\nconst warnedNoNotifications = new Set<string>();\n\n/**\n * Subscribe to change signals for a database.\n *\n * Registers `listener` for the database's signal scope and, on the first\n * subscriber for that scope, lazily starts the cross-replica listener (a no-op\n * when the adapter has no notification capability). Returns an unsubscribe\n * function that removes the listener and, when the scope's last subscriber\n * leaves, retracts the cross-replica listener so its refcount reaches 0.\n *\n * Delivery is synchronous: `listener` is invoked from the write path (or the\n * cross-replica loop) inside a per-listener try/catch, so it must not assume\n * an active request or tenant context — capture what it needs at subscribe\n * time.\n */\nexport function subscribeToChangeSignals(\n db: DatabaseInterface,\n listener: ChangeSignalListener,\n): () => void {\n const dbKey = resolveDbCacheKey(db);\n\n let set = localListeners.get(dbKey);\n if (!set) {\n set = new Set();\n localListeners.set(dbKey, set);\n }\n set.add(listener);\n\n // Lazily start the cross-replica listener on first interest for this scope.\n ensureChangeSignalListener(db);\n\n let unsubscribed = false;\n return () => {\n if (unsubscribed) return;\n unsubscribed = true;\n const current = localListeners.get(dbKey);\n if (!current) return;\n current.delete(listener);\n if (current.size === 0) {\n localListeners.delete(dbKey);\n // Last local subscriber gone — retract the cross-replica listener so its\n // refcount reaches 0 (mirrors collection-cache's finally-retract).\n retractChangeSignalListener(dbKey);\n }\n };\n}\n\n/**\n * Atomically reserve one local subscriber slot for a database scope.\n *\n * The `_events` route calls this before returning a streaming response so\n * concurrent opens cannot all pass a stale count and exceed the configured cap.\n * The returned release function is idempotent; callers must release it when the\n * stream either converts the reservation into a real subscription or tears down\n * before subscribing. `maxSubscribers === null` means unlimited and returns a\n * no-op reservation.\n */\nexport function tryReserveChangeSignalSubscriberSlot(\n db: DatabaseInterface,\n maxSubscribers: number | null,\n): (() => void) | null {\n if (maxSubscribers === null) {\n return () => {};\n }\n\n const dbKey = resolveDbCacheKey(db);\n if (changeSignalSubscriberCountForKey(dbKey) >= maxSubscribers) {\n return null;\n }\n\n reservedListenerSlots.set(dbKey, (reservedListenerSlots.get(dbKey) ?? 0) + 1);\n\n let released = false;\n return () => {\n if (released) return;\n released = true;\n const current = reservedListenerSlots.get(dbKey) ?? 0;\n if (current <= 1) {\n reservedListenerSlots.delete(dbKey);\n } else {\n reservedListenerSlots.set(dbKey, current - 1);\n }\n };\n}\n\n/**\n * Publish a change signal: synchronous local fan-out, then fire-and-forget\n * cross-replica broadcast. Never throws to the caller — a signal problem must\n * never fail the user's write.\n */\nexport function publishChangeSignal(\n db: DatabaseInterface,\n signal: ChangeSignal,\n): void {\n const dbKey = resolveDbCacheKey(db);\n deliverLocally(dbKey, signal);\n // Fire-and-forget: broadcast failures are swallowed inside broadcast.\n void broadcastChangeSignal(db, signal);\n}\n\n/**\n * Synchronous local fan-out to every subscriber for a scope. Each listener is\n * wrapped in its own try/catch so one throwing listener (a closed SSE\n * controller) never blocks the rest. Locally-published and peer-received\n * signals both flow through here — the single delivery path.\n */\nfunction deliverLocally(dbKey: string, signal: ChangeSignal): void {\n const set = localListeners.get(dbKey);\n if (!set || set.size === 0) return;\n // Snapshot so a listener that unsubscribes during delivery can't mutate the\n // set mid-iteration.\n for (const listener of [...set]) {\n try {\n listener(signal);\n } catch (error) {\n logger.warn('Change signal: a subscriber threw during local delivery', {\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n}\n\n/**\n * Broadcast a signal to peer replicas over the adapter's notification\n * capability. Resolves normally (never throws) whether or not a capability\n * exists — a missing capability warns once per scope and is not an error.\n *\n * Fire-and-forget from the write path: a broadcast failure must never fail the\n * write that triggered it.\n */\nexport async function broadcastChangeSignal(\n db: DatabaseInterface,\n signal: ChangeSignal,\n): Promise<void> {\n const notifications = getNotifications(db);\n const dbKey = resolveDbCacheKey(db);\n if (!notifications) {\n warnOnceNoNotifications(dbKey);\n return;\n }\n try {\n await notifications.notify(CHANGE_SIGNAL_CHANNEL, {\n ...signal,\n source: PROCESS_ID,\n });\n } catch (error) {\n logger.warn(\n `Change signal: failed to broadcast a signal for '${signal.table}'`,\n { error: error instanceof Error ? error.message : String(error) },\n );\n }\n}\n\n/**\n * Ensure a background listener consumes cross-replica broadcasts for this\n * database and delivers them locally. Started lazily by the first subscriber\n * for a scope; a no-op (with a one-time warning) when the adapter exposes no\n * notification capability. Notifications this process published are skipped by\n * `source` id (echo-avoidance): the local fan-out already delivered them.\n */\nfunction ensureChangeSignalListener(db: DatabaseInterface): void {\n const dbKey = resolveDbCacheKey(db);\n if (crossReplicaListeners.has(dbKey)) return;\n\n const notifications = getNotifications(db);\n if (!notifications) {\n warnOnceNoNotifications(dbKey);\n return;\n }\n\n const handle: ListenerHandle = { iterator: null, stopped: false };\n crossReplicaListeners.set(dbKey, handle);\n\n void (async () => {\n try {\n const iterable = notifications.listen(CHANGE_SIGNAL_CHANNEL);\n const iterator = iterable[Symbol.asyncIterator]();\n handle.iterator = iterator;\n\n while (!handle.stopped) {\n const { value, done } = await iterator.next();\n if (done || handle.stopped) break;\n\n const notification = value as { payload?: unknown };\n const payload: unknown =\n typeof notification?.payload === 'string'\n ? safeParse(notification.payload)\n : notification?.payload;\n\n if (!payload || typeof payload !== 'object') continue;\n const record = payload as Record<string, unknown>;\n // Echo-avoidance: skip signals this process published (already\n // delivered locally on the write path).\n if (record.source === PROCESS_ID) continue;\n\n const signal = toSignal(record);\n if (!signal) continue;\n deliverLocally(dbKey, signal);\n }\n } catch (error) {\n if (!handle.stopped) {\n logger.warn(\n 'Change signal: cross-replica listener terminated unexpectedly; ' +\n 'peer signals are no longer delivered for this database (local ' +\n 'delivery and cursor catch-up still work)',\n { error: error instanceof Error ? error.message : String(error) },\n );\n }\n } finally {\n // Only retract our own handle — a concurrent restart may have installed\n // a replacement; deleting unconditionally would orphan it.\n if (crossReplicaListeners.get(dbKey) === handle) {\n crossReplicaListeners.delete(dbKey);\n }\n }\n })();\n}\n\n/** Retract the cross-replica listener for a scope (last subscriber left). */\nfunction retractChangeSignalListener(dbKey: string): void {\n const handle = crossReplicaListeners.get(dbKey);\n if (!handle) return;\n handle.stopped = true;\n void handle.iterator?.return?.(undefined);\n crossReplicaListeners.delete(dbKey);\n}\n\n/**\n * Stop all cross-replica change-signal listeners. Used by tests and during\n * shutdown; safe to call when none are active.\n */\nexport function stopChangeSignalListeners(): void {\n for (const handle of crossReplicaListeners.values()) {\n handle.stopped = true;\n void handle.iterator?.return?.(undefined);\n }\n crossReplicaListeners.clear();\n warnedNoNotifications.clear();\n}\n\n/**\n * Clear all local subscribers, stop cross-replica listeners, and reset the\n * no-capability warning dedup. Call in test setup for isolation between files.\n */\nexport function resetChangeSignals(): void {\n localListeners.clear();\n reservedListenerSlots.clear();\n stopChangeSignalListeners();\n}\n\n/**\n * Number of active or reserved local subscribers for a database scope.\n *\n * Used by the `_events` route boundary to enforce the per-process subscriber\n * cap (#1860), and by integration tests to assert teardown — a leaked SSE\n * subscription or stale reservation would keep this above 0 after a client\n * disconnects.\n */\nexport function changeSignalSubscriberCount(db: DatabaseInterface): number {\n return changeSignalSubscriberCountForKey(resolveDbCacheKey(db));\n}\n\nfunction changeSignalSubscriberCountForKey(dbKey: string): number {\n return (\n (localListeners.get(dbKey)?.size ?? 0) +\n (reservedListenerSlots.get(dbKey) ?? 0)\n );\n}\n\nfunction warnOnceNoNotifications(dbKey: string): void {\n if (warnedNoNotifications.has(dbKey)) return;\n warnedNoNotifications.add(dbKey);\n logger.warn(\n 'Change signal: the database adapter exposes no notification capability, ' +\n 'so change signals are delivered in-process only. Cross-replica live ' +\n 'updates require an adapter with notifications (e.g. Postgres ' +\n 'LISTEN/NOTIFY); subscribers on other replicas fall back to cursor ' +\n 'polling.',\n );\n}\n\n/**\n * Coerce a received notification payload into a {@link ChangeSignal}, or\n * `undefined` if it is malformed. Guards the cross-replica path against\n * garbage on the channel.\n */\nfunction toSignal(record: Record<string, unknown>): ChangeSignal | undefined {\n const { table, operation, rowId, tenantId, seq } = record;\n if (typeof table !== 'string' || !table) return undefined;\n if (\n operation !== 'create' &&\n operation !== 'update' &&\n operation !== 'delete'\n ) {\n return undefined;\n }\n let normalizedSeq: number;\n try {\n normalizedSeq = toSafeInteger(seq ?? 0, 'Change-signal sequence');\n } catch {\n return undefined;\n }\n return {\n table,\n operation,\n rowId: typeof rowId === 'string' ? rowId : null,\n tenantId: typeof tenantId === 'string' ? tenantId : null,\n seq: normalizedSeq,\n };\n}\n\nfunction safeParse(value: string): unknown {\n try {\n return JSON.parse(value);\n } catch {\n return undefined;\n }\n}\n"],"mappings":"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"}
|
package/dist/class.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"class.d.ts","sourceRoot":"","sources":["../src/class.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,EAAE,KAAK,QAAQ,EAAS,MAAM,mBAAmB,CAAC;AACzD,OAAO,KAAK,EACV,iBAAiB,EACjB,wBAAwB,EACzB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AACxE,OAAO,KAAK,EAGV,kBAAkB,EAClB,eAAe,EACf,YAAY,EACZ,qBAAqB,EACrB,aAAa,EAEb,iBAAiB,EAClB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EACL,KAAK,iBAAiB,EAGvB,MAAM,oBAAoB,CAAC;AAU5B,OAAO,KAAK,EAEV,aAAa,EAEb,aAAa,EACb,YAAY,EACb,MAAM,aAAa,CAAC;AAErB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAIpD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"class.d.ts","sourceRoot":"","sources":["../src/class.ts"],"names":[],"mappings":"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"}
|