@happyvertical/smrt-core 0.38.2 → 0.38.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +11 -2
- package/dist/__tests__/fixtures/advisor-test-classes.d.ts +9 -0
- package/dist/__tests__/fixtures/advisor-test-classes.d.ts.map +1 -1
- package/dist/change-feed.d.ts +19 -10
- package/dist/change-feed.d.ts.map +1 -1
- package/dist/change-feed.js +43 -13
- package/dist/change-feed.js.map +1 -1
- package/dist/change-signals.d.ts +83 -0
- package/dist/change-signals.d.ts.map +1 -0
- package/dist/change-signals.js +272 -0
- package/dist/change-signals.js.map +1 -0
- package/dist/collection-cache.d.ts +21 -0
- package/dist/collection-cache.d.ts.map +1 -1
- package/dist/collection-cache.js +0 -0
- package/dist/collection-cache.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/decorators/index.d.ts +8 -0
- package/dist/decorators/index.d.ts.map +1 -1
- package/dist/decorators/index.js.map +1 -1
- package/dist/errors.js +2 -1
- package/dist/errors.js.map +1 -1
- package/dist/generators/changes-route.d.ts +15 -27
- package/dist/generators/changes-route.d.ts.map +1 -1
- package/dist/generators/changes-route.js +17 -7
- package/dist/generators/changes-route.js.map +1 -1
- package/dist/generators/cli.d.ts +3 -0
- package/dist/generators/cli.d.ts.map +1 -1
- package/dist/generators/cli.js +7 -4
- package/dist/generators/cli.js.map +1 -1
- package/dist/generators/conditional-get.d.ts +17 -6
- package/dist/generators/conditional-get.d.ts.map +1 -1
- package/dist/generators/conditional-get.js +19 -13
- package/dist/generators/conditional-get.js.map +1 -1
- package/dist/generators/events-route.d.ts +72 -0
- package/dist/generators/events-route.d.ts.map +1 -0
- package/dist/generators/events-route.js +234 -0
- package/dist/generators/events-route.js.map +1 -0
- package/dist/generators/index.d.ts +1 -0
- package/dist/generators/index.d.ts.map +1 -1
- package/dist/generators/index.js +2 -1
- package/dist/generators/mcp-runtime-template.d.ts.map +1 -1
- package/dist/generators/mcp-runtime-template.js +11 -5
- package/dist/generators/mcp-runtime-template.js.map +1 -1
- package/dist/generators/mcp.d.ts +3 -0
- package/dist/generators/mcp.d.ts.map +1 -1
- package/dist/generators/mcp.js +18 -9
- package/dist/generators/mcp.js.map +1 -1
- package/dist/generators/rest.d.ts +7 -1
- package/dist/generators/rest.d.ts.map +1 -1
- package/dist/generators/rest.js +77 -13
- package/dist/generators/rest.js.map +1 -1
- package/dist/generators.js +2 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/manifest/generator.js.map +1 -1
- package/dist/manifest/manifest-loader.js.map +1 -1
- package/dist/manifest/static-manifest.d.ts.map +1 -1
- package/dist/manifest/static-manifest.js +7 -3
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest/store.js.map +1 -1
- package/dist/manifest/test-manifest-stub.d.ts.map +1 -1
- package/dist/manifest/test-manifest-stub.js +1171 -90
- package/dist/manifest/test-manifest-stub.js.map +1 -1
- package/dist/manifest.json +9 -3
- package/dist/mcp-advisor/tools/preview-api-endpoints.d.ts +1 -0
- package/dist/mcp-advisor/tools/preview-api-endpoints.d.ts.map +1 -1
- package/dist/mcp-advisor/types.d.ts +4 -0
- package/dist/mcp-advisor/types.d.ts.map +1 -1
- package/dist/migrations/tracker.js +5 -4
- package/dist/migrations/tracker.js.map +1 -1
- package/dist/migrations/types.d.ts +1 -1
- package/dist/migrations/types.d.ts.map +1 -1
- package/dist/object.d.ts +17 -8
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js +42 -26
- package/dist/object.js.map +1 -1
- package/dist/registry/class-registration.d.ts.map +1 -1
- package/dist/registry/class-registration.js +12 -0
- package/dist/registry/class-registration.js.map +1 -1
- package/dist/registry/shared-state.d.ts +2 -1
- package/dist/registry/shared-state.d.ts.map +1 -1
- package/dist/registry/shared-state.js.map +1 -1
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +1 -1
- package/dist/registry.js.map +1 -1
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js.map +1 -1
- package/dist/scanner/manifest-generator.js.map +1 -1
- package/dist/scanner/types.d.ts +7 -0
- package/dist/scanner/types.d.ts.map +1 -1
- package/dist/schema/ddl/base-strategy.js +1 -1
- package/dist/schema/ddl/base-strategy.js.map +1 -1
- package/dist/scripts/migrate-datetime-to-timestamp.ts +1 -1
- package/dist/signals/sanitizer.d.ts.map +1 -1
- package/dist/signals/sanitizer.js.map +1 -1
- package/dist/smrt-knowledge.json +8 -8
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +2 -1
- package/dist/testing/notifications-stub.d.ts +24 -0
- package/dist/testing/notifications-stub.d.ts.map +1 -0
- package/dist/testing/notifications-stub.js +62 -0
- package/dist/testing/notifications-stub.js.map +1 -0
- package/dist/testing.js +2 -1
- package/dist/tools/tool-generator.js +1 -1
- package/dist/tools/tool-generator.js.map +1 -1
- package/dist/vite-plugin/changes-route.d.ts +14 -0
- package/dist/vite-plugin/changes-route.d.ts.map +1 -1
- package/dist/vite-plugin/changes-route.js +2 -2
- package/dist/vite-plugin/changes-route.js.map +1 -1
- package/dist/vite-plugin/events-route.d.ts +9 -0
- package/dist/vite-plugin/events-route.d.ts.map +1 -0
- package/dist/vite-plugin/events-route.js +155 -0
- package/dist/vite-plugin/events-route.js.map +1 -0
- package/dist/vite-plugin/index.d.ts +8 -0
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +8 -2
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.d.ts +8 -0
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +239 -38
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/dist/vite-plugin/web-collections.js +1 -1
- package/dist/vite-plugin/web-collections.js.map +1 -1
- package/package.json +12 -12
package/AGENTS.md
CHANGED
|
@@ -79,11 +79,20 @@ Adapter-agnostic change-observation spine (`src/change-feed.ts`) — the server
|
|
|
79
79
|
|
|
80
80
|
- `_smrt_changes` system table: one append per framework save/delete via a GlobalInterceptors writer registered at framework init. Deletes are tombstones (`operation: 'delete'`). `_smrt_*` tables are skipped. Feed-append failures log and never fail the user's write. No dirty-check: a field-unchanged `.save()` appends a spurious `update` entry (diff-aware paths like `getOrUpsert`/sync-apply short-circuit before `save()` and append nothing); subscribers must tolerate spurious entries — they are convergent.
|
|
81
81
|
- Sequences: allocated as `MAX(seq)+1` inside the INSERT with conflict retry — committed rows stay contiguous, so commit order == seq order on SQLite/Postgres/DuckDB (deliberately NOT identity/serial: those allocate before commit and break the cursor guarantee under concurrent writers).
|
|
82
|
-
- `getChangesSince(db, { since, tables?, tenantId?, limit? }) → { changes, cursor, resyncRequired? }`: strictly monotonic cursor; polling with returned cursors misses no committed change and never repeats one. A cursor that cannot be served incrementally — pruned below the retained `[floor..horizon]` run, or foreign/ahead of the horizon — gets `resyncRequired: true` with empty `changes` and
|
|
82
|
+
- `getChangesSince(db, { since, tables?, tenantId?, limit? }) → { changes, cursor, resyncRequired?, resyncCursor? }`: strictly monotonic cursor; polling with returned cursors misses no committed change and never repeats one. A cursor that cannot be served incrementally — pruned below the retained `[floor..horizon]` run, or foreign/ahead of the horizon — gets `resyncRequired: true` with empty `changes`, an unadvanced `cursor`, and `resyncCursor` set to the current horizon so clients can full-refetch then resume incrementally; detection runs on the UNFILTERED log so `tables`/`tenantId` filters never trigger or mask it. `getTenantScopedChangesSince()` resolves tenant via the DispatchBus resolver hook (fail-closed: tenancy on + no context → global rows only; tenant `T` sees `T` + global rows, never another tenant).
|
|
83
83
|
- `getTableVersion(db, table) → number`: the per-table change version (`MAX(seq)` for the table, replica-stable — no per-process divergence), the ETag source for zero-query conditional GETs (#1765). Advances on any framework write to the table (CRUD and sync-apply, which all `save()`/`delete()`). A table with no retained entry of its own falls back to the global horizon (never a resettable low value) so an all-pruned table cannot false-304 a stale client; only 0 when the feed is empty.
|
|
84
|
-
- Generated `_changes` routes: REST (`GET {basePath}/_changes`, requires `authMiddleware`, otherwise 401 — per-model `api.public` does NOT apply) and SvelteKit (`{routesDir}/_changes/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.changesRoute.enabled: false`). Query params: `since`, `tables` (comma-separated), `limit`. Responses stay HTTP 200 in the resync state — `resyncRequired` is protocol state, not an error.
|
|
84
|
+
- Generated `_changes` routes: REST (`GET {basePath}/_changes`, requires `authMiddleware`, otherwise 401 — per-model `api.public` does NOT apply) and SvelteKit (`{routesDir}/_changes/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.changesRoute.enabled: false`). Query params: `since`, `tables` (comma-separated), `limit`. Responses stay HTTP 200 in the resync state — `resyncRequired` is protocol state, not an error, and `resyncCursor` is the resume cursor after the client completes a full refetch.
|
|
85
85
|
- Retention: `pruneChangeFeed(db, { maxAgeMs?, maxRows? })` — schedule it. Pruning deletes oldest-first and always retains the newest entry (a non-empty feed is never emptied), which is what makes pruned-cursor detection provable and keeps caught-up consumers polling normally. Raw-SQL writes are invisible to the feed (same documented gap as the #1499 cache); `bumpChangeFeed(db, { table, rowId? })` is the manual escape hatch.
|
|
86
86
|
|
|
87
|
+
## Live Events / Change Signals (#1763, server half)
|
|
88
|
+
|
|
89
|
+
The push companion to the change feed (`src/change-signals.ts` + the generated `_events` SSE route) — the server half of live cache invalidation (PRD #1755). The client subscriber (two-client/reconnect/polling-fallback ACs) is a separate later slice.
|
|
90
|
+
|
|
91
|
+
- **Change-signal bus** (`src/change-signals.ts`): every framework save/delete that appends a durable feed row also publishes a coarse `ChangeSignal` `{ table, operation, rowId, tenantId, seq }` — **never a row payload** (authorization stays on the read path). Structurally mirrors the collection cache's notify/listen path. `subscribeToChangeSignals(db, listener) → unsubscribe`; `publishChangeSignal`/`broadcastChangeSignal`/the listener loop stay internal. `appendChange` now returns the allocated `seq` (was `void`); the signal carries it as a coarse resume cursor (`bumpChangeFeed` ignores the return). The publish runs only after the append SUCCEEDS (no signal without a durable feed row) and in its own log-and-swallow try/catch, so a signal problem never fails the user's write. `_smrt_*` writes never signal (the writer skips them). Delivery is synchronous per-listener with per-listener try/catch (one throwing SSE controller never blocks others); no per-subscriber queue — backpressure rides the platform `ReadableStream`.
|
|
92
|
+
- **In-process + cross-replica**: locally-published and peer-received signals go through the SAME `deliverLocally` path. Cross-replica fan-out rides the db adapter's optional notification capability (`db.notifications`, a NEW `smrt_change_signals` channel distinct from the cache channel) with echo-avoidance by `PROCESS_ID`. No capability → in-process only, warn-once, **never an error, never blocks the write** (subscribers on other replicas fall back to cursor polling).
|
|
93
|
+
- **Generated `_events` SSE route**: REST (`GET {basePath}/_events`, requires `authMiddleware`, otherwise 401 — fail-closed, per-model `api.public` does NOT apply; 405 non-GET; 503 no db) and SvelteKit (`{routesDir}/_events/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.eventsRoute.enabled: false`). Tenant scope is captured ONCE at connection open (`resolveDispatchTenantScope`) and filtered server-side per signal via `signalVisibleToTenant` (same rule as `getChangesSince`'s tenant filter) before any byte hits the wire — delivery runs outside any tenant ALS context, so it must use the captured value. The stream lifecycle lives in `buildChangeEventStream(db, { cursor, tenantScope, heartbeatMs? })` (exported; the SvelteKit route imports it so it stays thin): subscribe-before-catch-up (closes the gap window; overlap is deduped by the SSE `id:`/seq client-side), `retry: 3000`, cursor catch-up via `getChangesSince` filtered by the CAPTURED scope (not re-resolved from ALS, so it matches the live-signal filter exactly and can't replay another tenant's rows) (`Last-Event-ID` header beats `?since=`; default = live-forward only; `resyncRequired` → `event: resync`), heartbeat (`DEFAULT_EVENTS_HEARTBEAT_MS` = 15s), and `cancel()` teardown (clears heartbeat + unsubscribes). SSE frame: `id: <seq>\nevent: change\ndata: {table,operation,rowId,tenantId}\n\n` — seq is ONLY in the `id:` line, never the data JSON. **Same-origin only** (not CORS-wrapped): `EventSource` can't set headers and credentialed cross-origin needs Allow-Credentials the CORS helper doesn't emit — cross-origin SSE is a follow-up. Client disconnect through the Node `createServer` bridge now cancels the response reader (was a teardown leak) so `cancel()` fires and the subscription is released.
|
|
94
|
+
- **Known gaps** (documented in the module): no max-connections cap (bound SSE connections at the edge); raw-SQL writes don't signal (same gap as the feed); live signals for caller-managed-transaction writes are best-effort (the append + signal fire pre-commit, so a rolled-back write may emit a signal and its freed seq is later reused) — the autocommit default path is exact, and clients reconcile via full catch-up/resync (inherits the change feed's transaction caveat).
|
|
95
|
+
|
|
87
96
|
## Single Table Inheritance (STI)
|
|
88
97
|
|
|
89
98
|
- Base: `@smrt({ tableStrategy: 'sti' })` — children inherit, share one table
|
|
@@ -10,6 +10,15 @@ export declare class AdvisorRichProduct extends SmrtObject {
|
|
|
10
10
|
/** A custom domain method that should surface in get-object-config. */
|
|
11
11
|
recalculate(): Promise<number>;
|
|
12
12
|
}
|
|
13
|
+
export declare class AdvisorPolicyRecord extends SmrtObject {
|
|
14
|
+
displayName: string;
|
|
15
|
+
customerId: string;
|
|
16
|
+
externalId: number;
|
|
17
|
+
secretToken: string;
|
|
18
|
+
internalCode: string;
|
|
19
|
+
tenantId: string;
|
|
20
|
+
ignoredValue: string;
|
|
21
|
+
}
|
|
13
22
|
/**
|
|
14
23
|
* Boolean-config object: api/mcp enabled via `true`, cli disabled. Exercises
|
|
15
24
|
* the boolean branches in list-registered-objects and get-object-config.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"advisor-test-classes.d.ts","sourceRoot":"","sources":["../../../src/__tests__/fixtures/advisor-test-classes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAG7C;;;GAGG;AACH,qBASa,kBAAmB,SAAQ,UAAU;IAMhD,KAAK,EAAE,MAAM,CAAM;IAGnB,QAAQ,EAAE,MAAM,CAAK;IAGrB,IAAI,EAAE,MAAM,CAAO;IAEnB,uEAAuE;IACjE,WAAW,IAAI,OAAO,CAAC,MAAM,CAAC;CAGrC;AAED;;;GAGG;AACH,qBAKa,oBAAqB,SAAQ,UAAU;IAElD,KAAK,EAAE,MAAM,CAAM;CACpB;AAED;;;;GAIG;AACH,qBAIa,kBAAmB,SAAQ,UAAU;IAEhD,IAAI,EAAE,MAAM,CAAM;CACnB"}
|
|
1
|
+
{"version":3,"file":"advisor-test-classes.d.ts","sourceRoot":"","sources":["../../../src/__tests__/fixtures/advisor-test-classes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAG7C;;;GAGG;AACH,qBASa,kBAAmB,SAAQ,UAAU;IAMhD,KAAK,EAAE,MAAM,CAAM;IAGnB,QAAQ,EAAE,MAAM,CAAK;IAGrB,IAAI,EAAE,MAAM,CAAO;IAEnB,uEAAuE;IACjE,WAAW,IAAI,OAAO,CAAC,MAAM,CAAC;CAGrC;AAED,qBAaa,mBAAoB,SAAQ,UAAU;IAEjD,WAAW,EAAE,MAAM,CAAM;IAGzB,UAAU,EAAE,MAAM,CAAM;IAGxB,UAAU,EAAE,MAAM,CAAK;IAGvB,WAAW,EAAE,MAAM,CAAM;IAGzB,YAAY,EAAE,MAAM,CAAM;IAG1B,QAAQ,EAAE,MAAM,CAAM;IAGtB,YAAY,EAAE,MAAM,CAAM;CAC3B;AAED;;;GAGG;AACH,qBAKa,oBAAqB,SAAQ,UAAU;IAElD,KAAK,EAAE,MAAM,CAAM;CACpB;AAED;;;;GAIG;AACH,qBAIa,kBAAmB,SAAQ,UAAU;IAEhD,IAAI,EAAE,MAAM,CAAM;CACnB"}
|
package/dist/change-feed.d.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface GetChangesOptions {
|
|
|
39
39
|
* start, `since: 0` (like any cursor older than the retained window) can no
|
|
40
40
|
* longer be served incrementally — the read returns
|
|
41
41
|
* {@link ChangeFeedPage.resyncRequired} and the caller must do a full
|
|
42
|
-
* resync.
|
|
42
|
+
* resync before resuming from {@link ChangeFeedPage.resyncCursor}.
|
|
43
43
|
*/
|
|
44
44
|
since: number;
|
|
45
45
|
/** Restrict to these physical table names. Empty/omitted → all tables. */
|
|
@@ -81,13 +81,21 @@ export interface ChangeFeedPage {
|
|
|
81
81
|
* - the cursor is ahead of the committed horizon / unknown to this
|
|
82
82
|
* database (a foreign or reset cursor).
|
|
83
83
|
*
|
|
84
|
-
* When set, `changes` is empty and `cursor` echoes `since` unchanged
|
|
85
|
-
*
|
|
86
|
-
* the
|
|
87
|
-
* the **unfiltered** log: `tables`/`tenantId`
|
|
88
|
-
* rows and never trigger (or mask) a resync
|
|
84
|
+
* When set, `changes` is empty and `cursor` echoes `since` unchanged.
|
|
85
|
+
* After its full data refetch, the consumer should resume polling from
|
|
86
|
+
* {@link resyncCursor}, the committed horizon observed by this read.
|
|
87
|
+
* Detection is computed on the **unfiltered** log: `tables`/`tenantId`
|
|
88
|
+
* filters legitimately hide rows and never trigger (or mask) a resync
|
|
89
|
+
* signal.
|
|
89
90
|
*/
|
|
90
91
|
resyncRequired?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Current committed horizon to use after handling a resync. Present with
|
|
94
|
+
* {@link resyncRequired}; separated from `cursor` so old callers that rely
|
|
95
|
+
* on `cursor` echoing the rejected value keep their monotonic-cursor
|
|
96
|
+
* invariant.
|
|
97
|
+
*/
|
|
98
|
+
resyncCursor?: number;
|
|
91
99
|
}
|
|
92
100
|
/** Input for {@link appendChange} / {@link bumpChangeFeed}. */
|
|
93
101
|
export interface AppendChangeInput {
|
|
@@ -136,7 +144,7 @@ export declare function ensureChangeFeedTable(db: DatabaseInterface): Promise<vo
|
|
|
136
144
|
* risk. Savepoint-scoped protection is a possible follow-up; it is
|
|
137
145
|
* intentionally out of scope for the v1 spine.
|
|
138
146
|
*/
|
|
139
|
-
export declare function appendChange(db: DatabaseInterface, input: AppendChangeInput): Promise<
|
|
147
|
+
export declare function appendChange(db: DatabaseInterface, input: AppendChangeInput): Promise<number>;
|
|
140
148
|
/**
|
|
141
149
|
* Manual bump escape hatch for out-of-band writers.
|
|
142
150
|
*
|
|
@@ -168,8 +176,9 @@ export declare function bumpChangeFeed(db: DatabaseInterface, input: AppendChang
|
|
|
168
176
|
* ## Resync detection (pruned / foreign cursors)
|
|
169
177
|
*
|
|
170
178
|
* A cursor that cannot be served incrementally is flagged with
|
|
171
|
-
* `resyncRequired: true` (empty `changes`, `cursor` echoed unchanged
|
|
172
|
-
* pollers never go silently,
|
|
179
|
+
* `resyncRequired: true` (empty `changes`, `cursor` echoed unchanged,
|
|
180
|
+
* `resyncCursor` set to the current horizon) so pollers never go silently,
|
|
181
|
+
* permanently stale:
|
|
173
182
|
*
|
|
174
183
|
* - **Pruned gap**: retained sequences always form a contiguous run
|
|
175
184
|
* `[floor..horizon]` and {@link pruneChangeFeed} deletes oldest-first
|
|
@@ -295,7 +304,7 @@ export declare function registerChangeFeedWriter(): void;
|
|
|
295
304
|
/** Unregister the change-feed writer (test helper). */
|
|
296
305
|
export declare function unregisterChangeFeedWriter(): boolean;
|
|
297
306
|
/**
|
|
298
|
-
* Reset the append-failure warning
|
|
307
|
+
* Reset the append-failure and signal-publish warning dedups (test helper).
|
|
299
308
|
*/
|
|
300
309
|
export declare function resetChangeFeedWarnings(): void;
|
|
301
310
|
//# sourceMappingURL=change-feed.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"change-feed.d.ts","sourceRoot":"","sources":["../src/change-feed.ts"],"names":[],"mappings":"AAAA
|
|
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 { publishChangeSignal } from "./change-signals.js";
|
|
2
3
|
import { resolveDispatchTenantScope } from "./dispatch/tenant-resolver.js";
|
|
3
4
|
import { GlobalInterceptors } from "./interceptors.js";
|
|
4
5
|
import { detectEngine } from "./schema/ddl/index.js";
|
|
@@ -87,7 +88,8 @@ import { createLogger } from "@happyvertical/logger";
|
|
|
87
88
|
* always retains the newest entry, so retained sequences stay a contiguous
|
|
88
89
|
* `[floor..horizon]` run — which is how {@link getChangesSince} *detects* a
|
|
89
90
|
* consumer whose cursor predates the retained window and answers it with
|
|
90
|
-
* `resyncRequired: true`
|
|
91
|
+
* `resyncRequired: true` plus a fresh resume cursor instead of silently
|
|
92
|
+
* skipping the pruned changes.
|
|
91
93
|
*
|
|
92
94
|
* @see https://github.com/happyvertical/smrt/issues/1758
|
|
93
95
|
* @packageDocumentation
|
|
@@ -178,7 +180,7 @@ async function appendChange(db, input) {
|
|
|
178
180
|
const operation = input.operation ?? "update";
|
|
179
181
|
if (!VALID_OPERATIONS.has(operation)) throw new Error(`appendChange operation must be one of create/update/delete, got '${String(input.operation)}'`);
|
|
180
182
|
const p = placeholders(db);
|
|
181
|
-
const sql = `INSERT INTO ${CHANGE_FEED_TABLE} (seq, table_name, row_id, operation, tenant_id, created_at) SELECT COALESCE(MAX(seq), 0) + 1, ${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)} FROM ${CHANGE_FEED_TABLE}`;
|
|
183
|
+
const sql = `INSERT INTO ${CHANGE_FEED_TABLE} (seq, table_name, row_id, operation, tenant_id, created_at) SELECT COALESCE(MAX(seq), 0) + 1, ${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)} FROM ${CHANGE_FEED_TABLE} RETURNING seq`;
|
|
182
184
|
const params = [
|
|
183
185
|
table,
|
|
184
186
|
input.rowId ?? null,
|
|
@@ -187,11 +189,11 @@ async function appendChange(db, input) {
|
|
|
187
189
|
(/* @__PURE__ */ new Date()).toISOString()
|
|
188
190
|
];
|
|
189
191
|
for (let attempt = 1; attempt <= MAX_APPEND_ATTEMPTS; attempt++) try {
|
|
190
|
-
await db.query(sql, ...params);
|
|
191
|
-
return;
|
|
192
|
+
return toSeqNumber(getQueryRows(await db.query(sql, ...params))[0]?.seq);
|
|
192
193
|
} catch (error) {
|
|
193
194
|
if (!isUniqueViolation(error) || attempt === MAX_APPEND_ATTEMPTS) throw error;
|
|
194
195
|
}
|
|
196
|
+
throw new Error("appendChange exhausted retries without allocating a seq");
|
|
195
197
|
}
|
|
196
198
|
/**
|
|
197
199
|
* Manual bump escape hatch for out-of-band writers.
|
|
@@ -227,8 +229,9 @@ async function bumpChangeFeed(db, input) {
|
|
|
227
229
|
* ## Resync detection (pruned / foreign cursors)
|
|
228
230
|
*
|
|
229
231
|
* A cursor that cannot be served incrementally is flagged with
|
|
230
|
-
* `resyncRequired: true` (empty `changes`, `cursor` echoed unchanged
|
|
231
|
-
* pollers never go silently,
|
|
232
|
+
* `resyncRequired: true` (empty `changes`, `cursor` echoed unchanged,
|
|
233
|
+
* `resyncCursor` set to the current horizon) so pollers never go silently,
|
|
234
|
+
* permanently stale:
|
|
232
235
|
*
|
|
233
236
|
* - **Pruned gap**: retained sequences always form a contiguous run
|
|
234
237
|
* `[floor..horizon]` and {@link pruneChangeFeed} deletes oldest-first
|
|
@@ -261,17 +264,20 @@ async function getChangesSince(db, options) {
|
|
|
261
264
|
} : {
|
|
262
265
|
changes: [],
|
|
263
266
|
cursor: since,
|
|
264
|
-
resyncRequired: true
|
|
267
|
+
resyncRequired: true,
|
|
268
|
+
resyncCursor: 0
|
|
265
269
|
};
|
|
266
270
|
if (since > horizon) return {
|
|
267
271
|
changes: [],
|
|
268
272
|
cursor: since,
|
|
269
|
-
resyncRequired: true
|
|
273
|
+
resyncRequired: true,
|
|
274
|
+
resyncCursor: horizon
|
|
270
275
|
};
|
|
271
276
|
if (since < floor - 1) return {
|
|
272
277
|
changes: [],
|
|
273
278
|
cursor: since,
|
|
274
|
-
resyncRequired: true
|
|
279
|
+
resyncRequired: true,
|
|
280
|
+
resyncCursor: horizon
|
|
275
281
|
};
|
|
276
282
|
if (horizon === since) return {
|
|
277
283
|
changes: [],
|
|
@@ -442,6 +448,8 @@ async function deleteCounted(db, condition, params) {
|
|
|
442
448
|
var WAS_PERSISTED_KEY = "_smrtChangeFeedWasPersisted";
|
|
443
449
|
/** Databases we already warned about after a failed feed append. */
|
|
444
450
|
var warnedAppendFailures = /* @__PURE__ */ new Set();
|
|
451
|
+
/** Databases we already warned about after a failed signal publish (#1763). */
|
|
452
|
+
var warnedSignalPublishFailures = /* @__PURE__ */ new Set();
|
|
445
453
|
/**
|
|
446
454
|
* Register the change-feed writer with {@link GlobalInterceptors}.
|
|
447
455
|
*
|
|
@@ -500,12 +508,25 @@ async function appendForInstance(instance, operation) {
|
|
|
500
508
|
try {
|
|
501
509
|
const id = instance.id;
|
|
502
510
|
const tenantId = instance.tenantId;
|
|
503
|
-
|
|
511
|
+
const rowId = typeof id === "string" && id ? id : null;
|
|
512
|
+
const rowTenantId = typeof tenantId === "string" && tenantId ? tenantId : null;
|
|
513
|
+
const seq = await appendChange(db, {
|
|
504
514
|
table,
|
|
505
|
-
rowId
|
|
515
|
+
rowId,
|
|
506
516
|
operation,
|
|
507
|
-
tenantId:
|
|
517
|
+
tenantId: rowTenantId
|
|
508
518
|
});
|
|
519
|
+
try {
|
|
520
|
+
publishChangeSignal(db, {
|
|
521
|
+
table,
|
|
522
|
+
operation,
|
|
523
|
+
rowId,
|
|
524
|
+
tenantId: rowTenantId,
|
|
525
|
+
seq
|
|
526
|
+
});
|
|
527
|
+
} catch (error) {
|
|
528
|
+
warnSignalPublishFailureOnce(db, table, error);
|
|
529
|
+
}
|
|
509
530
|
} catch (error) {
|
|
510
531
|
warnAppendFailureOnce(db, table, error);
|
|
511
532
|
}
|
|
@@ -518,11 +539,20 @@ function warnAppendFailureOnce(db, table, error) {
|
|
|
518
539
|
logger.warn(`Change feed: failed to append a change entry for '${table}'. The write itself succeeded; the feed is missing this change (further failures for this database are suppressed). Consumers recover on full resync.`, { error: error instanceof Error ? error.message : String(error) });
|
|
519
540
|
} catch {}
|
|
520
541
|
}
|
|
542
|
+
function warnSignalPublishFailureOnce(db, table, error) {
|
|
543
|
+
try {
|
|
544
|
+
const dbKey = resolveDbCacheKey(db);
|
|
545
|
+
if (warnedSignalPublishFailures.has(dbKey)) return;
|
|
546
|
+
warnedSignalPublishFailures.add(dbKey);
|
|
547
|
+
logger.warn(`Change feed: failed to publish a live change signal for '${table}'. The write and its durable feed row are unaffected; live SSE subscribers miss this signal but recover via cursor catch-up (further failures for this database are suppressed).`, { error: error instanceof Error ? error.message : String(error) });
|
|
548
|
+
} catch {}
|
|
549
|
+
}
|
|
521
550
|
/**
|
|
522
|
-
* Reset the append-failure warning
|
|
551
|
+
* Reset the append-failure and signal-publish warning dedups (test helper).
|
|
523
552
|
*/
|
|
524
553
|
function resetChangeFeedWarnings() {
|
|
525
554
|
warnedAppendFailures.clear();
|
|
555
|
+
warnedSignalPublishFailures.clear();
|
|
526
556
|
}
|
|
527
557
|
//#endregion
|
|
528
558
|
export { CHANGE_FEED_INTERCEPTOR_NAME, CHANGE_FEED_TABLE, DEFAULT_CHANGES_LIMIT, MAX_CHANGES_LIMIT, appendChange, bumpChangeFeed, ensureChangeFeedTable, getChangesSince, getTableVersion, getTenantScopedChangesSince, pruneChangeFeed, registerChangeFeedWriter, resetChangeFeedWarnings, unregisterChangeFeedWriter };
|
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 * a single small INSERT (issued from the write path *after* the user's row\n * was written), so the serialization window is one statement; conflicts\n * resolve with a bounded retry loop and are impossible on single-writer\n * engines (SQLite).\n *\n * ## Failure semantics\n *\n * A feed-write failure must never fail the user's write. The interceptor\n * wraps the append in a try/catch: on failure it logs a warning (deduped per\n * database) and continues. The trade-off is availability of the user's\n * write over completeness of the feed — consumers already need a\n * full-resync path for cursors older than the retention window, and the\n * same path covers a (rare) dropped feed row. When the user's write runs\n * inside a caller-managed transaction on the same handle, the append joins\n * it and shares its fate (a rollback removes the change row with the data\n * row).\n *\n * ## Known gaps (documented in the PRD)\n *\n * - Writes that bypass framework mutation paths (raw SQL) are invisible to\n * the feed — the same accepted gap as the #1499 collection cache.\n * {@link bumpChangeFeed} is the manual escape hatch: out-of-band writers\n * append a synthetic change row for the affected table.\n * - **Spurious `update` entries**: `SmrtObject.save()` has no dirty-check,\n * so a field-unchanged `.save()` still appends an `update` row. This is\n * by design — the writer observes writes, not diffs (it has no old-row\n * access), so the feed faithfully mirrors the write path. Diff-aware\n * paths (`getOrUpsert()`'s diff guard, the sync-apply endpoint's no-op\n * detection) short-circuit before `save()` and append nothing.\n * Subscribers must tolerate spurious entries; they are convergent — a\n * re-fetch returns identical data.\n *\n * ## Retention\n *\n * The log is append-only and grows with write volume. {@link pruneChangeFeed}\n * bounds it by age (`maxAgeMs`) and/or row count (`maxRows`); call it from a\n * scheduled job sized so the retention window comfortably exceeds the\n * slowest consumer's polling interval. Pruning deletes oldest-first and\n * always retains the newest entry, so retained sequences stay a contiguous\n * `[floor..horizon]` run — which is how {@link getChangesSince} *detects* a\n * consumer whose cursor predates the retained window and answers it with\n * `resyncRequired: true` instead of silently skipping the pruned changes.\n *\n * @see https://github.com/happyvertical/smrt/issues/1758\n * @packageDocumentation\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { resolveDbCacheKey } from './collection-cache.js';\nimport { resolveDispatchTenantScope } from './dispatch/tenant-resolver.js';\nimport { GlobalInterceptors, type InterceptorContext } from './interceptors.js';\nimport type { SmrtObject } from './object.js';\nimport { detectEngine } from './schema/ddl/index.js';\nimport { CREATE_SMRT_CHANGES_TABLE } from './system/schema.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/** Name of the append-only change-feed system table. */\nexport const CHANGE_FEED_TABLE = '_smrt_changes';\n\n/** Interceptor name of the framework's change-feed writer. */\nexport const CHANGE_FEED_INTERCEPTOR_NAME = 'smrt-change-feed';\n\n/**\n * Change operations recorded in the feed. Deletes are tombstones —\n * consumers can distinguish \"row changed\" from \"row is gone\" without\n * consulting the source table.\n */\nexport type ChangeOperation = 'create' | 'update' | 'delete';\n\n/** One entry of the change feed. */\nexport interface ChangeFeedEntry {\n /** Strictly monotonic per-database sequence (the cursor dimension). */\n seq: number;\n /** Physical table the change happened in (STI children report the shared base table). */\n table: string;\n /**\n * Primary key of the changed row, or `null` for table-level synthetic\n * bumps recorded via {@link bumpChangeFeed} without a row id.\n */\n rowId: string | null;\n /** What happened. `'delete'` entries double as tombstones. */\n operation: ChangeOperation;\n /** Tenant the changed row belongs to, or `null` for global/non-tenant rows. */\n tenantId: string | null;\n /** ISO-8601 timestamp recorded when the change was appended. */\n timestamp: string;\n}\n\n/** Options for {@link getChangesSince}. */\nexport interface GetChangesOptions {\n /**\n * Cursor to read after. Only rows with `seq` strictly greater than `since`\n * are returned; pass a previously returned {@link ChangeFeedPage.cursor} to\n * poll.\n *\n * `0` reads from the start of the log only while it has not been pruned past\n * the beginning. Once retention has raised the retained floor above the\n * start, `since: 0` (like any cursor older than the retained window) can no\n * longer be served incrementally — the read returns\n * {@link ChangeFeedPage.resyncRequired} and the caller must do a full\n * resync.\n */\n since: number;\n /** Restrict to these physical table names. Empty/omitted → all tables. */\n tables?: string[];\n /**\n * Tenant visibility filter:\n * - omitted/`undefined` → no tenant filter (all rows).\n * - `null` → only global rows (`tenant_id IS NULL`).\n * - `'<tenantId>'` → that tenant's rows **plus** global rows, matching the\n * DispatchBus read rule (`tenant_id = T OR tenant_id IS NULL`). A tenant\n * never sees another tenant's changes.\n */\n tenantId?: string | null;\n /**\n * Page size (default {@link DEFAULT_CHANGES_LIMIT}, capped at\n * {@link MAX_CHANGES_LIMIT}). When a page fills up, the returned cursor\n * stops at the last returned row so the next poll continues seamlessly.\n */\n limit?: number;\n}\n\n/** Result page of {@link getChangesSince}. */\nexport interface ChangeFeedPage {\n /** Matching changes ordered by ascending `seq`. */\n changes: ChangeFeedEntry[];\n /**\n * The next cursor. Monotonic: never lower than the `since` it was derived\n * from. Equal to the committed horizon when the page was exhaustive, or to\n * the last returned `seq` when the page hit `limit`. Feed the value back\n * as `since` to observe every later change exactly once.\n */\n cursor: number;\n /**\n * Present (and `true`) when the supplied cursor cannot be served\n * incrementally and the consumer must fall back to a full resync:\n *\n * - the cursor predates the retained window (entries at or below it were\n * pruned away — the changes between it and the retained floor are gone\n * for good), or\n * - the cursor is ahead of the committed horizon / unknown to this\n * database (a foreign or reset cursor).\n *\n * When set, `changes` is empty and `cursor` echoes `since` unchanged —\n * the consumer must re-fetch its data in full and restart polling from\n * the cursor returned by its post-resync read. Detection is computed on\n * the **unfiltered** log: `tables`/`tenantId` filters legitimately hide\n * rows and never trigger (or mask) a resync signal.\n */\n resyncRequired?: boolean;\n}\n\n/** Input for {@link appendChange} / {@link bumpChangeFeed}. */\nexport interface AppendChangeInput {\n /** Physical table name the change refers to. */\n table: string;\n /** Changed row's primary key; `null`/omitted records a table-level change. */\n rowId?: string | null;\n /** Operation to record (default `'update'`). */\n operation?: ChangeOperation;\n /** Tenant the change belongs to (default `null` = global). */\n tenantId?: string | null;\n}\n\n/** Retention bounds for {@link pruneChangeFeed}. At least one is required. */\nexport interface ChangeFeedRetention {\n /** Prune entries older than this many milliseconds. */\n maxAgeMs?: number;\n /** Keep at most this many newest entries (by sequence). */\n maxRows?: number;\n}\n\n/** Default page size for {@link getChangesSince}. */\nexport const DEFAULT_CHANGES_LIMIT = 500;\n\n/** Hard cap on the page size for {@link getChangesSince}. */\nexport const MAX_CHANGES_LIMIT = 5_000;\n\n/**\n * Maximum append attempts under sequence contention. Conflicts only occur\n * with concurrent writers on MVCC engines and resolve as soon as the\n * blocking transaction commits, so a small bound is ample.\n */\nconst MAX_APPEND_ATTEMPTS = 20;\n\nconst VALID_OPERATIONS: ReadonlySet<string> = new Set([\n 'create',\n 'update',\n 'delete',\n]);\n\n// ============================================================================\n// Engine / SQL helpers (mirrors system/compatibility.ts conventions)\n// ============================================================================\n\ntype DatabaseWithConfig = DatabaseInterface & {\n config?: { type?: string; url?: string };\n type?: string;\n};\n\nfunction getEngine(db: DatabaseInterface): ReturnType<typeof detectEngine> {\n const withConfig = db as DatabaseWithConfig;\n return detectEngine(\n db.url || withConfig.config?.url || '',\n withConfig.type || withConfig.config?.type,\n );\n}\n\n/**\n * Positional placeholder factory: Postgres uses `$n`, SQLite/DuckDB use `?`.\n */\nfunction placeholders(db: DatabaseInterface): (index: number) => string {\n const engine = getEngine(db);\n return engine === 'postgres' ? (index) => `$${index}` : () => '?';\n}\n\nfunction getQueryRows(result: unknown): Record<string, unknown>[] {\n if (Array.isArray(result)) {\n return result as Record<string, unknown>[];\n }\n if (result && typeof result === 'object' && 'rows' in result) {\n const rows = (result as { rows?: unknown }).rows;\n if (Array.isArray(rows)) {\n return rows as Record<string, unknown>[];\n }\n }\n return [];\n}\n\nfunction isUniqueViolation(error: unknown): boolean {\n const message = error instanceof Error ? error.message : String(error ?? '');\n return (\n /unique constraint/i.test(message) ||\n /duplicate key/i.test(message) ||\n /primary key constraint/i.test(message) ||\n /constraint error/i.test(message)\n );\n}\n\n/**\n * Ensure the `_smrt_changes` system table exists on a database handle that\n * may not have passed through framework initialization (e.g. a raw handle\n * given to the REST generator). Idempotent (`CREATE ... IF NOT EXISTS`) and\n * guarded to run once per handle. Databases initialized through the\n * framework already have the table via the system-table bootstrap.\n */\nconst ensuredHandles = new WeakSet<object>();\n\nexport async function ensureChangeFeedTable(\n db: DatabaseInterface,\n): Promise<void> {\n if (ensuredHandles.has(db)) return;\n const statements = CREATE_SMRT_CHANGES_TABLE.split(';')\n .map((statement) => statement.trim())\n .filter((statement) => statement.length > 0);\n for (const statement of statements) {\n await db.query(statement);\n }\n ensuredHandles.add(db);\n}\n\n// ============================================================================\n// Append (writer primitive + manual bump escape hatch)\n// ============================================================================\n\n/**\n * Append one change entry with a database-allocated, strictly monotonic\n * sequence.\n *\n * The sequence is allocated inside the INSERT itself\n * (`COALESCE(MAX(seq), 0) + 1`) and retried on primary-key conflict, which\n * keeps committed sequences contiguous and makes commit order equal\n * sequence order — the property the cursor guarantee rests on (see the\n * module docs). Throws after {@link MAX_APPEND_ATTEMPTS} consecutive\n * conflicts or on any non-conflict database error; the framework's\n * interceptor catches and logs instead of failing the user's write.\n *\n * **Transaction assumption.** The conflict retry assumes an autocommit\n * handle, which is the default framework path: `save()`/`delete()` run their\n * statements as independent autocommit calls, so a conflicting append aborts\n * nothing and the retry recomputes `MAX(seq)` against the committed head. If a\n * caller instead wraps `save()` in its own transaction on an MVCC engine\n * (e.g. Postgres), a duplicate-key error can abort the *surrounding*\n * transaction — the retry is then futile and the caller's write rolls back.\n * Callers wrapping mutations in a transaction under genuine seq-head write\n * contention should disable the feed writer for that path or accept that\n * risk. Savepoint-scoped protection is a possible follow-up; it is\n * intentionally out of scope for the v1 spine.\n */\nexport async function appendChange(\n db: DatabaseInterface,\n input: AppendChangeInput,\n): Promise<void> {\n const table = input.table?.trim();\n if (!table) {\n throw new Error('appendChange requires a non-empty table name');\n }\n const operation = input.operation ?? 'update';\n if (!VALID_OPERATIONS.has(operation)) {\n throw new Error(\n `appendChange operation must be one of create/update/delete, got '${String(\n input.operation,\n )}'`,\n );\n }\n\n const p = placeholders(db);\n const sql =\n `INSERT INTO ${CHANGE_FEED_TABLE} ` +\n '(seq, table_name, row_id, operation, tenant_id, created_at) ' +\n `SELECT COALESCE(MAX(seq), 0) + 1, ${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)} ` +\n `FROM ${CHANGE_FEED_TABLE}`;\n const params = [\n table,\n input.rowId ?? null,\n operation,\n input.tenantId ?? null,\n new Date().toISOString(),\n ];\n\n for (let attempt = 1; attempt <= MAX_APPEND_ATTEMPTS; attempt++) {\n try {\n await db.query(sql, ...params);\n return;\n } catch (error) {\n if (!isUniqueViolation(error) || attempt === MAX_APPEND_ATTEMPTS) {\n throw error;\n }\n // Sequence head contention: another append won the value. Re-running\n // recomputes MAX(seq) against the now-committed head.\n }\n }\n}\n\n/**\n * Manual bump escape hatch for out-of-band writers.\n *\n * Framework mutation paths feed the log automatically, but raw SQL issued\n * outside `save()`/`delete()` is invisible to it (documented gap, shared\n * with the #1499 collection cache). Call this after such a write so feed\n * consumers observe the change. Omitting `rowId` records a table-level\n * change (`rowId: null`), which consumers should treat as \"anything in this\n * table may have changed\".\n *\n * @example\n * ```typescript\n * await db.query(`UPDATE products SET price = price * 1.1`);\n * await bumpChangeFeed(db, { table: 'products' });\n * ```\n */\nexport async function bumpChangeFeed(\n db: DatabaseInterface,\n input: AppendChangeInput,\n): Promise<void> {\n await ensureChangeFeedTable(db);\n await appendChange(db, input);\n}\n\n// ============================================================================\n// Read interface\n// ============================================================================\n\n/**\n * Read committed changes after a cursor.\n *\n * Returns every committed change with `since < seq <= cursor` that matches\n * the filters, ordered by ascending `seq`. The returned cursor is safe to\n * persist and poll with: committed sequences are contiguous (see module\n * docs), so nothing can commit at or below the observed horizon afterwards —\n * reads miss no committed change under concurrent writers and never return\n * the same change twice. When `since` is already at the horizon, returns an\n * empty page with `cursor: since`.\n *\n * ## Resync detection (pruned / foreign cursors)\n *\n * A cursor that cannot be served incrementally is flagged with\n * `resyncRequired: true` (empty `changes`, `cursor` echoed unchanged) so\n * pollers never go silently, permanently stale:\n *\n * - **Pruned gap**: retained sequences always form a contiguous run\n * `[floor..horizon]` and {@link pruneChangeFeed} deletes oldest-first\n * while always retaining the newest entry, so `since < floor - 1` proves\n * changes between the cursor and the retained window were pruned away.\n * - **Foreign/reset cursor**: `since > horizon` (ahead of anything this\n * database ever allocated), including any `since > 0` against a feed\n * with no entries.\n *\n * Detection runs on the **unfiltered** log — `tables`/`tenantId` filters\n * legitimately hide rows and never trigger (or mask) the signal. A caught-up\n * consumer (`since === horizon`) is never asked to resync, even when\n * retention has pruned everything older.\n *\n * Filters (`tables`, `tenantId`) affect which rows are *returned*, never how\n * the cursor advances — an exhausted filtered page still advances to the\n * horizon so pollers do not rescan filtered-out rows.\n */\nexport async function getChangesSince(\n db: DatabaseInterface,\n options: GetChangesOptions,\n): Promise<ChangeFeedPage> {\n const { since } = options;\n if (!Number.isFinite(since) || since < 0) {\n throw new Error(\n `getChangesSince requires a non-negative numeric cursor, got '${String(since)}'`,\n );\n }\n const limit = Math.min(\n Math.max(Math.floor(options.limit ?? DEFAULT_CHANGES_LIMIT), 1),\n MAX_CHANGES_LIMIT,\n );\n\n const p = placeholders(db);\n\n // The committed horizon: every seq <= horizon is committed and immutable\n // (append-only + contiguous allocation), so the page below is stable even\n // though it runs as a separate statement. The floor bounds the retained\n // window for pruned-cursor detection; both are computed UNFILTERED so\n // table/tenant filters can neither trigger nor mask a resync signal.\n const boundsRows = getQueryRows(\n await db.query(\n `SELECT MIN(seq) AS floor, MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`,\n ),\n );\n const floor = toSeqNumber(boundsRows[0]?.floor);\n const horizon = toSeqNumber(boundsRows[0]?.horizon);\n\n if (horizon === 0) {\n // No entries at all. A zero cursor is simply \"no changes ever\"; any\n // other cursor came from a different database (or a reset feed) and\n // cannot be served incrementally.\n return since === 0\n ? { changes: [], cursor: 0 }\n : { changes: [], cursor: since, resyncRequired: true };\n }\n\n if (since > horizon) {\n // Foreign or reset cursor — ahead of anything this database allocated.\n return { changes: [], cursor: since, resyncRequired: true };\n }\n\n if (since < floor - 1) {\n // Pruned gap — the changes with seq in (since, floor) are gone for good.\n return { changes: [], cursor: since, resyncRequired: true };\n }\n\n if (horizon === since) {\n return { changes: [], cursor: since };\n }\n\n const conditions: string[] = [];\n const params: unknown[] = [];\n let index = 0;\n const next = () => p(++index);\n\n conditions.push(`seq > ${next()}`);\n params.push(since);\n conditions.push(`seq <= ${next()}`);\n params.push(horizon);\n\n const tables = options.tables?.filter((table) => table.trim().length > 0);\n if (tables && tables.length > 0) {\n conditions.push(`table_name IN (${tables.map(() => next()).join(', ')})`);\n params.push(...tables);\n }\n\n if (options.tenantId === null) {\n conditions.push('tenant_id IS NULL');\n } else if (typeof options.tenantId === 'string') {\n conditions.push(`(tenant_id = ${next()} OR tenant_id IS NULL)`);\n params.push(options.tenantId);\n }\n\n const sql =\n 'SELECT seq, table_name, row_id, operation, tenant_id, created_at ' +\n `FROM ${CHANGE_FEED_TABLE} WHERE ${conditions.join(' AND ')} ` +\n `ORDER BY seq ASC LIMIT ${next()}`;\n params.push(limit);\n\n const rows = getQueryRows(await db.query(sql, ...params));\n const changes = rows.map(rowToEntry);\n\n // Page limited → resume after the last returned row. Page exhaustive →\n // everything up to the horizon (matching or filtered out) has been\n // observed, so advance all the way.\n const cursor =\n changes.length === limit ? changes[changes.length - 1].seq : horizon;\n\n return { changes, cursor };\n}\n\n/**\n * {@link getChangesSince} scoped by the active tenant context.\n *\n * Resolves the tenant through the same dependency-inversion hook the\n * DispatchBus uses ({@link resolveDispatchTenantScope}), so it works without\n * core depending on `@happyvertical/smrt-tenancy`:\n *\n * - Tenancy disabled (no resolver registered) → no tenant filter.\n * - Tenancy enabled with an active tenant `T` → `T`'s rows plus global rows.\n * - Tenancy enabled with **no** active tenant → global rows only\n * (**fail-closed**: a missing context never widens visibility to all\n * tenants).\n *\n * This is the read the generated `_changes` routes call after establishing\n * tenant context from the authenticated principal.\n */\nexport async function getTenantScopedChangesSince(\n db: DatabaseInterface,\n options: Omit<GetChangesOptions, 'tenantId'>,\n): Promise<ChangeFeedPage> {\n const scope = resolveDispatchTenantScope();\n if (!scope.enforced) {\n return getChangesSince(db, options);\n }\n return getChangesSince(db, { ...options, tenantId: scope.tenantId });\n}\n\n/**\n * The per-table change version — the ETag source for zero-query conditional\n * GETs (#1765).\n *\n * Returns `MAX(seq)` over the feed rows for `table`: a monotonic number that\n * advances on every framework write to that table (create/update/delete, and\n * writes through the sync-apply endpoint, which all `save()`/`delete()`).\n * Because sequences are the change feed's globally-monotonic cursor dimension\n * (allocated `MAX+1` at commit time, never a native identity — see the module\n * docs), the value is **replica-stable**: two processes reading the same\n * committed database compute the same version, with no per-process divergence.\n * That is what lets a generated read route derive an ETag that short-circuits a\n * matching `If-None-Match` into a `304` before the collection query runs — an\n * unchanged table costs one indexed `MAX(seq)` lookup (backed by\n * `idx_smrt_changes_table_seq`) to revalidate, not a table scan.\n *\n * ## Why the fallback to the global horizon (and not 0)\n *\n * A table with no *retained* feed entry falls back to the global horizon\n * (`MAX(seq)` across all tables), returning 0 only when the whole feed is\n * empty. Retention prunes oldest-first and always keeps the newest entry, so a\n * quiet table can lose all of its own entries while busier tables advance. If\n * such a table reported 0, a client that cached it while it was empty (version\n * 0) could, after a change→prune→change→prune cycle returned the lookup to 0,\n * be wrongly answered `304` against data that has since changed — a false-304.\n *\n * The horizon fallback closes that hole: any write to the table appends a new\n * sequence strictly greater than every previously-observed value (its own or\n * the horizon), so the version — and therefore the ETag — strictly exceeds any\n * value a client already holds, forcing a fresh `200`. The only cost is that a\n * table with no retained entries of its own revalidates whenever the global\n * horizon moves; a table with a retained entry uses its own stable `MAX(seq)`\n * and is unaffected by writes to sibling tables. A persistent per-table\n * high-water mark that survives pruning would remove even that cost; it is a\n * deliberate follow-up, out of scope for this slice.\n *\n * Idempotently ensures the feed table exists first, so it is safe to call from\n * a read route on a raw handle that has never been written to.\n */\nexport async function getTableVersion(\n db: DatabaseInterface,\n table: string,\n): Promise<number> {\n const name = table?.trim();\n if (!name) {\n throw new Error('getTableVersion requires a non-empty table name');\n }\n await ensureChangeFeedTable(db);\n\n const p = placeholders(db);\n const tableRows = getQueryRows(\n await db.query(\n `SELECT MAX(seq) AS version FROM ${CHANGE_FEED_TABLE} WHERE table_name = ${p(1)}`,\n name,\n ),\n );\n const tableVersion = tableRows[0]?.version;\n if (tableVersion != null) {\n return toSeqNumber(tableVersion);\n }\n\n // No retained entry for this table — fall back to the global horizon so an\n // all-pruned (or never-written) table never reports a resettable low value\n // that could false-304 a stale client. 0 only when the feed is empty.\n const horizonRows = getQueryRows(\n await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`),\n );\n return toSeqNumber(horizonRows[0]?.horizon);\n}\n\nfunction toSeqNumber(value: unknown): number {\n // Postgres adapters may surface BIGINT aggregates as strings.\n const parsed = typeof value === 'number' ? value : Number(value ?? 0);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction rowToEntry(row: Record<string, unknown>): ChangeFeedEntry {\n return {\n seq: toSeqNumber(row.seq),\n table: String(row.table_name ?? ''),\n rowId: row.row_id == null ? null : String(row.row_id),\n operation: String(row.operation ?? 'update') as ChangeOperation,\n tenantId: row.tenant_id == null ? null : String(row.tenant_id),\n timestamp: normalizeTimestamp(row.created_at),\n };\n}\n\nfunction normalizeTimestamp(value: unknown): string {\n if (value instanceof Date) return value.toISOString();\n return String(value ?? '');\n}\n\n// ============================================================================\n// Retention / compaction\n// ============================================================================\n\n/**\n * Prune the change feed to bound its growth.\n *\n * Applies whichever bounds are provided (at least one is required):\n * - `maxRows`: keep only the newest N entries by sequence.\n * - `maxAgeMs`: drop entries older than the cutoff.\n *\n * Pruning deletes oldest-first, never renumbers surviving entries, and\n * **always retains the newest entry** (a non-empty feed is never emptied,\n * whatever the bounds say). That invariant anchors pruned-cursor detection:\n * retained sequences stay a contiguous run `[floor..horizon]`, so\n * {@link getChangesSince} can prove a cursor predates the retained window\n * (`resyncRequired`) — and a fully caught-up consumer keeps polling\n * normally even after everything older was pruned.\n *\n * Cursors within the retained window keep working. Schedule pruning (e.g.\n * via `@happyvertical/smrt-jobs`) with a retention window comfortably\n * larger than the slowest consumer's polling interval; consumers whose\n * cursor falls out of it are told to full-resync via `resyncRequired`.\n *\n * @returns The number of entries pruned (approximate under concurrent prunes).\n */\nexport async function pruneChangeFeed(\n db: DatabaseInterface,\n retention: ChangeFeedRetention,\n): Promise<{ pruned: number }> {\n const { maxAgeMs, maxRows } = retention;\n if (maxAgeMs == null && maxRows == null) {\n throw new Error('pruneChangeFeed requires maxAgeMs and/or maxRows');\n }\n if (maxAgeMs != null && (!Number.isFinite(maxAgeMs) || maxAgeMs < 0)) {\n throw new Error(`pruneChangeFeed maxAgeMs must be >= 0, got ${maxAgeMs}`);\n }\n if (maxRows != null && (!Number.isFinite(maxRows) || maxRows < 0)) {\n throw new Error(`pruneChangeFeed maxRows must be >= 0, got ${maxRows}`);\n }\n\n const p = placeholders(db);\n\n // Snapshot the horizon once: both bounds prune strictly below it so the\n // newest entry always survives (see resync-detection contract above).\n const horizonRows = getQueryRows(\n await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`),\n );\n const horizon = toSeqNumber(horizonRows[0]?.horizon);\n if (horizon === 0) {\n return { pruned: 0 };\n }\n\n let pruned = 0;\n\n if (maxRows != null) {\n const pruneThrough = Math.min(horizon - Math.floor(maxRows), horizon - 1);\n if (pruneThrough > 0) {\n pruned += await deleteCounted(db, `seq <= ${p(1)}`, [pruneThrough]);\n }\n }\n\n if (maxAgeMs != null) {\n const cutoff = new Date(Date.now() - maxAgeMs).toISOString();\n pruned += await deleteCounted(\n db,\n `created_at < ${p(1)} AND seq < ${p(2)}`,\n [cutoff, horizon],\n );\n }\n\n return { pruned };\n}\n\nasync function deleteCounted(\n db: DatabaseInterface,\n condition: string,\n params: unknown[],\n): Promise<number> {\n const countRows = getQueryRows(\n await db.query(\n `SELECT COUNT(*) AS total FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`,\n ...params,\n ),\n );\n const total = toSeqNumber(countRows[0]?.total);\n if (total > 0) {\n await db.query(\n `DELETE FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`,\n ...params,\n );\n }\n return total;\n}\n\n// ============================================================================\n// Framework writer (GlobalInterceptors registration)\n// ============================================================================\n\nconst WAS_PERSISTED_KEY = '_smrtChangeFeedWasPersisted';\n\n/** Databases we already warned about after a failed feed append. */\nconst warnedAppendFailures = new Set<string>();\n\n/**\n * Register the change-feed writer with {@link GlobalInterceptors}.\n *\n * Called automatically during framework initialization (every\n * `SmrtClass.initialize()` passes through it), so applications never need\n * to call it directly; it is exported for tests and for re-registering\n * after `GlobalInterceptors.clear()`. Idempotent — a second call while the\n * writer is registered is a no-op.\n *\n * The writer observes the same hooks the reports scheduler and tenancy\n * interceptors use:\n * - `beforeSave` stashes whether the instance was already persisted (this\n * is what distinguishes `create` from `update` in the feed).\n * - `afterSave`/`afterDelete` append exactly one change entry per framework\n * save/delete. `_smrt_*` system tables are skipped — the feed observes\n * application data, not framework bookkeeping (and never itself).\n *\n * Failure policy: appends run after the user's write succeeded and must not\n * un-succeed it — failures are logged (deduped per database) and swallowed.\n */\nexport function registerChangeFeedWriter(): void {\n if (\n GlobalInterceptors.getAll().some(\n (interceptor) => interceptor.name === CHANGE_FEED_INTERCEPTOR_NAME,\n )\n ) {\n return;\n }\n\n GlobalInterceptors.register({\n name: CHANGE_FEED_INTERCEPTOR_NAME,\n // Below tenancy (100) so tenantId auto-population precedes the stash;\n // above the reports refresh interceptor (-10) so a triggered refresh\n // can already observe the appended change entry.\n priority: 0,\n\n beforeSave(instance: SmrtObject, context: InterceptorContext): void {\n try {\n context.metadata = {\n ...context.metadata,\n [WAS_PERSISTED_KEY]: instance.isPersisted === true,\n };\n } catch {\n // Never let feed bookkeeping block a save.\n }\n },\n\n async afterSave(\n instance: SmrtObject,\n context: InterceptorContext,\n ): Promise<void> {\n const wasPersisted = context.metadata?.[WAS_PERSISTED_KEY] === true;\n await appendForInstance(instance, wasPersisted ? 'update' : 'create');\n },\n\n async afterDelete(instance: SmrtObject): Promise<void> {\n await appendForInstance(instance, 'delete');\n },\n });\n}\n\n/** Unregister the change-feed writer (test helper). */\nexport function unregisterChangeFeedWriter(): boolean {\n return GlobalInterceptors.unregister(CHANGE_FEED_INTERCEPTOR_NAME);\n}\n\nasync function appendForInstance(\n instance: SmrtObject,\n operation: ChangeOperation,\n): Promise<void> {\n let db: DatabaseInterface;\n let table: string;\n try {\n table = instance.tableName;\n // System tables are framework bookkeeping, not client-syncable data —\n // recording them would let the feed observe (and re-observe) itself.\n if (!table || table.startsWith('_smrt_')) return;\n db = instance.db;\n } catch {\n // Not a fully initialized SmrtObject (e.g. plain-object doubles in\n // tests) — nothing to record.\n return;\n }\n\n try {\n const id = (instance as { id?: unknown }).id;\n const tenantId = (instance as unknown as Record<string, unknown>).tenantId;\n await appendChange(db, {\n table,\n rowId: typeof id === 'string' && id ? id : null,\n operation,\n tenantId: typeof tenantId === 'string' && tenantId ? tenantId : null,\n });\n } catch (error) {\n warnAppendFailureOnce(db, table, error);\n }\n}\n\nfunction warnAppendFailureOnce(\n db: DatabaseInterface,\n table: string,\n error: unknown,\n): void {\n try {\n const dbKey = resolveDbCacheKey(db);\n if (warnedAppendFailures.has(dbKey)) return;\n warnedAppendFailures.add(dbKey);\n logger.warn(\n `Change feed: failed to append a change entry for '${table}'. The ` +\n 'write itself succeeded; the feed is missing this change (further ' +\n 'failures for this database are suppressed). Consumers recover on ' +\n 'full resync.',\n { error: error instanceof Error ? error.message : String(error) },\n );\n } catch {\n // Logging must never propagate into the write path.\n }\n}\n\n/**\n * Reset the append-failure warning dedup (test helper).\n */\nexport function resetChangeFeedWarnings(): void {\n warnedAppendFailures.clear();\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"change-feed.js","names":[],"sources":["../src/change-feed.ts"],"sourcesContent":["/**\n * Adapter-agnostic change feed — the framework's change-observation spine\n * (issue #1758, parent PRD #1755).\n *\n * Every framework `save()`/`delete()` appends exactly one row to the\n * `_smrt_changes` system table (monotonic per-database sequence, table name,\n * row id, operation, tenant id, timestamp). Deletes are recorded as\n * tombstones (`operation: 'delete'`), distinguishable from updates. One read\n * interface — {@link getChangesSince} — returns changes after a cursor,\n * filterable by table and tenant, and serves three eventual consumers:\n * client delta pull, the SSE push channel, and the per-table version source\n * backing ETags.\n *\n * ## Cursor semantics (the precise guarantee)\n *\n * Sequences are allocated *inside* the append statement as\n * `COALESCE(MAX(seq), 0) + 1` over the feed table itself, with a retry on\n * primary-key conflict. `MAX(seq)` only observes committed rows, so a row\n * with sequence `N` can only be inserted while every row with sequence\n * `< N` is already committed (a conflicting in-flight allocation of the same\n * value blocks, then retries). Committed rows therefore always form a\n * contiguous run ending at `MAX(seq)` — the **committed horizon**. Sequence\n * order equals commit order; out-of-order commit visibility (the classic\n * MVCC race that makes native identity/serial columns unsafe as cursors\n * under concurrent writers) cannot occur.\n *\n * {@link getChangesSince} reads the committed horizon `H = MAX(seq)`, then\n * returns matching rows with `since < seq <= H` (bounded by `limit`), and a\n * `cursor` that is either `H` (page exhaustive) or the last returned `seq`\n * (page limited). Because no change can ever commit at or below an observed\n * horizon after it was observed, polling with returned cursors misses no\n * committed change and never returns the same change twice — under any\n * number of concurrent writers, identically on SQLite, Postgres and DuckDB.\n * This is the design reason the allocator is `MAX+1` rather than a native\n * AUTOINCREMENT/identity column: identity values are allocated before\n * commit, so a reader on Postgres could observe seq 101 while seq 100 is\n * still uncommitted and advance its cursor past it. (No shared\n * auto-increment mechanism exists in the system-table schema path either;\n * see `system/schema.ts`.)\n *\n * Contention note: appends serialize on the head of the log. Each append is\n * a single small INSERT (issued from the write path *after* the user's row\n * was written), so the serialization window is one statement; conflicts\n * resolve with a bounded retry loop and are impossible on single-writer\n * engines (SQLite).\n *\n * ## Failure semantics\n *\n * A feed-write failure must never fail the user's write. The interceptor\n * wraps the append in a try/catch: on failure it logs a warning (deduped per\n * database) and continues. The trade-off is availability of the user's\n * write over completeness of the feed — consumers already need a\n * full-resync path for cursors older than the retention window, and the\n * same path covers a (rare) dropped feed row. When the user's write runs\n * inside a caller-managed transaction on the same handle, the append joins\n * it and shares its fate (a rollback removes the change row with the data\n * row).\n *\n * ## Known gaps (documented in the PRD)\n *\n * - Writes that bypass framework mutation paths (raw SQL) are invisible to\n * the feed — the same accepted gap as the #1499 collection cache.\n * {@link bumpChangeFeed} is the manual escape hatch: out-of-band writers\n * append a synthetic change row for the affected table.\n * - **Spurious `update` entries**: `SmrtObject.save()` has no dirty-check,\n * so a field-unchanged `.save()` still appends an `update` row. This is\n * by design — the writer observes writes, not diffs (it has no old-row\n * access), so the feed faithfully mirrors the write path. Diff-aware\n * paths (`getOrUpsert()`'s diff guard, the sync-apply endpoint's no-op\n * detection) short-circuit before `save()` and append nothing.\n * Subscribers must tolerate spurious entries; they are convergent — a\n * re-fetch returns identical data.\n *\n * ## Retention\n *\n * The log is append-only and grows with write volume. {@link pruneChangeFeed}\n * bounds it by age (`maxAgeMs`) and/or row count (`maxRows`); call it from a\n * scheduled job sized so the retention window comfortably exceeds the\n * slowest consumer's polling interval. Pruning deletes oldest-first and\n * always retains the newest entry, so retained sequences stay a contiguous\n * `[floor..horizon]` run — which is how {@link getChangesSince} *detects* a\n * consumer whose cursor predates the retained window and answers it with\n * `resyncRequired: true` plus a fresh resume cursor instead of silently\n * skipping the pruned changes.\n *\n * @see https://github.com/happyvertical/smrt/issues/1758\n * @packageDocumentation\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { publishChangeSignal } from './change-signals.js';\nimport { resolveDbCacheKey } from './collection-cache.js';\nimport { resolveDispatchTenantScope } from './dispatch/tenant-resolver.js';\nimport { GlobalInterceptors, type InterceptorContext } from './interceptors.js';\nimport type { SmrtObject } from './object.js';\nimport { detectEngine } from './schema/ddl/index.js';\nimport { CREATE_SMRT_CHANGES_TABLE } from './system/schema.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/** Name of the append-only change-feed system table. */\nexport const CHANGE_FEED_TABLE = '_smrt_changes';\n\n/** Interceptor name of the framework's change-feed writer. */\nexport const CHANGE_FEED_INTERCEPTOR_NAME = 'smrt-change-feed';\n\n/**\n * Change operations recorded in the feed. Deletes are tombstones —\n * consumers can distinguish \"row changed\" from \"row is gone\" without\n * consulting the source table.\n */\nexport type ChangeOperation = 'create' | 'update' | 'delete';\n\n/** One entry of the change feed. */\nexport interface ChangeFeedEntry {\n /** Strictly monotonic per-database sequence (the cursor dimension). */\n seq: number;\n /** Physical table the change happened in (STI children report the shared base table). */\n table: string;\n /**\n * Primary key of the changed row, or `null` for table-level synthetic\n * bumps recorded via {@link bumpChangeFeed} without a row id.\n */\n rowId: string | null;\n /** What happened. `'delete'` entries double as tombstones. */\n operation: ChangeOperation;\n /** Tenant the changed row belongs to, or `null` for global/non-tenant rows. */\n tenantId: string | null;\n /** ISO-8601 timestamp recorded when the change was appended. */\n timestamp: string;\n}\n\n/** Options for {@link getChangesSince}. */\nexport interface GetChangesOptions {\n /**\n * Cursor to read after. Only rows with `seq` strictly greater than `since`\n * are returned; pass a previously returned {@link ChangeFeedPage.cursor} to\n * poll.\n *\n * `0` reads from the start of the log only while it has not been pruned past\n * the beginning. Once retention has raised the retained floor above the\n * start, `since: 0` (like any cursor older than the retained window) can no\n * longer be served incrementally — the read returns\n * {@link ChangeFeedPage.resyncRequired} and the caller must do a full\n * resync before resuming from {@link ChangeFeedPage.resyncCursor}.\n */\n since: number;\n /** Restrict to these physical table names. Empty/omitted → all tables. */\n tables?: string[];\n /**\n * Tenant visibility filter:\n * - omitted/`undefined` → no tenant filter (all rows).\n * - `null` → only global rows (`tenant_id IS NULL`).\n * - `'<tenantId>'` → that tenant's rows **plus** global rows, matching the\n * DispatchBus read rule (`tenant_id = T OR tenant_id IS NULL`). A tenant\n * never sees another tenant's changes.\n */\n tenantId?: string | null;\n /**\n * Page size (default {@link DEFAULT_CHANGES_LIMIT}, capped at\n * {@link MAX_CHANGES_LIMIT}). When a page fills up, the returned cursor\n * stops at the last returned row so the next poll continues seamlessly.\n */\n limit?: number;\n}\n\n/** Result page of {@link getChangesSince}. */\nexport interface ChangeFeedPage {\n /** Matching changes ordered by ascending `seq`. */\n changes: ChangeFeedEntry[];\n /**\n * The next cursor. Monotonic: never lower than the `since` it was derived\n * from. Equal to the committed horizon when the page was exhaustive, or to\n * the last returned `seq` when the page hit `limit`. Feed the value back\n * as `since` to observe every later change exactly once.\n */\n cursor: number;\n /**\n * Present (and `true`) when the supplied cursor cannot be served\n * incrementally and the consumer must fall back to a full resync:\n *\n * - the cursor predates the retained window (entries at or below it were\n * pruned away — the changes between it and the retained floor are gone\n * for good), or\n * - the cursor is ahead of the committed horizon / unknown to this\n * database (a foreign or reset cursor).\n *\n * When set, `changes` is empty and `cursor` echoes `since` unchanged.\n * After its full data refetch, the consumer should resume polling from\n * {@link resyncCursor}, the committed horizon observed by this read.\n * Detection is computed on the **unfiltered** log: `tables`/`tenantId`\n * filters legitimately hide rows and never trigger (or mask) a resync\n * signal.\n */\n resyncRequired?: boolean;\n /**\n * Current committed horizon to use after handling a resync. Present with\n * {@link resyncRequired}; separated from `cursor` so old callers that rely\n * on `cursor` echoing the rejected value keep their monotonic-cursor\n * invariant.\n */\n resyncCursor?: number;\n}\n\n/** Input for {@link appendChange} / {@link bumpChangeFeed}. */\nexport interface AppendChangeInput {\n /** Physical table name the change refers to. */\n table: string;\n /** Changed row's primary key; `null`/omitted records a table-level change. */\n rowId?: string | null;\n /** Operation to record (default `'update'`). */\n operation?: ChangeOperation;\n /** Tenant the change belongs to (default `null` = global). */\n tenantId?: string | null;\n}\n\n/** Retention bounds for {@link pruneChangeFeed}. At least one is required. */\nexport interface ChangeFeedRetention {\n /** Prune entries older than this many milliseconds. */\n maxAgeMs?: number;\n /** Keep at most this many newest entries (by sequence). */\n maxRows?: number;\n}\n\n/** Default page size for {@link getChangesSince}. */\nexport const DEFAULT_CHANGES_LIMIT = 500;\n\n/** Hard cap on the page size for {@link getChangesSince}. */\nexport const MAX_CHANGES_LIMIT = 5_000;\n\n/**\n * Maximum append attempts under sequence contention. Conflicts only occur\n * with concurrent writers on MVCC engines and resolve as soon as the\n * blocking transaction commits, so a small bound is ample.\n */\nconst MAX_APPEND_ATTEMPTS = 20;\n\nconst VALID_OPERATIONS: ReadonlySet<string> = new Set([\n 'create',\n 'update',\n 'delete',\n]);\n\n// ============================================================================\n// Engine / SQL helpers (mirrors system/compatibility.ts conventions)\n// ============================================================================\n\ntype DatabaseWithConfig = DatabaseInterface & {\n config?: { type?: string; url?: string };\n type?: string;\n};\n\nfunction getEngine(db: DatabaseInterface): ReturnType<typeof detectEngine> {\n const withConfig = db as DatabaseWithConfig;\n return detectEngine(\n db.url || withConfig.config?.url || '',\n withConfig.type || withConfig.config?.type,\n );\n}\n\n/**\n * Positional placeholder factory: Postgres uses `$n`, SQLite/DuckDB use `?`.\n */\nfunction placeholders(db: DatabaseInterface): (index: number) => string {\n const engine = getEngine(db);\n return engine === 'postgres' ? (index) => `$${index}` : () => '?';\n}\n\nfunction getQueryRows(result: unknown): Record<string, unknown>[] {\n if (Array.isArray(result)) {\n return result as Record<string, unknown>[];\n }\n if (result && typeof result === 'object' && 'rows' in result) {\n const rows = (result as { rows?: unknown }).rows;\n if (Array.isArray(rows)) {\n return rows as Record<string, unknown>[];\n }\n }\n return [];\n}\n\nfunction isUniqueViolation(error: unknown): boolean {\n const message = error instanceof Error ? error.message : String(error ?? '');\n return (\n /unique constraint/i.test(message) ||\n /duplicate key/i.test(message) ||\n /primary key constraint/i.test(message) ||\n /constraint error/i.test(message)\n );\n}\n\n/**\n * Ensure the `_smrt_changes` system table exists on a database handle that\n * may not have passed through framework initialization (e.g. a raw handle\n * given to the REST generator). Idempotent (`CREATE ... IF NOT EXISTS`) and\n * guarded to run once per handle. Databases initialized through the\n * framework already have the table via the system-table bootstrap.\n */\nconst ensuredHandles = new WeakSet<object>();\n\nexport async function ensureChangeFeedTable(\n db: DatabaseInterface,\n): Promise<void> {\n if (ensuredHandles.has(db)) return;\n const statements = CREATE_SMRT_CHANGES_TABLE.split(';')\n .map((statement) => statement.trim())\n .filter((statement) => statement.length > 0);\n for (const statement of statements) {\n await db.query(statement);\n }\n ensuredHandles.add(db);\n}\n\n// ============================================================================\n// Append (writer primitive + manual bump escape hatch)\n// ============================================================================\n\n/**\n * Append one change entry with a database-allocated, strictly monotonic\n * sequence.\n *\n * The sequence is allocated inside the INSERT itself\n * (`COALESCE(MAX(seq), 0) + 1`) and retried on primary-key conflict, which\n * keeps committed sequences contiguous and makes commit order equal\n * sequence order — the property the cursor guarantee rests on (see the\n * module docs). Throws after {@link MAX_APPEND_ATTEMPTS} consecutive\n * conflicts or on any non-conflict database error; the framework's\n * interceptor catches and logs instead of failing the user's write.\n *\n * **Transaction assumption.** The conflict retry assumes an autocommit\n * handle, which is the default framework path: `save()`/`delete()` run their\n * statements as independent autocommit calls, so a conflicting append aborts\n * nothing and the retry recomputes `MAX(seq)` against the committed head. If a\n * caller instead wraps `save()` in its own transaction on an MVCC engine\n * (e.g. Postgres), a duplicate-key error can abort the *surrounding*\n * transaction — the retry is then futile and the caller's write rolls back.\n * Callers wrapping mutations in a transaction under genuine seq-head write\n * contention should disable the feed writer for that path or accept that\n * risk. Savepoint-scoped protection is a possible follow-up; it is\n * intentionally out of scope for the v1 spine.\n */\nexport async function appendChange(\n db: DatabaseInterface,\n input: AppendChangeInput,\n): Promise<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 p = placeholders(db);\n // `RETURNING seq` yields the ACTUAL sequence the INSERT allocated, in the\n // SAME statement — atomic. This matters for correctness, not just tidiness: a\n // separate follow-up `SELECT MAX(seq)` is racy under concurrent appends (a\n // peer can commit a higher seq in between), which would hand two distinct\n // changes the same SSE `id` (a client that dedupes by id drops one → stale)\n // and let a client's `Last-Event-ID` overshoot a change it never received.\n // RETURNING closes that: each caller gets its own row's seq. SQLite (3.35+),\n // Postgres, and DuckDB all support it, and the sql adapters surface the\n // returned rows through `getQueryRows`. 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 `INSERT INTO ${CHANGE_FEED_TABLE} ` +\n '(seq, table_name, row_id, operation, tenant_id, created_at) ' +\n `SELECT COALESCE(MAX(seq), 0) + 1, ${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)} ` +\n `FROM ${CHANGE_FEED_TABLE} RETURNING seq`;\n const params = [\n table,\n input.rowId ?? null,\n operation,\n input.tenantId ?? null,\n new Date().toISOString(),\n ];\n\n for (let attempt = 1; attempt <= MAX_APPEND_ATTEMPTS; attempt++) {\n try {\n const rows = getQueryRows(await db.query(sql, ...params));\n return toSeqNumber(rows[0]?.seq);\n } catch (error) {\n if (!isUniqueViolation(error) || attempt === MAX_APPEND_ATTEMPTS) {\n throw error;\n }\n // Sequence head contention: another append won the value. Re-running\n // recomputes MAX(seq) against the now-committed head.\n }\n }\n\n // Unreachable: the loop returns a seq or throws on the final attempt. Present\n // so the function satisfies its `Promise<number>` contract structurally.\n throw new Error('appendChange exhausted retries without allocating a seq');\n}\n\n/**\n * Manual bump escape hatch for out-of-band writers.\n *\n * Framework mutation paths feed the log automatically, but raw SQL issued\n * outside `save()`/`delete()` is invisible to it (documented gap, shared\n * with the #1499 collection cache). Call this after such a write so feed\n * consumers observe the change. Omitting `rowId` records a table-level\n * change (`rowId: null`), which consumers should treat as \"anything in this\n * table may have changed\".\n *\n * @example\n * ```typescript\n * await db.query(`UPDATE products SET price = price * 1.1`);\n * await bumpChangeFeed(db, { table: 'products' });\n * ```\n */\nexport async function bumpChangeFeed(\n db: DatabaseInterface,\n input: AppendChangeInput,\n): Promise<void> {\n await ensureChangeFeedTable(db);\n await appendChange(db, input);\n}\n\n// ============================================================================\n// Read interface\n// ============================================================================\n\n/**\n * Read committed changes after a cursor.\n *\n * Returns every committed change with `since < seq <= cursor` that matches\n * the filters, ordered by ascending `seq`. The returned cursor is safe to\n * persist and poll with: committed sequences are contiguous (see module\n * docs), so nothing can commit at or below the observed horizon afterwards —\n * reads miss no committed change under concurrent writers and never return\n * the same change twice. When `since` is already at the horizon, returns an\n * empty page with `cursor: since`.\n *\n * ## Resync detection (pruned / foreign cursors)\n *\n * A cursor that cannot be served incrementally is flagged with\n * `resyncRequired: true` (empty `changes`, `cursor` echoed unchanged,\n * `resyncCursor` set to the current horizon) so pollers never go silently,\n * permanently stale:\n *\n * - **Pruned gap**: retained sequences always form a contiguous run\n * `[floor..horizon]` and {@link pruneChangeFeed} deletes oldest-first\n * while always retaining the newest entry, so `since < floor - 1` proves\n * changes between the cursor and the retained window were pruned away.\n * - **Foreign/reset cursor**: `since > horizon` (ahead of anything this\n * database ever allocated), including any `since > 0` against a feed\n * with no entries.\n *\n * Detection runs on the **unfiltered** log — `tables`/`tenantId` filters\n * legitimately hide rows and never trigger (or mask) the signal. A caught-up\n * consumer (`since === horizon`) is never asked to resync, even when\n * retention has pruned everything older.\n *\n * Filters (`tables`, `tenantId`) affect which rows are *returned*, never how\n * the cursor advances — an exhausted filtered page still advances to the\n * horizon so pollers do not rescan filtered-out rows.\n */\nexport async function getChangesSince(\n db: DatabaseInterface,\n options: GetChangesOptions,\n): Promise<ChangeFeedPage> {\n const { since } = options;\n if (!Number.isFinite(since) || since < 0) {\n throw new Error(\n `getChangesSince requires a non-negative numeric cursor, got '${String(since)}'`,\n );\n }\n const limit = Math.min(\n Math.max(Math.floor(options.limit ?? DEFAULT_CHANGES_LIMIT), 1),\n MAX_CHANGES_LIMIT,\n );\n\n const p = placeholders(db);\n\n // The committed horizon: every seq <= horizon is committed and immutable\n // (append-only + contiguous allocation), so the page below is stable even\n // though it runs as a separate statement. The floor bounds the retained\n // window for pruned-cursor detection; both are computed UNFILTERED so\n // table/tenant filters can neither trigger nor mask a resync signal.\n const boundsRows = getQueryRows(\n await db.query(\n `SELECT MIN(seq) AS floor, MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`,\n ),\n );\n const floor = toSeqNumber(boundsRows[0]?.floor);\n const horizon = toSeqNumber(boundsRows[0]?.horizon);\n\n if (horizon === 0) {\n // No entries at all. A zero cursor is simply \"no changes ever\"; any\n // other cursor came from a different database (or a reset feed) and\n // cannot be served incrementally.\n return since === 0\n ? { changes: [], cursor: 0 }\n : { changes: [], cursor: since, resyncRequired: true, resyncCursor: 0 };\n }\n\n if (since > horizon) {\n // Foreign or reset cursor — ahead of anything this database allocated.\n return {\n changes: [],\n cursor: since,\n resyncRequired: true,\n resyncCursor: horizon,\n };\n }\n\n if (since < floor - 1) {\n // Pruned gap — the changes with seq in (since, floor) are gone for good.\n return {\n changes: [],\n cursor: since,\n resyncRequired: true,\n resyncCursor: horizon,\n };\n }\n\n if (horizon === since) {\n return { changes: [], cursor: since };\n }\n\n const conditions: string[] = [];\n const params: unknown[] = [];\n let index = 0;\n const next = () => p(++index);\n\n conditions.push(`seq > ${next()}`);\n params.push(since);\n conditions.push(`seq <= ${next()}`);\n params.push(horizon);\n\n const tables = options.tables?.filter((table) => table.trim().length > 0);\n if (tables && tables.length > 0) {\n conditions.push(`table_name IN (${tables.map(() => next()).join(', ')})`);\n params.push(...tables);\n }\n\n if (options.tenantId === null) {\n conditions.push('tenant_id IS NULL');\n } else if (typeof options.tenantId === 'string') {\n conditions.push(`(tenant_id = ${next()} OR tenant_id IS NULL)`);\n params.push(options.tenantId);\n }\n\n const sql =\n 'SELECT seq, table_name, row_id, operation, tenant_id, created_at ' +\n `FROM ${CHANGE_FEED_TABLE} WHERE ${conditions.join(' AND ')} ` +\n `ORDER BY seq ASC LIMIT ${next()}`;\n params.push(limit);\n\n const rows = getQueryRows(await db.query(sql, ...params));\n const changes = rows.map(rowToEntry);\n\n // Page limited → resume after the last returned row. Page exhaustive →\n // everything up to the horizon (matching or filtered out) has been\n // observed, so advance all the way.\n const cursor =\n changes.length === limit ? changes[changes.length - 1].seq : horizon;\n\n return { changes, cursor };\n}\n\n/**\n * {@link getChangesSince} scoped by the active tenant context.\n *\n * Resolves the tenant through the same dependency-inversion hook the\n * DispatchBus uses ({@link resolveDispatchTenantScope}), so it works without\n * core depending on `@happyvertical/smrt-tenancy`:\n *\n * - Tenancy disabled (no resolver registered) → no tenant filter.\n * - Tenancy enabled with an active tenant `T` → `T`'s rows plus global rows.\n * - Tenancy enabled with **no** active tenant → global rows only\n * (**fail-closed**: a missing context never widens visibility to all\n * tenants).\n *\n * This is the read the generated `_changes` routes call after establishing\n * tenant context from the authenticated principal.\n */\nexport async function getTenantScopedChangesSince(\n db: DatabaseInterface,\n options: Omit<GetChangesOptions, 'tenantId'>,\n): Promise<ChangeFeedPage> {\n const scope = resolveDispatchTenantScope();\n if (!scope.enforced) {\n return getChangesSince(db, options);\n }\n return getChangesSince(db, { ...options, tenantId: scope.tenantId });\n}\n\n/**\n * The per-table change version — the ETag source for zero-query conditional\n * GETs (#1765).\n *\n * Returns `MAX(seq)` over the feed rows for `table`: a monotonic number that\n * advances on every framework write to that table (create/update/delete, and\n * writes through the sync-apply endpoint, which all `save()`/`delete()`).\n * Because sequences are the change feed's globally-monotonic cursor dimension\n * (allocated `MAX+1` at commit time, never a native identity — see the module\n * docs), the value is **replica-stable**: two processes reading the same\n * committed database compute the same version, with no per-process divergence.\n * That is what lets a generated read route derive an ETag that short-circuits a\n * matching `If-None-Match` into a `304` before the collection query runs — an\n * unchanged table costs one indexed `MAX(seq)` lookup (backed by\n * `idx_smrt_changes_table_seq`) to revalidate, not a table scan.\n *\n * ## Why the fallback to the global horizon (and not 0)\n *\n * A table with no *retained* feed entry falls back to the global horizon\n * (`MAX(seq)` across all tables), returning 0 only when the whole feed is\n * empty. Retention prunes oldest-first and always keeps the newest entry, so a\n * quiet table can lose all of its own entries while busier tables advance. If\n * such a table reported 0, a client that cached it while it was empty (version\n * 0) could, after a change→prune→change→prune cycle returned the lookup to 0,\n * be wrongly answered `304` against data that has since changed — a false-304.\n *\n * The horizon fallback closes that hole: any write to the table appends a new\n * sequence strictly greater than every previously-observed value (its own or\n * the horizon), so the version — and therefore the ETag — strictly exceeds any\n * value a client already holds, forcing a fresh `200`. The only cost is that a\n * table with no retained entries of its own revalidates whenever the global\n * horizon moves; a table with a retained entry uses its own stable `MAX(seq)`\n * and is unaffected by writes to sibling tables. A persistent per-table\n * high-water mark that survives pruning would remove even that cost; it is a\n * deliberate follow-up, out of scope for this slice.\n *\n * Idempotently ensures the feed table exists first, so it is safe to call from\n * a read route on a raw handle that has never been written to.\n */\nexport async function getTableVersion(\n db: DatabaseInterface,\n table: string,\n): Promise<number> {\n const name = table?.trim();\n if (!name) {\n throw new Error('getTableVersion requires a non-empty table name');\n }\n await ensureChangeFeedTable(db);\n\n const p = placeholders(db);\n const tableRows = getQueryRows(\n await db.query(\n `SELECT MAX(seq) AS version FROM ${CHANGE_FEED_TABLE} WHERE table_name = ${p(1)}`,\n name,\n ),\n );\n const tableVersion = tableRows[0]?.version;\n if (tableVersion != null) {\n return toSeqNumber(tableVersion);\n }\n\n // No retained entry for this table — fall back to the global horizon so an\n // all-pruned (or never-written) table never reports a resettable low value\n // that could false-304 a stale client. 0 only when the feed is empty.\n const horizonRows = getQueryRows(\n await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`),\n );\n return toSeqNumber(horizonRows[0]?.horizon);\n}\n\nfunction toSeqNumber(value: unknown): number {\n // Postgres adapters may surface BIGINT aggregates as strings.\n const parsed = typeof value === 'number' ? value : Number(value ?? 0);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction rowToEntry(row: Record<string, unknown>): ChangeFeedEntry {\n return {\n seq: toSeqNumber(row.seq),\n table: String(row.table_name ?? ''),\n rowId: row.row_id == null ? null : String(row.row_id),\n operation: String(row.operation ?? 'update') as ChangeOperation,\n tenantId: row.tenant_id == null ? null : String(row.tenant_id),\n timestamp: normalizeTimestamp(row.created_at),\n };\n}\n\nfunction normalizeTimestamp(value: unknown): string {\n if (value instanceof Date) return value.toISOString();\n return String(value ?? '');\n}\n\n// ============================================================================\n// Retention / compaction\n// ============================================================================\n\n/**\n * Prune the change feed to bound its growth.\n *\n * Applies whichever bounds are provided (at least one is required):\n * - `maxRows`: keep only the newest N entries by sequence.\n * - `maxAgeMs`: drop entries older than the cutoff.\n *\n * Pruning deletes oldest-first, never renumbers surviving entries, and\n * **always retains the newest entry** (a non-empty feed is never emptied,\n * whatever the bounds say). That invariant anchors pruned-cursor detection:\n * retained sequences stay a contiguous run `[floor..horizon]`, so\n * {@link getChangesSince} can prove a cursor predates the retained window\n * (`resyncRequired`) — and a fully caught-up consumer keeps polling\n * normally even after everything older was pruned.\n *\n * Cursors within the retained window keep working. Schedule pruning (e.g.\n * via `@happyvertical/smrt-jobs`) with a retention window comfortably\n * larger than the slowest consumer's polling interval; consumers whose\n * cursor falls out of it are told to full-resync via `resyncRequired`.\n *\n * @returns The number of entries pruned (approximate under concurrent prunes).\n */\nexport async function pruneChangeFeed(\n db: DatabaseInterface,\n retention: ChangeFeedRetention,\n): Promise<{ pruned: number }> {\n const { maxAgeMs, maxRows } = retention;\n if (maxAgeMs == null && maxRows == null) {\n throw new Error('pruneChangeFeed requires maxAgeMs and/or maxRows');\n }\n if (maxAgeMs != null && (!Number.isFinite(maxAgeMs) || maxAgeMs < 0)) {\n throw new Error(`pruneChangeFeed maxAgeMs must be >= 0, got ${maxAgeMs}`);\n }\n if (maxRows != null && (!Number.isFinite(maxRows) || maxRows < 0)) {\n throw new Error(`pruneChangeFeed maxRows must be >= 0, got ${maxRows}`);\n }\n\n const p = placeholders(db);\n\n // Snapshot the horizon once: both bounds prune strictly below it so the\n // newest entry always survives (see resync-detection contract above).\n const horizonRows = getQueryRows(\n await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`),\n );\n const horizon = toSeqNumber(horizonRows[0]?.horizon);\n if (horizon === 0) {\n return { pruned: 0 };\n }\n\n let pruned = 0;\n\n if (maxRows != null) {\n const pruneThrough = Math.min(horizon - Math.floor(maxRows), horizon - 1);\n if (pruneThrough > 0) {\n pruned += await deleteCounted(db, `seq <= ${p(1)}`, [pruneThrough]);\n }\n }\n\n if (maxAgeMs != null) {\n const cutoff = new Date(Date.now() - maxAgeMs).toISOString();\n pruned += await deleteCounted(\n db,\n `created_at < ${p(1)} AND seq < ${p(2)}`,\n [cutoff, horizon],\n );\n }\n\n return { pruned };\n}\n\nasync function deleteCounted(\n db: DatabaseInterface,\n condition: string,\n params: unknown[],\n): Promise<number> {\n const countRows = getQueryRows(\n await db.query(\n `SELECT COUNT(*) AS total FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`,\n ...params,\n ),\n );\n const total = toSeqNumber(countRows[0]?.total);\n if (total > 0) {\n await db.query(\n `DELETE FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`,\n ...params,\n );\n }\n return total;\n}\n\n// ============================================================================\n// Framework writer (GlobalInterceptors registration)\n// ============================================================================\n\nconst WAS_PERSISTED_KEY = '_smrtChangeFeedWasPersisted';\n\n/** Databases we already warned about after a failed feed append. */\nconst warnedAppendFailures = new Set<string>();\n\n/** Databases we already warned about after a failed signal publish (#1763). */\nconst warnedSignalPublishFailures = new Set<string>();\n\n/**\n * Register the change-feed writer with {@link GlobalInterceptors}.\n *\n * Called automatically during framework initialization (every\n * `SmrtClass.initialize()` passes through it), so applications never need\n * to call it directly; it is exported for tests and for re-registering\n * after `GlobalInterceptors.clear()`. Idempotent — a second call while the\n * writer is registered is a no-op.\n *\n * The writer observes the same hooks the reports scheduler and tenancy\n * interceptors use:\n * - `beforeSave` stashes whether the instance was already persisted (this\n * is what distinguishes `create` from `update` in the feed).\n * - `afterSave`/`afterDelete` append exactly one change entry per framework\n * save/delete. `_smrt_*` system tables are skipped — the feed observes\n * application data, not framework bookkeeping (and never itself).\n *\n * Failure policy: appends run after the user's write succeeded and must not\n * un-succeed it — failures are logged (deduped per database) and swallowed.\n */\nexport function registerChangeFeedWriter(): void {\n if (\n GlobalInterceptors.getAll().some(\n (interceptor) => interceptor.name === CHANGE_FEED_INTERCEPTOR_NAME,\n )\n ) {\n return;\n }\n\n GlobalInterceptors.register({\n name: CHANGE_FEED_INTERCEPTOR_NAME,\n // Below tenancy (100) so tenantId auto-population precedes the stash;\n // above the reports refresh interceptor (-10) so a triggered refresh\n // can already observe the appended change entry.\n priority: 0,\n\n beforeSave(instance: SmrtObject, context: InterceptorContext): void {\n try {\n context.metadata = {\n ...context.metadata,\n [WAS_PERSISTED_KEY]: instance.isPersisted === true,\n };\n } catch {\n // Never let feed bookkeeping block a save.\n }\n },\n\n async afterSave(\n instance: SmrtObject,\n context: InterceptorContext,\n ): Promise<void> {\n const wasPersisted = context.metadata?.[WAS_PERSISTED_KEY] === true;\n await appendForInstance(instance, wasPersisted ? 'update' : 'create');\n },\n\n async afterDelete(instance: SmrtObject): Promise<void> {\n await appendForInstance(instance, 'delete');\n },\n });\n}\n\n/** Unregister the change-feed writer (test helper). */\nexport function unregisterChangeFeedWriter(): boolean {\n return GlobalInterceptors.unregister(CHANGE_FEED_INTERCEPTOR_NAME);\n}\n\nasync function appendForInstance(\n instance: SmrtObject,\n operation: ChangeOperation,\n): Promise<void> {\n let db: DatabaseInterface;\n let table: string;\n try {\n table = instance.tableName;\n // System tables are framework bookkeeping, not client-syncable data —\n // recording them would let the feed observe (and re-observe) itself.\n if (!table || table.startsWith('_smrt_')) return;\n db = instance.db;\n } catch {\n // Not a fully initialized SmrtObject (e.g. plain-object doubles in\n // tests) — nothing to record.\n return;\n }\n\n try {\n const id = (instance as { id?: unknown }).id;\n const tenantId = (instance as unknown as Record<string, unknown>).tenantId;\n const rowId = typeof id === 'string' && id ? id : null;\n const rowTenantId =\n typeof tenantId === 'string' && tenantId ? tenantId : null;\n const seq = await appendChange(db, {\n table,\n rowId,\n operation,\n tenantId: rowTenantId,\n });\n\n // Publish a coarse live signal for the SSE `_events` route (#1763). This\n // runs only after the durable feed append SUCCEEDED (same try block, so a\n // failed append never emits a signal — \"no signal without a durable feed\n // row\"). Its own try/catch (distinct dedup key) keeps a signal-publish\n // problem from failing the user's write or masking the append's own\n // failure semantics above.\n try {\n publishChangeSignal(db, {\n table,\n operation,\n rowId,\n tenantId: rowTenantId,\n seq,\n });\n } catch (error) {\n warnSignalPublishFailureOnce(db, table, error);\n }\n } catch (error) {\n warnAppendFailureOnce(db, table, error);\n }\n}\n\nfunction warnAppendFailureOnce(\n db: DatabaseInterface,\n table: string,\n error: unknown,\n): void {\n try {\n const dbKey = resolveDbCacheKey(db);\n if (warnedAppendFailures.has(dbKey)) return;\n warnedAppendFailures.add(dbKey);\n logger.warn(\n `Change feed: failed to append a change entry for '${table}'. The ` +\n 'write itself succeeded; the feed is missing this change (further ' +\n 'failures for this database are suppressed). Consumers recover on ' +\n 'full resync.',\n { error: error instanceof Error ? error.message : String(error) },\n );\n } catch {\n // Logging must never propagate into the write path.\n }\n}\n\nfunction warnSignalPublishFailureOnce(\n db: DatabaseInterface,\n table: string,\n error: unknown,\n): void {\n try {\n const dbKey = resolveDbCacheKey(db);\n if (warnedSignalPublishFailures.has(dbKey)) return;\n warnedSignalPublishFailures.add(dbKey);\n logger.warn(\n `Change feed: failed to publish a live change signal for '${table}'. ` +\n 'The write and its durable feed row are unaffected; live SSE ' +\n 'subscribers miss this signal but recover via cursor catch-up ' +\n '(further failures for this database are suppressed).',\n { error: error instanceof Error ? error.message : String(error) },\n );\n } catch {\n // Logging must never propagate into the write path.\n }\n}\n\n/**\n * Reset the append-failure and signal-publish warning dedups (test helper).\n */\nexport function resetChangeFeedWarnings(): void {\n warnedAppendFailures.clear();\n warnedSignalPublishFailures.clear();\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { DatabaseInterface } from '@happyvertical/sql';
|
|
2
|
+
/**
|
|
3
|
+
* Operation carried by a change signal. Kept as a local string union — this
|
|
4
|
+
* module deliberately does NOT import `./change-feed.js` (which imports this
|
|
5
|
+
* one via the writer) so there is no import cycle. It must stay in sync with
|
|
6
|
+
* `ChangeOperation` in `change-feed.ts`.
|
|
7
|
+
*/
|
|
8
|
+
export type ChangeSignalOperation = 'create' | 'update' | 'delete';
|
|
9
|
+
/**
|
|
10
|
+
* A coarse change notification. Carries no row payload by design.
|
|
11
|
+
*/
|
|
12
|
+
export interface ChangeSignal {
|
|
13
|
+
/** Physical table the change happened in. */
|
|
14
|
+
table: string;
|
|
15
|
+
/** What happened (`'delete'` doubles as a tombstone signal). */
|
|
16
|
+
operation: ChangeSignalOperation;
|
|
17
|
+
/** Primary key of the changed row, or `null` for table-level changes. */
|
|
18
|
+
rowId: string | null;
|
|
19
|
+
/** Tenant the changed row belongs to, or `null` for global rows. */
|
|
20
|
+
tenantId: string | null;
|
|
21
|
+
/**
|
|
22
|
+
* The change feed sequence allocated for this change — the cursor a
|
|
23
|
+
* reconnecting client resumes from via `getChangesSince`.
|
|
24
|
+
*/
|
|
25
|
+
seq: number;
|
|
26
|
+
}
|
|
27
|
+
/** A subscriber invoked synchronously for every locally-visible signal. */
|
|
28
|
+
export type ChangeSignalListener = (signal: ChangeSignal) => void;
|
|
29
|
+
/**
|
|
30
|
+
* Notification channel for cross-replica change-signal broadcasts. A distinct
|
|
31
|
+
* channel from the collection cache's — the two buses carry different payloads
|
|
32
|
+
* and evolve independently.
|
|
33
|
+
*/
|
|
34
|
+
export declare const CHANGE_SIGNAL_CHANNEL = "smrt_change_signals";
|
|
35
|
+
/**
|
|
36
|
+
* Subscribe to change signals for a database.
|
|
37
|
+
*
|
|
38
|
+
* Registers `listener` for the database's signal scope and, on the first
|
|
39
|
+
* subscriber for that scope, lazily starts the cross-replica listener (a no-op
|
|
40
|
+
* when the adapter has no notification capability). Returns an unsubscribe
|
|
41
|
+
* function that removes the listener and, when the scope's last subscriber
|
|
42
|
+
* leaves, retracts the cross-replica listener so its refcount reaches 0.
|
|
43
|
+
*
|
|
44
|
+
* Delivery is synchronous: `listener` is invoked from the write path (or the
|
|
45
|
+
* cross-replica loop) inside a per-listener try/catch, so it must not assume
|
|
46
|
+
* an active request or tenant context — capture what it needs at subscribe
|
|
47
|
+
* time.
|
|
48
|
+
*/
|
|
49
|
+
export declare function subscribeToChangeSignals(db: DatabaseInterface, listener: ChangeSignalListener): () => void;
|
|
50
|
+
/**
|
|
51
|
+
* Publish a change signal: synchronous local fan-out, then fire-and-forget
|
|
52
|
+
* cross-replica broadcast. Never throws to the caller — a signal problem must
|
|
53
|
+
* never fail the user's write.
|
|
54
|
+
*/
|
|
55
|
+
export declare function publishChangeSignal(db: DatabaseInterface, signal: ChangeSignal): void;
|
|
56
|
+
/**
|
|
57
|
+
* Broadcast a signal to peer replicas over the adapter's notification
|
|
58
|
+
* capability. Resolves normally (never throws) whether or not a capability
|
|
59
|
+
* exists — a missing capability warns once per scope and is not an error.
|
|
60
|
+
*
|
|
61
|
+
* Fire-and-forget from the write path: a broadcast failure must never fail the
|
|
62
|
+
* write that triggered it.
|
|
63
|
+
*/
|
|
64
|
+
export declare function broadcastChangeSignal(db: DatabaseInterface, signal: ChangeSignal): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* Stop all cross-replica change-signal listeners. Used by tests and during
|
|
67
|
+
* shutdown; safe to call when none are active.
|
|
68
|
+
*/
|
|
69
|
+
export declare function stopChangeSignalListeners(): void;
|
|
70
|
+
/**
|
|
71
|
+
* Clear all local subscribers, stop cross-replica listeners, and reset the
|
|
72
|
+
* no-capability warning dedup. Call in test setup for isolation between files.
|
|
73
|
+
*/
|
|
74
|
+
export declare function resetChangeSignals(): void;
|
|
75
|
+
/**
|
|
76
|
+
* Number of active local subscribers for a database scope (test helper).
|
|
77
|
+
*
|
|
78
|
+
* Exposed so integration tests can assert teardown — a leaked SSE subscription
|
|
79
|
+
* would keep this above 0 after a client disconnects. Not part of the public
|
|
80
|
+
* API surface (kept internal to core; not re-exported from `index.ts`).
|
|
81
|
+
*/
|
|
82
|
+
export declare function changeSignalSubscriberCount(db: DatabaseInterface): number;
|
|
83
|
+
//# sourceMappingURL=change-signals.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"change-signals.d.ts","sourceRoot":"","sources":["../src/change-signals.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAS5D;;;;;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;AAoB3D;;;;;;;;;;;;;GAaG;AACH,wBAAgB,wBAAwB,CACtC,EAAE,EAAE,iBAAiB,EACrB,QAAQ,EAAE,oBAAoB,GAC7B,MAAM,IAAI,CA2BZ;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,CAGzC;AAED;;;;;;GAMG;AACH,wBAAgB,2BAA2B,CAAC,EAAE,EAAE,iBAAiB,GAAG,MAAM,CAEzE"}
|