@happyvertical/smrt-core 0.38.9 → 0.38.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/AGENTS.md +3 -3
  2. package/dist/change-signals.d.ts +16 -4
  3. package/dist/change-signals.d.ts.map +1 -1
  4. package/dist/change-signals.js +50 -5
  5. package/dist/change-signals.js.map +1 -1
  6. package/dist/generators/events-route.d.ts +44 -0
  7. package/dist/generators/events-route.d.ts.map +1 -1
  8. package/dist/generators/events-route.js +60 -4
  9. package/dist/generators/events-route.js.map +1 -1
  10. package/dist/generators/index.d.ts +2 -2
  11. package/dist/generators/index.d.ts.map +1 -1
  12. package/dist/generators/index.js +3 -3
  13. package/dist/generators/rest.d.ts +26 -7
  14. package/dist/generators/rest.d.ts.map +1 -1
  15. package/dist/generators/rest.js +84 -5
  16. package/dist/generators/rest.js.map +1 -1
  17. package/dist/generators.js +3 -3
  18. package/dist/index.js +3 -3
  19. package/dist/manifest/static-manifest.js +2 -2
  20. package/dist/manifest/static-manifest.js.map +1 -1
  21. package/dist/manifest/store.js +1 -1
  22. package/dist/manifest/test-manifest-stub.js +2 -2
  23. package/dist/manifest/test-manifest-stub.js.map +1 -1
  24. package/dist/manifest.json +2 -2
  25. package/dist/smrt-knowledge.json +6 -6
  26. package/dist/vite-plugin/events-route.d.ts +1 -1
  27. package/dist/vite-plugin/events-route.d.ts.map +1 -1
  28. package/dist/vite-plugin/events-route.js +19 -4
  29. package/dist/vite-plugin/events-route.js.map +1 -1
  30. package/dist/vite-plugin/index.d.ts +2 -1
  31. package/dist/vite-plugin/index.d.ts.map +1 -1
  32. package/dist/vite-plugin/index.js +2 -2
  33. package/dist/vite-plugin/index.js.map +1 -1
  34. package/dist/vite-plugin/sveltekit-generator.d.ts +3 -1
  35. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  36. package/dist/vite-plugin/sveltekit-generator.js +2 -2
  37. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  38. package/package.json +10 -10
package/AGENTS.md CHANGED
@@ -90,8 +90,8 @@ The push companion to the change feed (`src/change-signals.ts` + the generated `
90
90
 
91
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
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).
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 or subscriber capacity reached) and SvelteKit (`{routesDir}/_events/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.eventsRoute.enabled: false`; cap via `sveltekit.eventsRoute.maxSubscribers`, where `0` means unlimited). 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?, manifestHash? })` (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`, optional connection-open `event: manifest` carrying `{ manifestHash }` for live contract detection, 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 change frame: `id: <seq>\nevent: change\ndata: {table,operation,rowId,tenantId}\n\n` — seq is ONLY in the `id:` line, never the data JSON. Subscriber cap default is `DEFAULT_EVENTS_MAX_SUBSCRIBERS` = 1000; over-cap connections return retryable 503 + `Retry-After`, and existing subscribers are unaffected. **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): 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
95
 
96
96
  ## Single Table Inheritance (STI)
97
97
 
@@ -112,7 +112,7 @@ The push companion to the change feed (`src/change-signals.ts` + the generated `
112
112
 
113
113
  The web module also emits a build-time **`manifestHash`** constant (#1764): `computeWebManifestHash(manifest)` is a deterministic, replica-stable digest of the emitted web-collection SHAPE (name/className/endpoint/idField/actions/fields/relationships), canonicalized (recursive key sort) before `sha256 → base64url`, truncated to 16 chars — so the same schema always hashes the same, and a field add/remove/type-change/edge-change changes it. A change means old persisted client rows may mis-hydrate, so smrt-web keys its durable persistence namespace on it and its `updateAvailable` contract signal compares against it. Three emission sites must not drift: the runtime value (`generateWebModule`), the `@happyvertical/smrt-virt-web` ambient d.ts (`vite-plugin/index.ts`), and the physical `@smrt/web` d.ts (`prebuild/index.ts`).
114
114
 
115
- Generated reads (`list`/`get`) on the REST and SvelteKit generators support conditional GET (helpers in `src/generators/conditional-get.ts`). ETag v2 (#1765): the validator is the table's change-feed version (`getTableVersion`) keyed by the request representation, so a **concrete** `If-None-Match` short-circuits into a 304 with an empty body **before** the collection query runs — an unchanged table revalidates with zero table scan. A wildcard `If-None-Match: *` is deferred until the payload builds (existence confirmed), so a missing item still returns 404, not a false 304. Tenant-scoped reads fold the active tenant into the representation (`resolveTenantEtagDiscriminator`) so one tenant's cached validator never satisfies another's read of the same URL. Routes whose GET renders via a **custom serializer** (which can load related tables the base-table version can't observe) keep the v1 body-hash ETag (`#1757`, query-first but correct); the default `toPublicJSON` path — all REST reads and non-serializer SvelteKit reads — uses v2. v2 is weakly consistent by design (the cost of not reading the data): a revalidation in the sub-statement window between a committed write and its feed append can return a stale 304 that self-heals on the next revalidation. The other v2 window — a deploy that changes the response shape WITHOUT a table write — is closed by the **#1764 ETag salt**: `computeTableVersionEtag(version, representation, manifestHash?)` folds the build's web-collection shape digest into the digest, so a shape-only redeploy busts every read validator (`undefined` reproduces the pre-#1764 unsalted value byte-for-byte, so existing callers/tests are unaffected). The generated SvelteKit route bakes the digest in as a `MANIFEST_HASH` constant (via `generateConditionalGetRouteHelper`'s `manifestHash` option, sourced from `computeWebManifestHash(manifest)`) — automatic for the SvelteKit transport. The runtime `APIGenerator` reads `APIConfig.manifestHash`, but it is **NOT auto-populated**: a non-SvelteKit runtime-REST deployment that wants the shape-only-deploy guard must pass `manifestHash` (imported from `@happyvertical/smrt-virt-web`) into its `APIConfig` a deliberate consumer responsibility; left unset, that path's read ETags stay unsalted (equivalent to pre-#1764). The digest scope is get-OR-list (`selectWebEtagSaltEntries`), so **get-only** routes are salted too. Strong consistency still requires the v1 body-hash path. Cache-Control policy (unchanged from #1757): `private, no-cache` by default; public models may opt into shared caching via `@smrt({ api: { public: true | 'read', cache: { sMaxage } } })` → `public, max-age=0, s-maxage=<n>`; non-public models never emit shared-cache headers. Tenant-scoped models (any mode) never emit them either — bodies vary with session-cookie tenant context that URL-keyed shared caches cannot see; `sMaxage` is neutralized to `private, no-cache` with a one-time warning.
115
+ Generated reads (`list`/`get`) on the REST and SvelteKit generators support conditional GET (helpers in `src/generators/conditional-get.ts`). ETag v2 (#1765): the validator is the table's change-feed version (`getTableVersion`) keyed by the request representation, so a **concrete** `If-None-Match` short-circuits into a 304 with an empty body **before** the collection query runs — an unchanged table revalidates with zero table scan. A wildcard `If-None-Match: *` is deferred until the payload builds (existence confirmed), so a missing item still returns 404, not a false 304. Tenant-scoped reads fold the active tenant into the representation (`resolveTenantEtagDiscriminator`) so one tenant's cached validator never satisfies another's read of the same URL. Routes whose GET renders via a **custom serializer** (which can load related tables the base-table version can't observe) keep the v1 body-hash ETag (`#1757`, query-first but correct); the default `toPublicJSON` path — all REST reads and non-serializer SvelteKit reads — uses v2. v2 is weakly consistent by design (the cost of not reading the data): a revalidation in the sub-statement window between a committed write and its feed append can return a stale 304 that self-heals on the next revalidation. The other v2 window — a deploy that changes the response shape WITHOUT a table write — is closed by the **#1764 ETag salt**: `computeTableVersionEtag(version, representation, manifestHash?)` folds the build's web-collection shape digest into the digest, so a shape-only redeploy busts every read validator (`undefined` reproduces the pre-#1764 unsalted value byte-for-byte for direct helper callers). The generated SvelteKit route bakes the digest in as a `MANIFEST_HASH` constant (via `generateConditionalGetRouteHelper`'s `manifestHash` option, sourced from `computeWebManifestHash(manifest)`) — automatic for the SvelteKit transport. The runtime `APIGenerator` auto-populates the same salt from the runtime registry with `computeRuntimeWebManifestHash()` when `APIConfig.manifestHash` is omitted; explicit `APIConfig.manifestHash` still wins for custom setups. The digest scope is get-OR-list (`selectWebEtagSaltEntries`), so **get-only** routes are salted too. Strong consistency still requires the v1 body-hash path. Cache-Control policy (unchanged from #1757): `private, no-cache` by default; public models may opt into shared caching via `@smrt({ api: { public: true | 'read', cache: { sMaxage } } })` → `public, max-age=0, s-maxage=<n>`; non-public models never emit shared-cache headers. Tenant-scoped models (any mode) never emit them either — bodies vary with session-cookie tenant context that URL-keyed shared caches cannot see; `sMaxage` is neutralized to `private, no-cache` with a one-time warning.
116
116
 
117
117
  ## Child Accessors (R10)
118
118
 
@@ -47,6 +47,17 @@ export declare const CHANGE_SIGNAL_CHANNEL = "smrt_change_signals";
47
47
  * time.
48
48
  */
49
49
  export declare function subscribeToChangeSignals(db: DatabaseInterface, listener: ChangeSignalListener): () => void;
50
+ /**
51
+ * Atomically reserve one local subscriber slot for a database scope.
52
+ *
53
+ * The `_events` route calls this before returning a streaming response so
54
+ * concurrent opens cannot all pass a stale count and exceed the configured cap.
55
+ * The returned release function is idempotent; callers must release it when the
56
+ * stream either converts the reservation into a real subscription or tears down
57
+ * before subscribing. `maxSubscribers === null` means unlimited and returns a
58
+ * no-op reservation.
59
+ */
60
+ export declare function tryReserveChangeSignalSubscriberSlot(db: DatabaseInterface, maxSubscribers: number | null): (() => void) | null;
50
61
  /**
51
62
  * Publish a change signal: synchronous local fan-out, then fire-and-forget
52
63
  * cross-replica broadcast. Never throws to the caller — a signal problem must
@@ -73,11 +84,12 @@ export declare function stopChangeSignalListeners(): void;
73
84
  */
74
85
  export declare function resetChangeSignals(): void;
75
86
  /**
76
- * Number of active local subscribers for a database scope (test helper).
87
+ * Number of active or reserved local subscribers for a database scope.
77
88
  *
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`).
89
+ * Used by the `_events` route boundary to enforce the per-process subscriber
90
+ * cap (#1860), and by integration tests to assert teardown a leaked SSE
91
+ * subscription or stale reservation would keep this above 0 after a client
92
+ * disconnects.
81
93
  */
82
94
  export declare function changeSignalSubscriberCount(db: DatabaseInterface): number;
83
95
  //# sourceMappingURL=change-signals.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"change-signals.d.ts","sourceRoot":"","sources":["../src/change-signals.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8DG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;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;AA4B3D;;;;;;;;;;;;;GAaG;AACH,wBAAgB,wBAAwB,CACtC,EAAE,EAAE,iBAAiB,EACrB,QAAQ,EAAE,oBAAoB,GAC7B,MAAM,IAAI,CA2BZ;AAED;;;;;;;;;GASG;AACH,wBAAgB,oCAAoC,CAClD,EAAE,EAAE,iBAAiB,EACrB,cAAc,EAAE,MAAM,GAAG,IAAI,GAC5B,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAuBrB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,EAAE,EAAE,iBAAiB,EACrB,MAAM,EAAE,YAAY,GACnB,IAAI,CAKN;AAwBD;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,EAAE,EAAE,iBAAiB,EACrB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,IAAI,CAAC,CAkBf;AA4ED;;;GAGG;AACH,wBAAgB,yBAAyB,IAAI,IAAI,CAOhD;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,IAAI,IAAI,CAIzC;AAED;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CAAC,EAAE,EAAE,iBAAiB,GAAG,MAAM,CAEzE"}
@@ -42,10 +42,9 @@ import { createLogger } from "@happyvertical/logger";
42
42
  *
43
43
  * ## Known gaps
44
44
  *
45
- * - **No max-connections cap**: the bus imposes no ceiling on concurrent
46
- * subscribers (SSE connections). A deployment expecting many long-lived
47
- * `_events` connections should bound them at the edge (reverse proxy /
48
- * load balancer). A per-process cap is a deliberate follow-up.
45
+ * - **Subscriber cap lives at the route boundary**: the bus tracks subscribers,
46
+ * but generated `_events` routes decide whether to reject a new connection
47
+ * before opening a stream (#1860).
49
48
  * - **Raw-SQL writes are invisible**: signals originate from the framework
50
49
  * write path (same accepted gap as the #1758 feed and #1498 cache). A
51
50
  * `bumpChangeFeed` escape-hatch write appends a feed row but does not
@@ -78,6 +77,13 @@ var CHANGE_SIGNAL_CHANNEL = "smrt_change_signals";
78
77
  * independent in-memory databases).
79
78
  */
80
79
  var localListeners = /* @__PURE__ */ new Map();
80
+ /**
81
+ * dbKey -> pending subscriber slots claimed by `_events` before a stream has
82
+ * reached its `start()` callback. This closes the check-then-subscribe race for
83
+ * concurrent connection opens: capacity considers active listeners plus these
84
+ * in-flight claims.
85
+ */
86
+ var reservedListenerSlots = /* @__PURE__ */ new Map();
81
87
  /** dbKey → background cross-replica listener handle. */
82
88
  var crossReplicaListeners = /* @__PURE__ */ new Map();
83
89
  /** dbKeys we already warned about for a missing notification capability. */
@@ -119,6 +125,30 @@ function subscribeToChangeSignals(db, listener) {
119
125
  };
120
126
  }
121
127
  /**
128
+ * Atomically reserve one local subscriber slot for a database scope.
129
+ *
130
+ * The `_events` route calls this before returning a streaming response so
131
+ * concurrent opens cannot all pass a stale count and exceed the configured cap.
132
+ * The returned release function is idempotent; callers must release it when the
133
+ * stream either converts the reservation into a real subscription or tears down
134
+ * before subscribing. `maxSubscribers === null` means unlimited and returns a
135
+ * no-op reservation.
136
+ */
137
+ function tryReserveChangeSignalSubscriberSlot(db, maxSubscribers) {
138
+ if (maxSubscribers === null) return () => {};
139
+ const dbKey = resolveDbCacheKey(db);
140
+ if (changeSignalSubscriberCountForKey(dbKey) >= maxSubscribers) return null;
141
+ reservedListenerSlots.set(dbKey, (reservedListenerSlots.get(dbKey) ?? 0) + 1);
142
+ let released = false;
143
+ return () => {
144
+ if (released) return;
145
+ released = true;
146
+ const current = reservedListenerSlots.get(dbKey) ?? 0;
147
+ if (current <= 1) reservedListenerSlots.delete(dbKey);
148
+ else reservedListenerSlots.set(dbKey, current - 1);
149
+ };
150
+ }
151
+ /**
122
152
  * Publish a change signal: synchronous local fan-out, then fire-and-forget
123
153
  * cross-replica broadcast. Never throws to the caller — a signal problem must
124
154
  * never fail the user's write.
@@ -235,8 +265,23 @@ function stopChangeSignalListeners() {
235
265
  */
236
266
  function resetChangeSignals() {
237
267
  localListeners.clear();
268
+ reservedListenerSlots.clear();
238
269
  stopChangeSignalListeners();
239
270
  }
271
+ /**
272
+ * Number of active or reserved local subscribers for a database scope.
273
+ *
274
+ * Used by the `_events` route boundary to enforce the per-process subscriber
275
+ * cap (#1860), and by integration tests to assert teardown — a leaked SSE
276
+ * subscription or stale reservation would keep this above 0 after a client
277
+ * disconnects.
278
+ */
279
+ function changeSignalSubscriberCount(db) {
280
+ return changeSignalSubscriberCountForKey(resolveDbCacheKey(db));
281
+ }
282
+ function changeSignalSubscriberCountForKey(dbKey) {
283
+ return (localListeners.get(dbKey)?.size ?? 0) + (reservedListenerSlots.get(dbKey) ?? 0);
284
+ }
240
285
  function warnOnceNoNotifications(dbKey) {
241
286
  if (warnedNoNotifications.has(dbKey)) return;
242
287
  warnedNoNotifications.add(dbKey);
@@ -267,6 +312,6 @@ function safeParse(value) {
267
312
  }
268
313
  }
269
314
  //#endregion
270
- export { CHANGE_SIGNAL_CHANNEL, broadcastChangeSignal, publishChangeSignal, resetChangeSignals, stopChangeSignalListeners, subscribeToChangeSignals };
315
+ export { CHANGE_SIGNAL_CHANNEL, broadcastChangeSignal, changeSignalSubscriberCount, publishChangeSignal, resetChangeSignals, stopChangeSignalListeners, subscribeToChangeSignals, tryReserveChangeSignalSubscriberSlot };
271
316
 
272
317
  //# sourceMappingURL=change-signals.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"change-signals.js","names":[],"sources":["../src/change-signals.ts"],"sourcesContent":["/**\n * Change-signal bus — the live push spine for the generated `_events` SSE\n * route (issue #1763, parent PRD #1755).\n *\n * The change feed (#1758) is a durable, cursor-addressable log; this bus is\n * its ephemeral companion. Every framework `save()`/`delete()` that appends a\n * feed row also publishes a coarse {@link ChangeSignal} here, which fans out\n * synchronously to in-process subscribers (the SSE controllers of connected\n * `_events` clients) and, when the database adapter exposes a notification\n * capability, to peer replicas over the same channel. Absence of that\n * capability degrades gracefully — no cross-replica push, never an error, and\n * it never blocks the write.\n *\n * ## What a signal carries (and deliberately does not)\n *\n * A signal is `{ table, operation, rowId, tenantId, seq }` — never any row\n * payload. Authorization stays entirely on the read path: a subscriber learns\n * *that* something changed and its cursor (`seq`), then re-reads through the\n * authorized collection routes to catch up. This is why the bus can broadcast\n * a tenant's writes to peer replicas without leaking data across a trust\n * boundary. The `seq` is the same monotonic cursor dimension the change feed\n * allocates, so a reconnecting client can resume via `getChangesSince`.\n *\n * ## Delivery model\n *\n * - Local delivery is a synchronous fan-out ({@link deliverLocally}) into each\n * listener callback, wrapped in a try/catch **per listener** so one throwing\n * listener (e.g. a closed SSE controller's `enqueue`) never blocks the\n * others. There is no per-subscriber queue — backpressure is delegated to\n * each platform `ReadableStream`.\n * - Cross-replica delivery reuses the same {@link deliverLocally} helper on\n * receipt, so locally-published and peer-received signals travel one code\n * path. Notifications this process published are skipped by `source` id\n * (echo-avoidance), exactly as the collection cache does.\n *\n * This mirrors `collection-cache.ts`'s notify/listen structure; study that\n * module for the shared cross-process conventions (`resolveDbCacheKey`,\n * `getNotifications`, the lazy listener and finally-retract).\n *\n * ## Known gaps\n *\n * - **No max-connections cap**: the bus imposes no ceiling on concurrent\n * subscribers (SSE connections). A deployment expecting many long-lived\n * `_events` connections should bound them at the edge (reverse proxy /\n * load balancer). A per-process cap is a deliberate follow-up.\n * - **Raw-SQL writes are invisible**: signals originate from the framework\n * write path (same accepted gap as the #1758 feed and #1498 cache). A\n * `bumpChangeFeed` escape-hatch write appends a feed row but does not\n * publish a signal.\n * - **Caller-managed transactions are best-effort**: the append + signal fire\n * from `afterSave`/`afterDelete`, i.e. *before* a caller-wrapped transaction\n * commits (the autocommit default path — save()/delete() as independent\n * statements — is exact). Inside such a transaction, a signal may fire for a\n * change that a later rollback undoes, and the rolled-back seq is then reused\n * by the next append — so a client trusting a pre-commit `Last-Event-ID`\n * could skip the reuse via catch-up. This inherits the change feed's\n * documented transaction caveat (see `change-feed.ts`); no generic\n * post-commit hook exists to close it. Clients reconcile via full catch-up /\n * resync, so convergence still holds — live delivery is just best-effort for\n * transaction-wrapped writes.\n *\n * @see https://github.com/happyvertical/smrt/issues/1763\n * @packageDocumentation\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport {\n getNotifications,\n PROCESS_ID,\n resolveDbCacheKey,\n} from './collection-cache.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Operation carried by a change signal. Kept as a local string union — this\n * module deliberately does NOT import `./change-feed.js` (which imports this\n * one via the writer) so there is no import cycle. It must stay in sync with\n * `ChangeOperation` in `change-feed.ts`.\n */\nexport type ChangeSignalOperation = 'create' | 'update' | 'delete';\n\n/**\n * A coarse change notification. Carries no row payload by design.\n */\nexport interface ChangeSignal {\n // NEVER add row-payload fields — #1763 AC: authorization stays on the read path\n /** Physical table the change happened in. */\n table: string;\n /** What happened (`'delete'` doubles as a tombstone signal). */\n operation: ChangeSignalOperation;\n /** Primary key of the changed row, or `null` for table-level changes. */\n rowId: string | null;\n /** Tenant the changed row belongs to, or `null` for global rows. */\n tenantId: string | null;\n /**\n * The change feed sequence allocated for this change — the cursor a\n * reconnecting client resumes from via `getChangesSince`.\n */\n seq: number;\n}\n\n/** A subscriber invoked synchronously for every locally-visible signal. */\nexport type ChangeSignalListener = (signal: ChangeSignal) => void;\n\n/**\n * Notification channel for cross-replica change-signal broadcasts. A distinct\n * channel from the collection cache's — the two buses carry different payloads\n * and evolve independently.\n */\nexport const CHANGE_SIGNAL_CHANNEL = 'smrt_change_signals';\n\n/**\n * dbKey → local listeners. Keyed via `resolveDbCacheKey` so `:memory:` and\n * URL-less handles are scoped per instance (never cross-deliver between two\n * independent in-memory databases).\n */\nconst localListeners = new Map<string, Set<ChangeSignalListener>>();\n\ninterface ListenerHandle {\n iterator: AsyncIterator<unknown> | null;\n stopped: boolean;\n}\n\n/** dbKey → background cross-replica listener handle. */\nconst crossReplicaListeners = new Map<string, ListenerHandle>();\n\n/** dbKeys we already warned about for a missing notification capability. */\nconst warnedNoNotifications = new Set<string>();\n\n/**\n * Subscribe to change signals for a database.\n *\n * Registers `listener` for the database's signal scope and, on the first\n * subscriber for that scope, lazily starts the cross-replica listener (a no-op\n * when the adapter has no notification capability). Returns an unsubscribe\n * function that removes the listener and, when the scope's last subscriber\n * leaves, retracts the cross-replica listener so its refcount reaches 0.\n *\n * Delivery is synchronous: `listener` is invoked from the write path (or the\n * cross-replica loop) inside a per-listener try/catch, so it must not assume\n * an active request or tenant context — capture what it needs at subscribe\n * time.\n */\nexport function subscribeToChangeSignals(\n db: DatabaseInterface,\n listener: ChangeSignalListener,\n): () => void {\n const dbKey = resolveDbCacheKey(db);\n\n let set = localListeners.get(dbKey);\n if (!set) {\n set = new Set();\n localListeners.set(dbKey, set);\n }\n set.add(listener);\n\n // Lazily start the cross-replica listener on first interest for this scope.\n ensureChangeSignalListener(db);\n\n let unsubscribed = false;\n return () => {\n if (unsubscribed) return;\n unsubscribed = true;\n const current = localListeners.get(dbKey);\n if (!current) return;\n current.delete(listener);\n if (current.size === 0) {\n localListeners.delete(dbKey);\n // Last local subscriber gone — retract the cross-replica listener so its\n // refcount reaches 0 (mirrors collection-cache's finally-retract).\n retractChangeSignalListener(dbKey);\n }\n };\n}\n\n/**\n * Publish a change signal: synchronous local fan-out, then fire-and-forget\n * cross-replica broadcast. Never throws to the caller — a signal problem must\n * never fail the user's write.\n */\nexport function publishChangeSignal(\n db: DatabaseInterface,\n signal: ChangeSignal,\n): void {\n const dbKey = resolveDbCacheKey(db);\n deliverLocally(dbKey, signal);\n // Fire-and-forget: broadcast failures are swallowed inside broadcast.\n void broadcastChangeSignal(db, signal);\n}\n\n/**\n * Synchronous local fan-out to every subscriber for a scope. Each listener is\n * wrapped in its own try/catch so one throwing listener (a closed SSE\n * controller) never blocks the rest. Locally-published and peer-received\n * signals both flow through here — the single delivery path.\n */\nfunction deliverLocally(dbKey: string, signal: ChangeSignal): void {\n const set = localListeners.get(dbKey);\n if (!set || set.size === 0) return;\n // Snapshot so a listener that unsubscribes during delivery can't mutate the\n // set mid-iteration.\n for (const listener of [...set]) {\n try {\n listener(signal);\n } catch (error) {\n logger.warn('Change signal: a subscriber threw during local delivery', {\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n}\n\n/**\n * Broadcast a signal to peer replicas over the adapter's notification\n * capability. Resolves normally (never throws) whether or not a capability\n * exists — a missing capability warns once per scope and is not an error.\n *\n * Fire-and-forget from the write path: a broadcast failure must never fail the\n * write that triggered it.\n */\nexport async function broadcastChangeSignal(\n db: DatabaseInterface,\n signal: ChangeSignal,\n): Promise<void> {\n const notifications = getNotifications(db);\n const dbKey = resolveDbCacheKey(db);\n if (!notifications) {\n warnOnceNoNotifications(dbKey);\n return;\n }\n try {\n await notifications.notify(CHANGE_SIGNAL_CHANNEL, {\n ...signal,\n source: PROCESS_ID,\n });\n } catch (error) {\n logger.warn(\n `Change signal: failed to broadcast a signal for '${signal.table}'`,\n { error: error instanceof Error ? error.message : String(error) },\n );\n }\n}\n\n/**\n * Ensure a background listener consumes cross-replica broadcasts for this\n * database and delivers them locally. Started lazily by the first subscriber\n * for a scope; a no-op (with a one-time warning) when the adapter exposes no\n * notification capability. Notifications this process published are skipped by\n * `source` id (echo-avoidance): the local fan-out already delivered them.\n */\nfunction ensureChangeSignalListener(db: DatabaseInterface): void {\n const dbKey = resolveDbCacheKey(db);\n if (crossReplicaListeners.has(dbKey)) return;\n\n const notifications = getNotifications(db);\n if (!notifications) {\n warnOnceNoNotifications(dbKey);\n return;\n }\n\n const handle: ListenerHandle = { iterator: null, stopped: false };\n crossReplicaListeners.set(dbKey, handle);\n\n void (async () => {\n try {\n const iterable = notifications.listen(CHANGE_SIGNAL_CHANNEL);\n const iterator = iterable[Symbol.asyncIterator]();\n handle.iterator = iterator;\n\n while (!handle.stopped) {\n const { value, done } = await iterator.next();\n if (done || handle.stopped) break;\n\n const notification = value as { payload?: unknown };\n const payload: unknown =\n typeof notification?.payload === 'string'\n ? safeParse(notification.payload)\n : notification?.payload;\n\n if (!payload || typeof payload !== 'object') continue;\n const record = payload as Record<string, unknown>;\n // Echo-avoidance: skip signals this process published (already\n // delivered locally on the write path).\n if (record.source === PROCESS_ID) continue;\n\n const signal = toSignal(record);\n if (!signal) continue;\n deliverLocally(dbKey, signal);\n }\n } catch (error) {\n if (!handle.stopped) {\n logger.warn(\n 'Change signal: cross-replica listener terminated unexpectedly; ' +\n 'peer signals are no longer delivered for this database (local ' +\n 'delivery and cursor catch-up still work)',\n { error: error instanceof Error ? error.message : String(error) },\n );\n }\n } finally {\n // Only retract our own handle — a concurrent restart may have installed\n // a replacement; deleting unconditionally would orphan it.\n if (crossReplicaListeners.get(dbKey) === handle) {\n crossReplicaListeners.delete(dbKey);\n }\n }\n })();\n}\n\n/** Retract the cross-replica listener for a scope (last subscriber left). */\nfunction retractChangeSignalListener(dbKey: string): void {\n const handle = crossReplicaListeners.get(dbKey);\n if (!handle) return;\n handle.stopped = true;\n void handle.iterator?.return?.(undefined);\n crossReplicaListeners.delete(dbKey);\n}\n\n/**\n * Stop all cross-replica change-signal listeners. Used by tests and during\n * shutdown; safe to call when none are active.\n */\nexport function stopChangeSignalListeners(): void {\n for (const handle of crossReplicaListeners.values()) {\n handle.stopped = true;\n void handle.iterator?.return?.(undefined);\n }\n crossReplicaListeners.clear();\n warnedNoNotifications.clear();\n}\n\n/**\n * Clear all local subscribers, stop cross-replica listeners, and reset the\n * no-capability warning dedup. Call in test setup for isolation between files.\n */\nexport function resetChangeSignals(): void {\n localListeners.clear();\n stopChangeSignalListeners();\n}\n\n/**\n * Number of active local subscribers for a database scope (test helper).\n *\n * Exposed so integration tests can assert teardown — a leaked SSE subscription\n * would keep this above 0 after a client disconnects. Not part of the public\n * API surface (kept internal to core; not re-exported from `index.ts`).\n */\nexport function changeSignalSubscriberCount(db: DatabaseInterface): number {\n return localListeners.get(resolveDbCacheKey(db))?.size ?? 0;\n}\n\nfunction warnOnceNoNotifications(dbKey: string): void {\n if (warnedNoNotifications.has(dbKey)) return;\n warnedNoNotifications.add(dbKey);\n logger.warn(\n 'Change signal: the database adapter exposes no notification capability, ' +\n 'so change signals are delivered in-process only. Cross-replica live ' +\n 'updates require an adapter with notifications (e.g. Postgres ' +\n 'LISTEN/NOTIFY); subscribers on other replicas fall back to cursor ' +\n 'polling.',\n );\n}\n\n/**\n * Coerce a received notification payload into a {@link ChangeSignal}, or\n * `undefined` if it is malformed. Guards the cross-replica path against\n * garbage on the channel.\n */\nfunction toSignal(record: Record<string, unknown>): ChangeSignal | undefined {\n const { table, operation, rowId, tenantId, seq } = record;\n if (typeof table !== 'string' || !table) return undefined;\n if (\n operation !== 'create' &&\n operation !== 'update' &&\n operation !== 'delete'\n ) {\n return undefined;\n }\n return {\n table,\n operation,\n rowId: typeof rowId === 'string' ? rowId : null,\n tenantId: typeof tenantId === 'string' ? tenantId : null,\n seq: typeof seq === 'number' ? seq : Number(seq ?? 0) || 0,\n };\n}\n\nfunction safeParse(value: string): unknown {\n try {\n return JSON.parse(value);\n } catch {\n return undefined;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"change-signals.js","names":[],"sources":["../src/change-signals.ts"],"sourcesContent":["/**\n * Change-signal bus — the live push spine for the generated `_events` SSE\n * route (issue #1763, parent PRD #1755).\n *\n * The change feed (#1758) is a durable, cursor-addressable log; this bus is\n * its ephemeral companion. Every framework `save()`/`delete()` that appends a\n * feed row also publishes a coarse {@link ChangeSignal} here, which fans out\n * synchronously to in-process subscribers (the SSE controllers of connected\n * `_events` clients) and, when the database adapter exposes a notification\n * capability, to peer replicas over the same channel. Absence of that\n * capability degrades gracefully — no cross-replica push, never an error, and\n * it never blocks the write.\n *\n * ## What a signal carries (and deliberately does not)\n *\n * A signal is `{ table, operation, rowId, tenantId, seq }` — never any row\n * payload. Authorization stays entirely on the read path: a subscriber learns\n * *that* something changed and its cursor (`seq`), then re-reads through the\n * authorized collection routes to catch up. This is why the bus can broadcast\n * a tenant's writes to peer replicas without leaking data across a trust\n * boundary. The `seq` is the same monotonic cursor dimension the change feed\n * allocates, so a reconnecting client can resume via `getChangesSince`.\n *\n * ## Delivery model\n *\n * - Local delivery is a synchronous fan-out ({@link deliverLocally}) into each\n * listener callback, wrapped in a try/catch **per listener** so one throwing\n * listener (e.g. a closed SSE controller's `enqueue`) never blocks the\n * others. There is no per-subscriber queue — backpressure is delegated to\n * each platform `ReadableStream`.\n * - Cross-replica delivery reuses the same {@link deliverLocally} helper on\n * receipt, so locally-published and peer-received signals travel one code\n * path. Notifications this process published are skipped by `source` id\n * (echo-avoidance), exactly as the collection cache does.\n *\n * This mirrors `collection-cache.ts`'s notify/listen structure; study that\n * module for the shared cross-process conventions (`resolveDbCacheKey`,\n * `getNotifications`, the lazy listener and finally-retract).\n *\n * ## Known gaps\n *\n * - **Subscriber cap lives at the route boundary**: the bus tracks subscribers,\n * but generated `_events` routes decide whether to reject a new connection\n * before opening a stream (#1860).\n * - **Raw-SQL writes are invisible**: signals originate from the framework\n * write path (same accepted gap as the #1758 feed and #1498 cache). A\n * `bumpChangeFeed` escape-hatch write appends a feed row but does not\n * publish a signal.\n * - **Caller-managed transactions are best-effort**: the append + signal fire\n * from `afterSave`/`afterDelete`, i.e. *before* a caller-wrapped transaction\n * commits (the autocommit default path — save()/delete() as independent\n * statements — is exact). Inside such a transaction, a signal may fire for a\n * change that a later rollback undoes, and the rolled-back seq is then reused\n * by the next append — so a client trusting a pre-commit `Last-Event-ID`\n * could skip the reuse via catch-up. This inherits the change feed's\n * documented transaction caveat (see `change-feed.ts`); no generic\n * post-commit hook exists to close it. Clients reconcile via full catch-up /\n * resync, so convergence still holds — live delivery is just best-effort for\n * transaction-wrapped writes.\n *\n * @see https://github.com/happyvertical/smrt/issues/1763\n * @packageDocumentation\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport {\n getNotifications,\n PROCESS_ID,\n resolveDbCacheKey,\n} from './collection-cache.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Operation carried by a change signal. Kept as a local string union — this\n * module deliberately does NOT import `./change-feed.js` (which imports this\n * one via the writer) so there is no import cycle. It must stay in sync with\n * `ChangeOperation` in `change-feed.ts`.\n */\nexport type ChangeSignalOperation = 'create' | 'update' | 'delete';\n\n/**\n * A coarse change notification. Carries no row payload by design.\n */\nexport interface ChangeSignal {\n // NEVER add row-payload fields — #1763 AC: authorization stays on the read path\n /** Physical table the change happened in. */\n table: string;\n /** What happened (`'delete'` doubles as a tombstone signal). */\n operation: ChangeSignalOperation;\n /** Primary key of the changed row, or `null` for table-level changes. */\n rowId: string | null;\n /** Tenant the changed row belongs to, or `null` for global rows. */\n tenantId: string | null;\n /**\n * The change feed sequence allocated for this change — the cursor a\n * reconnecting client resumes from via `getChangesSince`.\n */\n seq: number;\n}\n\n/** A subscriber invoked synchronously for every locally-visible signal. */\nexport type ChangeSignalListener = (signal: ChangeSignal) => void;\n\n/**\n * Notification channel for cross-replica change-signal broadcasts. A distinct\n * channel from the collection cache's — the two buses carry different payloads\n * and evolve independently.\n */\nexport const CHANGE_SIGNAL_CHANNEL = 'smrt_change_signals';\n\n/**\n * dbKey → local listeners. Keyed via `resolveDbCacheKey` so `:memory:` and\n * URL-less handles are scoped per instance (never cross-deliver between two\n * independent in-memory databases).\n */\nconst localListeners = new Map<string, Set<ChangeSignalListener>>();\n\n/**\n * dbKey -> pending subscriber slots claimed by `_events` before a stream has\n * reached its `start()` callback. This closes the check-then-subscribe race for\n * concurrent connection opens: capacity considers active listeners plus these\n * in-flight claims.\n */\nconst reservedListenerSlots = new Map<string, number>();\n\ninterface ListenerHandle {\n iterator: AsyncIterator<unknown> | null;\n stopped: boolean;\n}\n\n/** dbKey → background cross-replica listener handle. */\nconst crossReplicaListeners = new Map<string, ListenerHandle>();\n\n/** dbKeys we already warned about for a missing notification capability. */\nconst warnedNoNotifications = new Set<string>();\n\n/**\n * Subscribe to change signals for a database.\n *\n * Registers `listener` for the database's signal scope and, on the first\n * subscriber for that scope, lazily starts the cross-replica listener (a no-op\n * when the adapter has no notification capability). Returns an unsubscribe\n * function that removes the listener and, when the scope's last subscriber\n * leaves, retracts the cross-replica listener so its refcount reaches 0.\n *\n * Delivery is synchronous: `listener` is invoked from the write path (or the\n * cross-replica loop) inside a per-listener try/catch, so it must not assume\n * an active request or tenant context — capture what it needs at subscribe\n * time.\n */\nexport function subscribeToChangeSignals(\n db: DatabaseInterface,\n listener: ChangeSignalListener,\n): () => void {\n const dbKey = resolveDbCacheKey(db);\n\n let set = localListeners.get(dbKey);\n if (!set) {\n set = new Set();\n localListeners.set(dbKey, set);\n }\n set.add(listener);\n\n // Lazily start the cross-replica listener on first interest for this scope.\n ensureChangeSignalListener(db);\n\n let unsubscribed = false;\n return () => {\n if (unsubscribed) return;\n unsubscribed = true;\n const current = localListeners.get(dbKey);\n if (!current) return;\n current.delete(listener);\n if (current.size === 0) {\n localListeners.delete(dbKey);\n // Last local subscriber gone — retract the cross-replica listener so its\n // refcount reaches 0 (mirrors collection-cache's finally-retract).\n retractChangeSignalListener(dbKey);\n }\n };\n}\n\n/**\n * Atomically reserve one local subscriber slot for a database scope.\n *\n * The `_events` route calls this before returning a streaming response so\n * concurrent opens cannot all pass a stale count and exceed the configured cap.\n * The returned release function is idempotent; callers must release it when the\n * stream either converts the reservation into a real subscription or tears down\n * before subscribing. `maxSubscribers === null` means unlimited and returns a\n * no-op reservation.\n */\nexport function tryReserveChangeSignalSubscriberSlot(\n db: DatabaseInterface,\n maxSubscribers: number | null,\n): (() => void) | null {\n if (maxSubscribers === null) {\n return () => {};\n }\n\n const dbKey = resolveDbCacheKey(db);\n if (changeSignalSubscriberCountForKey(dbKey) >= maxSubscribers) {\n return null;\n }\n\n reservedListenerSlots.set(dbKey, (reservedListenerSlots.get(dbKey) ?? 0) + 1);\n\n let released = false;\n return () => {\n if (released) return;\n released = true;\n const current = reservedListenerSlots.get(dbKey) ?? 0;\n if (current <= 1) {\n reservedListenerSlots.delete(dbKey);\n } else {\n reservedListenerSlots.set(dbKey, current - 1);\n }\n };\n}\n\n/**\n * Publish a change signal: synchronous local fan-out, then fire-and-forget\n * cross-replica broadcast. Never throws to the caller — a signal problem must\n * never fail the user's write.\n */\nexport function publishChangeSignal(\n db: DatabaseInterface,\n signal: ChangeSignal,\n): void {\n const dbKey = resolveDbCacheKey(db);\n deliverLocally(dbKey, signal);\n // Fire-and-forget: broadcast failures are swallowed inside broadcast.\n void broadcastChangeSignal(db, signal);\n}\n\n/**\n * Synchronous local fan-out to every subscriber for a scope. Each listener is\n * wrapped in its own try/catch so one throwing listener (a closed SSE\n * controller) never blocks the rest. Locally-published and peer-received\n * signals both flow through here — the single delivery path.\n */\nfunction deliverLocally(dbKey: string, signal: ChangeSignal): void {\n const set = localListeners.get(dbKey);\n if (!set || set.size === 0) return;\n // Snapshot so a listener that unsubscribes during delivery can't mutate the\n // set mid-iteration.\n for (const listener of [...set]) {\n try {\n listener(signal);\n } catch (error) {\n logger.warn('Change signal: a subscriber threw during local delivery', {\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n}\n\n/**\n * Broadcast a signal to peer replicas over the adapter's notification\n * capability. Resolves normally (never throws) whether or not a capability\n * exists — a missing capability warns once per scope and is not an error.\n *\n * Fire-and-forget from the write path: a broadcast failure must never fail the\n * write that triggered it.\n */\nexport async function broadcastChangeSignal(\n db: DatabaseInterface,\n signal: ChangeSignal,\n): Promise<void> {\n const notifications = getNotifications(db);\n const dbKey = resolveDbCacheKey(db);\n if (!notifications) {\n warnOnceNoNotifications(dbKey);\n return;\n }\n try {\n await notifications.notify(CHANGE_SIGNAL_CHANNEL, {\n ...signal,\n source: PROCESS_ID,\n });\n } catch (error) {\n logger.warn(\n `Change signal: failed to broadcast a signal for '${signal.table}'`,\n { error: error instanceof Error ? error.message : String(error) },\n );\n }\n}\n\n/**\n * Ensure a background listener consumes cross-replica broadcasts for this\n * database and delivers them locally. Started lazily by the first subscriber\n * for a scope; a no-op (with a one-time warning) when the adapter exposes no\n * notification capability. Notifications this process published are skipped by\n * `source` id (echo-avoidance): the local fan-out already delivered them.\n */\nfunction ensureChangeSignalListener(db: DatabaseInterface): void {\n const dbKey = resolveDbCacheKey(db);\n if (crossReplicaListeners.has(dbKey)) return;\n\n const notifications = getNotifications(db);\n if (!notifications) {\n warnOnceNoNotifications(dbKey);\n return;\n }\n\n const handle: ListenerHandle = { iterator: null, stopped: false };\n crossReplicaListeners.set(dbKey, handle);\n\n void (async () => {\n try {\n const iterable = notifications.listen(CHANGE_SIGNAL_CHANNEL);\n const iterator = iterable[Symbol.asyncIterator]();\n handle.iterator = iterator;\n\n while (!handle.stopped) {\n const { value, done } = await iterator.next();\n if (done || handle.stopped) break;\n\n const notification = value as { payload?: unknown };\n const payload: unknown =\n typeof notification?.payload === 'string'\n ? safeParse(notification.payload)\n : notification?.payload;\n\n if (!payload || typeof payload !== 'object') continue;\n const record = payload as Record<string, unknown>;\n // Echo-avoidance: skip signals this process published (already\n // delivered locally on the write path).\n if (record.source === PROCESS_ID) continue;\n\n const signal = toSignal(record);\n if (!signal) continue;\n deliverLocally(dbKey, signal);\n }\n } catch (error) {\n if (!handle.stopped) {\n logger.warn(\n 'Change signal: cross-replica listener terminated unexpectedly; ' +\n 'peer signals are no longer delivered for this database (local ' +\n 'delivery and cursor catch-up still work)',\n { error: error instanceof Error ? error.message : String(error) },\n );\n }\n } finally {\n // Only retract our own handle — a concurrent restart may have installed\n // a replacement; deleting unconditionally would orphan it.\n if (crossReplicaListeners.get(dbKey) === handle) {\n crossReplicaListeners.delete(dbKey);\n }\n }\n })();\n}\n\n/** Retract the cross-replica listener for a scope (last subscriber left). */\nfunction retractChangeSignalListener(dbKey: string): void {\n const handle = crossReplicaListeners.get(dbKey);\n if (!handle) return;\n handle.stopped = true;\n void handle.iterator?.return?.(undefined);\n crossReplicaListeners.delete(dbKey);\n}\n\n/**\n * Stop all cross-replica change-signal listeners. Used by tests and during\n * shutdown; safe to call when none are active.\n */\nexport function stopChangeSignalListeners(): void {\n for (const handle of crossReplicaListeners.values()) {\n handle.stopped = true;\n void handle.iterator?.return?.(undefined);\n }\n crossReplicaListeners.clear();\n warnedNoNotifications.clear();\n}\n\n/**\n * Clear all local subscribers, stop cross-replica listeners, and reset the\n * no-capability warning dedup. Call in test setup for isolation between files.\n */\nexport function resetChangeSignals(): void {\n localListeners.clear();\n reservedListenerSlots.clear();\n stopChangeSignalListeners();\n}\n\n/**\n * Number of active or reserved local subscribers for a database scope.\n *\n * Used by the `_events` route boundary to enforce the per-process subscriber\n * cap (#1860), and by integration tests to assert teardown — a leaked SSE\n * subscription or stale reservation would keep this above 0 after a client\n * disconnects.\n */\nexport function changeSignalSubscriberCount(db: DatabaseInterface): number {\n return changeSignalSubscriberCountForKey(resolveDbCacheKey(db));\n}\n\nfunction changeSignalSubscriberCountForKey(dbKey: string): number {\n return (\n (localListeners.get(dbKey)?.size ?? 0) +\n (reservedListenerSlots.get(dbKey) ?? 0)\n );\n}\n\nfunction warnOnceNoNotifications(dbKey: string): void {\n if (warnedNoNotifications.has(dbKey)) return;\n warnedNoNotifications.add(dbKey);\n logger.warn(\n 'Change signal: the database adapter exposes no notification capability, ' +\n 'so change signals are delivered in-process only. Cross-replica live ' +\n 'updates require an adapter with notifications (e.g. Postgres ' +\n 'LISTEN/NOTIFY); subscribers on other replicas fall back to cursor ' +\n 'polling.',\n );\n}\n\n/**\n * Coerce a received notification payload into a {@link ChangeSignal}, or\n * `undefined` if it is malformed. Guards the cross-replica path against\n * garbage on the channel.\n */\nfunction toSignal(record: Record<string, unknown>): ChangeSignal | undefined {\n const { table, operation, rowId, tenantId, seq } = record;\n if (typeof table !== 'string' || !table) return undefined;\n if (\n operation !== 'create' &&\n operation !== 'update' &&\n operation !== 'delete'\n ) {\n return undefined;\n }\n return {\n table,\n operation,\n rowId: typeof rowId === 'string' ? rowId : null,\n tenantId: typeof tenantId === 'string' ? tenantId : null,\n seq: typeof seq === 'number' ? seq : Number(seq ?? 0) || 0,\n };\n}\n\nfunction safeParse(value: string): unknown {\n try {\n return JSON.parse(value);\n } catch {\n return undefined;\n }\n}\n"],"mappings":"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"}
@@ -8,6 +8,19 @@ export interface EventsRouteOptions {
8
8
  authMiddleware?: ChangesAuthMiddleware;
9
9
  /** The generator's `APIContext.db` (instance, config object, or URL string). */
10
10
  db?: unknown;
11
+ /**
12
+ * The build's web-collection shape digest (#1764). When supplied, the stream
13
+ * emits it in a connection-open `manifest` event so long-lived tabs can latch
14
+ * `updateAvailable.contract` on reconnect (#1859).
15
+ */
16
+ manifestHash?: string;
17
+ /**
18
+ * Per-process cap on active `_events` subscribers (#1860). Defaults to
19
+ * {@link DEFAULT_EVENTS_MAX_SUBSCRIBERS}; new over-cap connections receive a
20
+ * retryable 503 and existing subscribers are left untouched. Set to 0 for no
21
+ * cap.
22
+ */
23
+ maxSubscribers?: number;
11
24
  }
12
25
  /**
13
26
  * Pseudo object name passed to the auth middleware for the events route, so
@@ -16,6 +29,10 @@ export interface EventsRouteOptions {
16
29
  export declare const EVENTS_ROUTE_OBJECT_NAME = "_events";
17
30
  /** Default heartbeat interval (ms). Overridable via stream options. */
18
31
  export declare const DEFAULT_EVENTS_HEARTBEAT_MS = 15000;
32
+ /** Default per-process `_events` subscriber cap (#1860). */
33
+ export declare const DEFAULT_EVENTS_MAX_SUBSCRIBERS = 1000;
34
+ /** Retry hint for over-cap `_events` connections (#1860). */
35
+ export declare const DEFAULT_EVENTS_RETRY_AFTER_SECONDS = 5;
19
36
  /** Options for {@link buildChangeEventStream}. */
20
37
  export interface ChangeEventStreamOptions {
21
38
  /**
@@ -31,7 +48,34 @@ export interface ChangeEventStreamOptions {
31
48
  tenantScope: DispatchTenantScope;
32
49
  /** Heartbeat interval (ms). Defaults to {@link DEFAULT_EVENTS_HEARTBEAT_MS}. */
33
50
  heartbeatMs?: number;
51
+ /**
52
+ * Optional server manifest hash emitted once at connection open as
53
+ * `event: manifest`. The hash carries no tenant/user data.
54
+ */
55
+ manifestHash?: string;
56
+ /**
57
+ * Reservation claimed at the route boundary before the streaming response was
58
+ * returned. Released when the stream subscribes, or during teardown if the
59
+ * stream never reaches `start()`.
60
+ */
61
+ releaseSubscriberSlot?: () => void;
34
62
  }
63
+ /**
64
+ * Normalize an `_events` subscriber cap.
65
+ *
66
+ * `0` means unlimited rather than reject-all, matching common limit semantics.
67
+ * Invalid values fall back to the default operational cap.
68
+ */
69
+ export declare function normalizeEventsMaxSubscribers(value: number | undefined): number | null;
70
+ /** True when opening a new `_events` stream would exceed the configured cap. */
71
+ export declare function changeEventSubscribersAtCapacity(db: DatabaseInterface, maxSubscribers?: number): boolean;
72
+ /**
73
+ * Atomically claim one `_events` subscriber slot at the route boundary.
74
+ * Returns null when the configured cap is already reached.
75
+ */
76
+ export declare function tryReserveChangeEventSubscriberSlot(db: DatabaseInterface, maxSubscribers?: number): (() => void) | null;
77
+ /** Retryable over-cap response shared by REST and generated SvelteKit routes. */
78
+ export declare function eventStreamCapacityExceededResponse(): Response;
35
79
  /**
36
80
  * Whether a signal is visible to a captured tenant scope. Exact same rule as
37
81
  * `getChangesSince`'s tenantId filter, run **synchronously server-side** inside
@@ -1 +1 @@
1
- {"version":3,"file":"events-route.d.ts","sourceRoot":"","sources":["../../src/generators/events-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,OAAO,EACL,KAAK,YAAY,EAElB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,KAAK,mBAAmB,EAEzB,MAAM,gCAAgC,CAAC;AACxC,OAAO,EACL,KAAK,qBAAqB,EAE3B,MAAM,oBAAoB,CAAC;AAI5B,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,0DAA0D;IAC1D,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC,gFAAgF;IAChF,EAAE,CAAC,EAAE,OAAO,CAAC;CACd;AAED;;;GAGG;AACH,eAAO,MAAM,wBAAwB,YAAY,CAAC;AAElD,uEAAuE;AACvE,eAAO,MAAM,2BAA2B,QAAQ,CAAC;AAIjD,kDAAkD;AAClD,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB;;;;OAIG;IACH,WAAW,EAAE,mBAAmB,CAAC;IACjC,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,YAAY,EACjB,KAAK,EAAE,mBAAmB,GACzB,OAAO,CAIT;AA2BD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,iBAAiB,EACrB,OAAO,EAAE,wBAAwB,GAChC,cAAc,CAAC,UAAU,CAAC,CA8H5B;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,QAAQ,CAAC,CAyDnB"}
1
+ {"version":3,"file":"events-route.d.ts","sourceRoot":"","sources":["../../src/generators/events-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,OAAO,EACL,KAAK,YAAY,EAIlB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,KAAK,mBAAmB,EAEzB,MAAM,gCAAgC,CAAC;AACxC,OAAO,EACL,KAAK,qBAAqB,EAE3B,MAAM,oBAAoB,CAAC;AAI5B,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,0DAA0D;IAC1D,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC,gFAAgF;IAChF,EAAE,CAAC,EAAE,OAAO,CAAC;IACb;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;GAGG;AACH,eAAO,MAAM,wBAAwB,YAAY,CAAC;AAElD,uEAAuE;AACvE,eAAO,MAAM,2BAA2B,QAAQ,CAAC;AAEjD,4DAA4D;AAC5D,eAAO,MAAM,8BAA8B,OAAO,CAAC;AAEnD,6DAA6D;AAC7D,eAAO,MAAM,kCAAkC,IAAI,CAAC;AAIpD,kDAAkD;AAClD,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB;;;;OAIG;IACH,WAAW,EAAE,mBAAmB,CAAC;IACjC,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,MAAM,IAAI,CAAC;CACpC;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAC3C,KAAK,EAAE,MAAM,GAAG,SAAS,GACxB,MAAM,GAAG,IAAI,CAOf;AAED,gFAAgF;AAChF,wBAAgB,gCAAgC,CAC9C,EAAE,EAAE,iBAAiB,EACrB,cAAc,CAAC,EAAE,MAAM,GACtB,OAAO,CAOT;AAED;;;GAGG;AACH,wBAAgB,mCAAmC,CACjD,EAAE,EAAE,iBAAiB,EACrB,cAAc,CAAC,EAAE,MAAM,GACtB,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAKrB;AAED,iFAAiF;AACjF,wBAAgB,mCAAmC,IAAI,QAAQ,CAa9D;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,YAAY,EACjB,KAAK,EAAE,mBAAmB,GACzB,OAAO,CAIT;AAkCD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,iBAAiB,EACrB,OAAO,EAAE,wBAAwB,GAChC,cAAc,CAAC,UAAU,CAAC,CA6I5B;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,QAAQ,CAAC,CAwEnB"}
@@ -1,4 +1,4 @@
1
- import { subscribeToChangeSignals } from "../change-signals.js";
1
+ import { changeSignalSubscriberCount, subscribeToChangeSignals, tryReserveChangeSignalSubscriberSlot } from "../change-signals.js";
2
2
  import { resolveDispatchTenantScope } from "../dispatch/tenant-resolver.js";
3
3
  import { ensureChangeFeedTable, getChangesSince } from "../change-feed.js";
4
4
  import { resolveChangesDb } from "./changes-route.js";
@@ -43,8 +43,46 @@ var logger = createLogger({ level: "info" });
43
43
  var EVENTS_ROUTE_OBJECT_NAME = "_events";
44
44
  /** Default heartbeat interval (ms). Overridable via stream options. */
45
45
  var DEFAULT_EVENTS_HEARTBEAT_MS = 15e3;
46
+ /** Default per-process `_events` subscriber cap (#1860). */
47
+ var DEFAULT_EVENTS_MAX_SUBSCRIBERS = 1e3;
48
+ /** Retry hint for over-cap `_events` connections (#1860). */
49
+ var DEFAULT_EVENTS_RETRY_AFTER_SECONDS = 5;
46
50
  var encoder = new TextEncoder();
47
51
  /**
52
+ * Normalize an `_events` subscriber cap.
53
+ *
54
+ * `0` means unlimited rather than reject-all, matching common limit semantics.
55
+ * Invalid values fall back to the default operational cap.
56
+ */
57
+ function normalizeEventsMaxSubscribers(value) {
58
+ if (value === void 0) return DEFAULT_EVENTS_MAX_SUBSCRIBERS;
59
+ if (value === 0) return null;
60
+ if (!Number.isFinite(value) || value < 0) return DEFAULT_EVENTS_MAX_SUBSCRIBERS;
61
+ return Math.floor(value);
62
+ }
63
+ /** True when opening a new `_events` stream would exceed the configured cap. */
64
+ function changeEventSubscribersAtCapacity(db, maxSubscribers) {
65
+ const normalizedMaxSubscribers = normalizeEventsMaxSubscribers(maxSubscribers);
66
+ return normalizedMaxSubscribers !== null && changeSignalSubscriberCount(db) >= normalizedMaxSubscribers;
67
+ }
68
+ /**
69
+ * Atomically claim one `_events` subscriber slot at the route boundary.
70
+ * Returns null when the configured cap is already reached.
71
+ */
72
+ function tryReserveChangeEventSubscriberSlot(db, maxSubscribers) {
73
+ return tryReserveChangeSignalSubscriberSlot(db, normalizeEventsMaxSubscribers(maxSubscribers));
74
+ }
75
+ /** Retryable over-cap response shared by REST and generated SvelteKit routes. */
76
+ function eventStreamCapacityExceededResponse() {
77
+ return new Response(JSON.stringify({ error: "Live events unavailable: subscriber capacity reached" }), {
78
+ status: 503,
79
+ headers: {
80
+ "Content-Type": "application/json",
81
+ "Retry-After": String(5)
82
+ }
83
+ });
84
+ }
85
+ /**
48
86
  * Whether a signal is visible to a captured tenant scope. Exact same rule as
49
87
  * `getChangesSince`'s tenantId filter, run **synchronously server-side** inside
50
88
  * the enqueue callback before any byte hits the wire:
@@ -71,6 +109,10 @@ function encodeSseEvent(sig) {
71
109
  });
72
110
  return encoder.encode(`id: ${sig.seq}\nevent: change\ndata: ${data}\n\n`);
73
111
  }
112
+ /** SSE manifest frame emitted at connection open for live contract detection. */
113
+ function encodeSseManifestEvent(manifestHash) {
114
+ return encoder.encode(`event: manifest\ndata: ${JSON.stringify({ manifestHash })}\n\n`);
115
+ }
74
116
  /** SSE resync frame. The id advances EventSource past an unservable cursor. */
75
117
  function encodeSseResyncEvent(cursor) {
76
118
  return encoder.encode(`id: ${cursor}\nevent: resync\ndata: {}\n\n`);
@@ -98,9 +140,10 @@ function encodeSseComment(text) {
98
140
  * controller and keep the cross-replica listener refcount above 0).
99
141
  */
100
142
  function buildChangeEventStream(db, options) {
101
- const { cursor, tenantScope } = options;
143
+ const { cursor, tenantScope, manifestHash } = options;
102
144
  const heartbeatMs = options.heartbeatMs ?? 15e3;
103
145
  let unsubscribe = null;
146
+ let releaseSubscriberSlot = options.releaseSubscriberSlot ?? null;
104
147
  let heartbeat = null;
105
148
  let closed = false;
106
149
  const teardown = () => {
@@ -114,6 +157,10 @@ function buildChangeEventStream(db, options) {
114
157
  unsubscribe();
115
158
  unsubscribe = null;
116
159
  }
160
+ if (releaseSubscriberSlot) {
161
+ releaseSubscriberSlot();
162
+ releaseSubscriberSlot = null;
163
+ }
117
164
  };
118
165
  return new ReadableStream({
119
166
  async start(controller) {
@@ -126,7 +173,12 @@ function buildChangeEventStream(db, options) {
126
173
  teardown();
127
174
  }
128
175
  });
176
+ if (releaseSubscriberSlot) {
177
+ releaseSubscriberSlot();
178
+ releaseSubscriberSlot = null;
179
+ }
129
180
  controller.enqueue(encoder.encode("retry: 3000\n\n"));
181
+ if (manifestHash !== void 0) controller.enqueue(encodeSseManifestEvent(manifestHash));
130
182
  if (cursor != null) try {
131
183
  const catchupTenantId = tenantScope.enforced ? tenantScope.tenantId : void 0;
132
184
  let since = cursor;
@@ -195,11 +247,15 @@ async function handleEventsRoute(req, options) {
195
247
  });
196
248
  const db = await resolveChangesDb(options.db);
197
249
  await ensureChangeFeedTable(db);
250
+ const releaseSubscriberSlot = tryReserveChangeEventSubscriberSlot(db, options.maxSubscribers);
251
+ if (!releaseSubscriberSlot) return eventStreamCapacityExceededResponse();
198
252
  const cursor = parseCursor(authResult);
199
253
  const tenantScope = resolveDispatchTenantScope();
200
254
  return new Response(buildChangeEventStream(db, {
201
255
  cursor,
202
- tenantScope
256
+ tenantScope,
257
+ manifestHash: options.manifestHash,
258
+ releaseSubscriberSlot
203
259
  }), {
204
260
  status: 200,
205
261
  headers: {
@@ -229,6 +285,6 @@ function parseCursor(req) {
229
285
  return null;
230
286
  }
231
287
  //#endregion
232
- export { DEFAULT_EVENTS_HEARTBEAT_MS, EVENTS_ROUTE_OBJECT_NAME, buildChangeEventStream, handleEventsRoute, signalVisibleToTenant };
288
+ export { DEFAULT_EVENTS_HEARTBEAT_MS, DEFAULT_EVENTS_MAX_SUBSCRIBERS, DEFAULT_EVENTS_RETRY_AFTER_SECONDS, EVENTS_ROUTE_OBJECT_NAME, buildChangeEventStream, changeEventSubscribersAtCapacity, eventStreamCapacityExceededResponse, handleEventsRoute, normalizeEventsMaxSubscribers, signalVisibleToTenant, tryReserveChangeEventSubscriberSlot };
233
289
 
234
290
  //# sourceMappingURL=events-route.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"events-route.js","names":[],"sources":["../../src/generators/events-route.ts"],"sourcesContent":["/**\n * Generated `_events` SSE route — live change signals (issue #1763, SERVER\n * half; parent PRD #1755).\n *\n * Handles `GET {basePath}/_events` in the REST generator: an auth-guarded,\n * tenant-scoped Server-Sent-Events stream of coarse change signals ({table,\n * operation, rowId, tenantId} + a `seq` cursor in the SSE `id:` field). It is\n * the push companion to the pull-based `_changes` route (#1758): a subscriber\n * reacts to a signal by re-reading through the authorized collection routes,\n * so **no row payload ever crosses this channel** — authorization stays\n * entirely on the read path.\n *\n * The stream lifecycle (subscribe, catch-up replay, heartbeat, teardown) lives\n * in {@link buildChangeEventStream} so it is written and tested once; both the\n * REST generator here and the generated SvelteKit route import it. `rest.ts`\n * only registers the path.\n *\n * Contract:\n * - **Fail-closed auth** (identical to `_changes`, #1540 posture): no\n * `authMiddleware` configured → 401; the middleware may return a Response to\n * short-circuit (e.g. 403). The feed spans every table, so per-model\n * `api: { public }` opt-outs deliberately do not apply.\n * - **Tenant scope is captured ONCE at connection open** — signal delivery\n * happens from a different async context (the writer's afterSave, possibly\n * another request or replica) with no tenant ALS active, so the filter must\n * be the value resolved at subscribe time, not re-resolved per signal.\n * - **Same-origin only** for this slice: `rest.ts` does NOT wrap `_events` in\n * CORS headers. `EventSource` cannot set request headers and credentialed\n * cross-origin SSE needs `Access-Control-Allow-Credentials` the CORS helper\n * does not emit; cross-origin SSE is a deliberate follow-up.\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { ensureChangeFeedTable, getChangesSince } from '../change-feed.js';\nimport {\n type ChangeSignal,\n subscribeToChangeSignals,\n} from '../change-signals.js';\nimport {\n type DispatchTenantScope,\n resolveDispatchTenantScope,\n} from '../dispatch/tenant-resolver.js';\nimport {\n type ChangesAuthMiddleware,\n resolveChangesDb,\n} from './changes-route.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/** Options for the `_events` route handler. */\nexport interface EventsRouteOptions {\n /** The generator's configured auth middleware, if any. */\n authMiddleware?: ChangesAuthMiddleware;\n /** The generator's `APIContext.db` (instance, config object, or URL string). */\n db?: unknown;\n}\n\n/**\n * Pseudo object name passed to the auth middleware for the events route, so\n * middlewares can recognize and specially authorize it (mirrors `_changes`).\n */\nexport const EVENTS_ROUTE_OBJECT_NAME = '_events';\n\n/** Default heartbeat interval (ms). Overridable via stream options. */\nexport const DEFAULT_EVENTS_HEARTBEAT_MS = 15000;\n\nconst encoder = new TextEncoder();\n\n/** Options for {@link buildChangeEventStream}. */\nexport interface ChangeEventStreamOptions {\n /**\n * Catch-up cursor. When a non-negative number, changes after it are replayed\n * before going live; `null` means live-forward only (no catch-up).\n */\n cursor: number | null;\n /**\n * Tenant scope captured at connection open. Delivery filters against this\n * fixed value — it must NOT be re-resolved per signal (delivery runs outside\n * any tenant ALS context).\n */\n tenantScope: DispatchTenantScope;\n /** Heartbeat interval (ms). Defaults to {@link DEFAULT_EVENTS_HEARTBEAT_MS}. */\n heartbeatMs?: number;\n}\n\n/**\n * Whether a signal is visible to a captured tenant scope. Exact same rule as\n * `getChangesSince`'s tenantId filter, run **synchronously server-side** inside\n * the enqueue callback before any byte hits the wire:\n * - not enforced → visible.\n * - enforced, no active tenant (`tenantId === null`) → only global signals.\n * - enforced, tenant `T` → `T`'s signals plus global signals.\n */\nexport function signalVisibleToTenant(\n sig: ChangeSignal,\n scope: DispatchTenantScope,\n): boolean {\n if (!scope.enforced) return true;\n if (scope.tenantId === null) return sig.tenantId === null;\n return sig.tenantId === scope.tenantId || sig.tenantId === null;\n}\n\n/**\n * SSE frame for a change signal. The `data` JSON is EXACTLY\n * `{table, operation, rowId, tenantId}` — the `seq` lives only in the `id:`\n * field (the EventSource `Last-Event-ID` a client echoes to resume).\n */\nfunction encodeSseEvent(sig: ChangeSignal): Uint8Array {\n const data = JSON.stringify({\n table: sig.table,\n operation: sig.operation,\n rowId: sig.rowId,\n tenantId: sig.tenantId,\n });\n return encoder.encode(`id: ${sig.seq}\\nevent: change\\ndata: ${data}\\n\\n`);\n}\n\n/** SSE resync frame. The id advances EventSource past an unservable cursor. */\nfunction encodeSseResyncEvent(cursor: number): Uint8Array {\n return encoder.encode(`id: ${cursor}\\nevent: resync\\ndata: {}\\n\\n`);\n}\n\n/** SSE comment line (used for heartbeats — ignored by EventSource). */\nfunction encodeSseComment(text: string): Uint8Array {\n return encoder.encode(`: ${text}\\n\\n`);\n}\n\n/**\n * Build the SSE body stream for an `_events` connection.\n *\n * `start(controller)`:\n * a. **Subscribe FIRST**, before catch-up. Subscribing before the catch-up\n * read closes the gap window: a write landing between subscribe and the\n * catch-up read is delivered twice (once live, once in the replay) — which\n * is safe, since the client dedupes by the SSE `id:`/seq.\n * b. Write the `retry:` reconnection hint.\n * c. If a cursor was supplied, replay changes after it (paging until\n * exhausted); on `resyncRequired`, emit `event: resync` at the server's\n * fresh horizon.\n * d. Start the heartbeat interval.\n *\n * `cancel()` tears down on disconnect: clears the heartbeat and unsubscribes,\n * so a dropped client never leaks its subscription (which would pin the dead\n * controller and keep the cross-replica listener refcount above 0).\n */\nexport function buildChangeEventStream(\n db: DatabaseInterface,\n options: ChangeEventStreamOptions,\n): ReadableStream<Uint8Array> {\n const { cursor, tenantScope } = options;\n const heartbeatMs = options.heartbeatMs ?? DEFAULT_EVENTS_HEARTBEAT_MS;\n\n let unsubscribe: (() => void) | null = null;\n let heartbeat: ReturnType<typeof setInterval> | null = null;\n let closed = false;\n\n const teardown = () => {\n if (closed) return;\n closed = true;\n if (heartbeat) {\n clearInterval(heartbeat);\n heartbeat = null;\n }\n if (unsubscribe) {\n unsubscribe();\n unsubscribe = null;\n }\n };\n\n return new ReadableStream<Uint8Array>({\n async start(controller) {\n // (a) Subscribe FIRST, before catch-up — closes the subscribe/catch-up\n // gap window (a write in between is delivered twice; the client dedupes\n // by seq). The tenant filter uses the scope captured at open, never a\n // per-signal re-resolution.\n unsubscribe = subscribeToChangeSignals(db, (sig) => {\n if (closed) return;\n if (!signalVisibleToTenant(sig, tenantScope)) return;\n try {\n controller.enqueue(encodeSseEvent(sig));\n } catch {\n // Controller already closed (client gone before cancel fired) —\n // tear down so we stop trying to write to a dead controller.\n teardown();\n }\n });\n\n // (b) Reconnection hint.\n controller.enqueue(encoder.encode('retry: 3000\\n\\n'));\n\n // (c) Catch-up replay from the cursor, if one was supplied.\n if (cursor != null) {\n try {\n // Catch-up MUST filter by the scope captured at connection open, not\n // re-resolve the tenant via ALS at call time. start() happens to run\n // in-request today, but relying on that is fragile — and it must match\n // the live-signal filter exactly (signalVisibleToTenant): when\n // enforced, `scope.tenantId` (a tenant id → that tenant + global; null\n // → global only); when not enforced, undefined → no tenant filter.\n const catchupTenantId = tenantScope.enforced\n ? tenantScope.tenantId\n : undefined;\n let since = cursor;\n // Page until exhausted (cursor stops advancing / resync).\n for (;;) {\n const page = await getChangesSince(db, {\n since,\n tenantId: catchupTenantId,\n });\n if (page.resyncRequired) {\n const resyncCursor =\n typeof page.resyncCursor === 'number' &&\n Number.isFinite(page.resyncCursor) &&\n page.resyncCursor >= 0\n ? page.resyncCursor\n : since;\n controller.enqueue(encodeSseResyncEvent(resyncCursor));\n break;\n }\n for (const change of page.changes) {\n controller.enqueue(\n encodeSseEvent({\n table: change.table,\n operation: change.operation,\n rowId: change.rowId,\n tenantId: change.tenantId,\n seq: change.seq,\n }),\n );\n }\n if (closed) break;\n if (page.cursor === since || page.changes.length === 0) {\n break;\n }\n since = page.cursor;\n // NOTE: catch-up enqueues per-page without a hard cap. It is\n // bounded — an over-old cursor hits `resyncRequired` and stops — but\n // a large retention window replayed to a slow client could spike\n // memory. Honor the controller's backpressure signal cheaply: when\n // the internal queue is full (`desiredSize <= 0`), yield between\n // pages so the consumer drains first. Bounded by `closed` (set on\n // cancel/disconnect), so it can't spin on a client that never reads.\n while (\n !closed &&\n controller.desiredSize !== null &&\n controller.desiredSize <= 0\n ) {\n await new Promise((resolve) => setTimeout(resolve, 5));\n }\n }\n } catch (error) {\n logger.warn('_events: cursor catch-up failed', {\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n\n // (d) Heartbeat keeps intermediaries from idling the connection out.\n heartbeat = setInterval(() => {\n if (closed) return;\n try {\n controller.enqueue(encodeSseComment('heartbeat'));\n } catch {\n teardown();\n }\n }, heartbeatMs);\n // Do not keep the event loop alive solely for heartbeats.\n (heartbeat as { unref?: () => void }).unref?.();\n },\n cancel() {\n // Client disconnected (abort) — release the subscription + heartbeat.\n teardown();\n },\n });\n}\n\n/**\n * Handle a request against the generated `_events` route.\n *\n * Returns 405 for non-GET; 401 when no auth middleware is configured\n * (fail-closed) or the middleware rejects; 503 when the generator has no\n * database; otherwise a 200 `text/event-stream` response whose body is the\n * live signal stream (built by {@link buildChangeEventStream}).\n */\nexport async function handleEventsRoute(\n req: Request,\n options: EventsRouteOptions,\n): Promise<Response> {\n if (req.method !== 'GET') {\n return new Response(JSON.stringify({ error: 'Method not allowed' }), {\n status: 405,\n headers: { 'Content-Type': 'application/json' },\n });\n }\n\n // Fail-closed (#1540): the signal stream spans every table, so it is never\n // public — an auth middleware must be configured and must pass.\n if (!options.authMiddleware) {\n return new Response(JSON.stringify({ error: 'Authentication required' }), {\n status: 401,\n headers: { 'Content-Type': 'application/json' },\n });\n }\n const authCheck = options.authMiddleware(\n EVENTS_ROUTE_OBJECT_NAME,\n req.method.toLowerCase(),\n );\n const authResult = await authCheck(req);\n if (authResult instanceof Response) {\n return authResult;\n }\n\n if (options.db == null) {\n return new Response(\n JSON.stringify({\n error:\n 'Live events unavailable: no database configured for the API generator',\n }),\n { status: 503, headers: { 'Content-Type': 'application/json' } },\n );\n }\n\n const db = await resolveChangesDb(options.db);\n // A raw handle passed straight to the generator may not have gone through\n // framework init; the feed table backs cursor catch-up.\n await ensureChangeFeedTable(db);\n\n // Cursor: Last-Event-ID (reconnection) takes precedence over ?since=.\n // Default = live-forward only (no catch-up).\n const cursor = parseCursor(authResult);\n\n // Capture the tenant scope ONCE at connection open — delivery runs outside\n // any tenant ALS context and must filter against this fixed value.\n const tenantScope = resolveDispatchTenantScope();\n\n return new Response(buildChangeEventStream(db, { cursor, tenantScope }), {\n status: 200,\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache, no-transform',\n Connection: 'keep-alive',\n 'X-Accel-Buffering': 'no',\n },\n });\n}\n\n/**\n * Resolve the catch-up cursor for a request: `Last-Event-ID` header first\n * (what an auto-reconnecting EventSource sends), then `?since=`. Returns a\n * non-negative integer, or `null` for live-forward only.\n */\nfunction parseCursor(req: Request): number | null {\n const lastEventId = req.headers.get('Last-Event-ID');\n if (lastEventId !== null && lastEventId.trim() !== '') {\n const n = Number(lastEventId);\n if (Number.isFinite(n) && n >= 0) return Math.floor(n);\n }\n\n const since = new URL(req.url).searchParams.get('since');\n if (since !== null && since.trim() !== '') {\n const n = Number(since);\n if (Number.isFinite(n) && n >= 0) return Math.floor(n);\n }\n\n return null;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"events-route.js","names":[],"sources":["../../src/generators/events-route.ts"],"sourcesContent":["/**\n * Generated `_events` SSE route — live change signals (issue #1763, SERVER\n * half; parent PRD #1755).\n *\n * Handles `GET {basePath}/_events` in the REST generator: an auth-guarded,\n * tenant-scoped Server-Sent-Events stream of coarse change signals ({table,\n * operation, rowId, tenantId} + a `seq` cursor in the SSE `id:` field). It is\n * the push companion to the pull-based `_changes` route (#1758): a subscriber\n * reacts to a signal by re-reading through the authorized collection routes,\n * so **no row payload ever crosses this channel** — authorization stays\n * entirely on the read path.\n *\n * The stream lifecycle (subscribe, catch-up replay, heartbeat, teardown) lives\n * in {@link buildChangeEventStream} so it is written and tested once; both the\n * REST generator here and the generated SvelteKit route import it. `rest.ts`\n * only registers the path.\n *\n * Contract:\n * - **Fail-closed auth** (identical to `_changes`, #1540 posture): no\n * `authMiddleware` configured → 401; the middleware may return a Response to\n * short-circuit (e.g. 403). The feed spans every table, so per-model\n * `api: { public }` opt-outs deliberately do not apply.\n * - **Tenant scope is captured ONCE at connection open** — signal delivery\n * happens from a different async context (the writer's afterSave, possibly\n * another request or replica) with no tenant ALS active, so the filter must\n * be the value resolved at subscribe time, not re-resolved per signal.\n * - **Same-origin only** for this slice: `rest.ts` does NOT wrap `_events` in\n * CORS headers. `EventSource` cannot set request headers and credentialed\n * cross-origin SSE needs `Access-Control-Allow-Credentials` the CORS helper\n * does not emit; cross-origin SSE is a deliberate follow-up.\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { ensureChangeFeedTable, getChangesSince } from '../change-feed.js';\nimport {\n type ChangeSignal,\n changeSignalSubscriberCount,\n subscribeToChangeSignals,\n tryReserveChangeSignalSubscriberSlot,\n} from '../change-signals.js';\nimport {\n type DispatchTenantScope,\n resolveDispatchTenantScope,\n} from '../dispatch/tenant-resolver.js';\nimport {\n type ChangesAuthMiddleware,\n resolveChangesDb,\n} from './changes-route.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/** Options for the `_events` route handler. */\nexport interface EventsRouteOptions {\n /** The generator's configured auth middleware, if any. */\n authMiddleware?: ChangesAuthMiddleware;\n /** The generator's `APIContext.db` (instance, config object, or URL string). */\n db?: unknown;\n /**\n * The build's web-collection shape digest (#1764). When supplied, the stream\n * emits it in a connection-open `manifest` event so long-lived tabs can latch\n * `updateAvailable.contract` on reconnect (#1859).\n */\n manifestHash?: string;\n /**\n * Per-process cap on active `_events` subscribers (#1860). Defaults to\n * {@link DEFAULT_EVENTS_MAX_SUBSCRIBERS}; new over-cap connections receive a\n * retryable 503 and existing subscribers are left untouched. Set to 0 for no\n * cap.\n */\n maxSubscribers?: number;\n}\n\n/**\n * Pseudo object name passed to the auth middleware for the events route, so\n * middlewares can recognize and specially authorize it (mirrors `_changes`).\n */\nexport const EVENTS_ROUTE_OBJECT_NAME = '_events';\n\n/** Default heartbeat interval (ms). Overridable via stream options. */\nexport const DEFAULT_EVENTS_HEARTBEAT_MS = 15000;\n\n/** Default per-process `_events` subscriber cap (#1860). */\nexport const DEFAULT_EVENTS_MAX_SUBSCRIBERS = 1000;\n\n/** Retry hint for over-cap `_events` connections (#1860). */\nexport const DEFAULT_EVENTS_RETRY_AFTER_SECONDS = 5;\n\nconst encoder = new TextEncoder();\n\n/** Options for {@link buildChangeEventStream}. */\nexport interface ChangeEventStreamOptions {\n /**\n * Catch-up cursor. When a non-negative number, changes after it are replayed\n * before going live; `null` means live-forward only (no catch-up).\n */\n cursor: number | null;\n /**\n * Tenant scope captured at connection open. Delivery filters against this\n * fixed value — it must NOT be re-resolved per signal (delivery runs outside\n * any tenant ALS context).\n */\n tenantScope: DispatchTenantScope;\n /** Heartbeat interval (ms). Defaults to {@link DEFAULT_EVENTS_HEARTBEAT_MS}. */\n heartbeatMs?: number;\n /**\n * Optional server manifest hash emitted once at connection open as\n * `event: manifest`. The hash carries no tenant/user data.\n */\n manifestHash?: string;\n /**\n * Reservation claimed at the route boundary before the streaming response was\n * returned. Released when the stream subscribes, or during teardown if the\n * stream never reaches `start()`.\n */\n releaseSubscriberSlot?: () => void;\n}\n\n/**\n * Normalize an `_events` subscriber cap.\n *\n * `0` means unlimited rather than reject-all, matching common limit semantics.\n * Invalid values fall back to the default operational cap.\n */\nexport function normalizeEventsMaxSubscribers(\n value: number | undefined,\n): number | null {\n if (value === undefined) return DEFAULT_EVENTS_MAX_SUBSCRIBERS;\n if (value === 0) return null;\n if (!Number.isFinite(value) || value < 0) {\n return DEFAULT_EVENTS_MAX_SUBSCRIBERS;\n }\n return Math.floor(value);\n}\n\n/** True when opening a new `_events` stream would exceed the configured cap. */\nexport function changeEventSubscribersAtCapacity(\n db: DatabaseInterface,\n maxSubscribers?: number,\n): boolean {\n const normalizedMaxSubscribers =\n normalizeEventsMaxSubscribers(maxSubscribers);\n return (\n normalizedMaxSubscribers !== null &&\n changeSignalSubscriberCount(db) >= normalizedMaxSubscribers\n );\n}\n\n/**\n * Atomically claim one `_events` subscriber slot at the route boundary.\n * Returns null when the configured cap is already reached.\n */\nexport function tryReserveChangeEventSubscriberSlot(\n db: DatabaseInterface,\n maxSubscribers?: number,\n): (() => void) | null {\n return tryReserveChangeSignalSubscriberSlot(\n db,\n normalizeEventsMaxSubscribers(maxSubscribers),\n );\n}\n\n/** Retryable over-cap response shared by REST and generated SvelteKit routes. */\nexport function eventStreamCapacityExceededResponse(): Response {\n return new Response(\n JSON.stringify({\n error: 'Live events unavailable: subscriber capacity reached',\n }),\n {\n status: 503,\n headers: {\n 'Content-Type': 'application/json',\n 'Retry-After': String(DEFAULT_EVENTS_RETRY_AFTER_SECONDS),\n },\n },\n );\n}\n\n/**\n * Whether a signal is visible to a captured tenant scope. Exact same rule as\n * `getChangesSince`'s tenantId filter, run **synchronously server-side** inside\n * the enqueue callback before any byte hits the wire:\n * - not enforced → visible.\n * - enforced, no active tenant (`tenantId === null`) → only global signals.\n * - enforced, tenant `T` → `T`'s signals plus global signals.\n */\nexport function signalVisibleToTenant(\n sig: ChangeSignal,\n scope: DispatchTenantScope,\n): boolean {\n if (!scope.enforced) return true;\n if (scope.tenantId === null) return sig.tenantId === null;\n return sig.tenantId === scope.tenantId || sig.tenantId === null;\n}\n\n/**\n * SSE frame for a change signal. The `data` JSON is EXACTLY\n * `{table, operation, rowId, tenantId}` — the `seq` lives only in the `id:`\n * field (the EventSource `Last-Event-ID` a client echoes to resume).\n */\nfunction encodeSseEvent(sig: ChangeSignal): Uint8Array {\n const data = JSON.stringify({\n table: sig.table,\n operation: sig.operation,\n rowId: sig.rowId,\n tenantId: sig.tenantId,\n });\n return encoder.encode(`id: ${sig.seq}\\nevent: change\\ndata: ${data}\\n\\n`);\n}\n\n/** SSE manifest frame emitted at connection open for live contract detection. */\nfunction encodeSseManifestEvent(manifestHash: string): Uint8Array {\n return encoder.encode(\n `event: manifest\\ndata: ${JSON.stringify({ manifestHash })}\\n\\n`,\n );\n}\n\n/** SSE resync frame. The id advances EventSource past an unservable cursor. */\nfunction encodeSseResyncEvent(cursor: number): Uint8Array {\n return encoder.encode(`id: ${cursor}\\nevent: resync\\ndata: {}\\n\\n`);\n}\n\n/** SSE comment line (used for heartbeats — ignored by EventSource). */\nfunction encodeSseComment(text: string): Uint8Array {\n return encoder.encode(`: ${text}\\n\\n`);\n}\n\n/**\n * Build the SSE body stream for an `_events` connection.\n *\n * `start(controller)`:\n * a. **Subscribe FIRST**, before catch-up. Subscribing before the catch-up\n * read closes the gap window: a write landing between subscribe and the\n * catch-up read is delivered twice (once live, once in the replay) — which\n * is safe, since the client dedupes by the SSE `id:`/seq.\n * b. Write the `retry:` reconnection hint.\n * c. If a cursor was supplied, replay changes after it (paging until\n * exhausted); on `resyncRequired`, emit `event: resync` at the server's\n * fresh horizon.\n * d. Start the heartbeat interval.\n *\n * `cancel()` tears down on disconnect: clears the heartbeat and unsubscribes,\n * so a dropped client never leaks its subscription (which would pin the dead\n * controller and keep the cross-replica listener refcount above 0).\n */\nexport function buildChangeEventStream(\n db: DatabaseInterface,\n options: ChangeEventStreamOptions,\n): ReadableStream<Uint8Array> {\n const { cursor, tenantScope, manifestHash } = options;\n const heartbeatMs = options.heartbeatMs ?? DEFAULT_EVENTS_HEARTBEAT_MS;\n\n let unsubscribe: (() => void) | null = null;\n let releaseSubscriberSlot = options.releaseSubscriberSlot ?? null;\n let heartbeat: ReturnType<typeof setInterval> | null = null;\n let closed = false;\n\n const teardown = () => {\n if (closed) return;\n closed = true;\n if (heartbeat) {\n clearInterval(heartbeat);\n heartbeat = null;\n }\n if (unsubscribe) {\n unsubscribe();\n unsubscribe = null;\n }\n if (releaseSubscriberSlot) {\n releaseSubscriberSlot();\n releaseSubscriberSlot = null;\n }\n };\n\n return new ReadableStream<Uint8Array>({\n async start(controller) {\n // (a) Subscribe FIRST, before catch-up — closes the subscribe/catch-up\n // gap window (a write in between is delivered twice; the client dedupes\n // by seq). The tenant filter uses the scope captured at open, never a\n // per-signal re-resolution.\n unsubscribe = subscribeToChangeSignals(db, (sig) => {\n if (closed) return;\n if (!signalVisibleToTenant(sig, tenantScope)) return;\n try {\n controller.enqueue(encodeSseEvent(sig));\n } catch {\n // Controller already closed (client gone before cancel fired) —\n // tear down so we stop trying to write to a dead controller.\n teardown();\n }\n });\n if (releaseSubscriberSlot) {\n releaseSubscriberSlot();\n releaseSubscriberSlot = null;\n }\n\n // (b) Reconnection hint.\n controller.enqueue(encoder.encode('retry: 3000\\n\\n'));\n // Advertise the server contract at connection open (#1859). A reconnect\n // naturally replays this frame, letting a long-lived tab learn about a\n // shape-only API deploy without a full page load.\n if (manifestHash !== undefined) {\n controller.enqueue(encodeSseManifestEvent(manifestHash));\n }\n\n // (c) Catch-up replay from the cursor, if one was supplied.\n if (cursor != null) {\n try {\n // Catch-up MUST filter by the scope captured at connection open, not\n // re-resolve the tenant via ALS at call time. start() happens to run\n // in-request today, but relying on that is fragile — and it must match\n // the live-signal filter exactly (signalVisibleToTenant): when\n // enforced, `scope.tenantId` (a tenant id → that tenant + global; null\n // → global only); when not enforced, undefined → no tenant filter.\n const catchupTenantId = tenantScope.enforced\n ? tenantScope.tenantId\n : undefined;\n let since = cursor;\n // Page until exhausted (cursor stops advancing / resync).\n for (;;) {\n const page = await getChangesSince(db, {\n since,\n tenantId: catchupTenantId,\n });\n if (page.resyncRequired) {\n const resyncCursor =\n typeof page.resyncCursor === 'number' &&\n Number.isFinite(page.resyncCursor) &&\n page.resyncCursor >= 0\n ? page.resyncCursor\n : since;\n controller.enqueue(encodeSseResyncEvent(resyncCursor));\n break;\n }\n for (const change of page.changes) {\n controller.enqueue(\n encodeSseEvent({\n table: change.table,\n operation: change.operation,\n rowId: change.rowId,\n tenantId: change.tenantId,\n seq: change.seq,\n }),\n );\n }\n if (closed) break;\n if (page.cursor === since || page.changes.length === 0) {\n break;\n }\n since = page.cursor;\n // NOTE: catch-up enqueues per-page without a hard cap. It is\n // bounded — an over-old cursor hits `resyncRequired` and stops — but\n // a large retention window replayed to a slow client could spike\n // memory. Honor the controller's backpressure signal cheaply: when\n // the internal queue is full (`desiredSize <= 0`), yield between\n // pages so the consumer drains first. Bounded by `closed` (set on\n // cancel/disconnect), so it can't spin on a client that never reads.\n while (\n !closed &&\n controller.desiredSize !== null &&\n controller.desiredSize <= 0\n ) {\n await new Promise((resolve) => setTimeout(resolve, 5));\n }\n }\n } catch (error) {\n logger.warn('_events: cursor catch-up failed', {\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n\n // (d) Heartbeat keeps intermediaries from idling the connection out.\n heartbeat = setInterval(() => {\n if (closed) return;\n try {\n controller.enqueue(encodeSseComment('heartbeat'));\n } catch {\n teardown();\n }\n }, heartbeatMs);\n // Do not keep the event loop alive solely for heartbeats.\n (heartbeat as { unref?: () => void }).unref?.();\n },\n cancel() {\n // Client disconnected (abort) — release the subscription + heartbeat.\n teardown();\n },\n });\n}\n\n/**\n * Handle a request against the generated `_events` route.\n *\n * Returns 405 for non-GET; 401 when no auth middleware is configured\n * (fail-closed) or the middleware rejects; 503 when the generator has no\n * database; otherwise a 200 `text/event-stream` response whose body is the\n * live signal stream (built by {@link buildChangeEventStream}).\n */\nexport async function handleEventsRoute(\n req: Request,\n options: EventsRouteOptions,\n): Promise<Response> {\n if (req.method !== 'GET') {\n return new Response(JSON.stringify({ error: 'Method not allowed' }), {\n status: 405,\n headers: { 'Content-Type': 'application/json' },\n });\n }\n\n // Fail-closed (#1540): the signal stream spans every table, so it is never\n // public — an auth middleware must be configured and must pass.\n if (!options.authMiddleware) {\n return new Response(JSON.stringify({ error: 'Authentication required' }), {\n status: 401,\n headers: { 'Content-Type': 'application/json' },\n });\n }\n const authCheck = options.authMiddleware(\n EVENTS_ROUTE_OBJECT_NAME,\n req.method.toLowerCase(),\n );\n const authResult = await authCheck(req);\n if (authResult instanceof Response) {\n return authResult;\n }\n\n if (options.db == null) {\n return new Response(\n JSON.stringify({\n error:\n 'Live events unavailable: no database configured for the API generator',\n }),\n { status: 503, headers: { 'Content-Type': 'application/json' } },\n );\n }\n\n const db = await resolveChangesDb(options.db);\n // A raw handle passed straight to the generator may not have gone through\n // framework init; the feed table backs cursor catch-up.\n await ensureChangeFeedTable(db);\n const releaseSubscriberSlot = tryReserveChangeEventSubscriberSlot(\n db,\n options.maxSubscribers,\n );\n if (!releaseSubscriberSlot) {\n return eventStreamCapacityExceededResponse();\n }\n\n // Cursor: Last-Event-ID (reconnection) takes precedence over ?since=.\n // Default = live-forward only (no catch-up).\n const cursor = parseCursor(authResult);\n\n // Capture the tenant scope ONCE at connection open — delivery runs outside\n // any tenant ALS context and must filter against this fixed value.\n const tenantScope = resolveDispatchTenantScope();\n\n return new Response(\n buildChangeEventStream(db, {\n cursor,\n tenantScope,\n manifestHash: options.manifestHash,\n releaseSubscriberSlot,\n }),\n {\n status: 200,\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache, no-transform',\n Connection: 'keep-alive',\n 'X-Accel-Buffering': 'no',\n },\n },\n );\n}\n\n/**\n * Resolve the catch-up cursor for a request: `Last-Event-ID` header first\n * (what an auto-reconnecting EventSource sends), then `?since=`. Returns a\n * non-negative integer, or `null` for live-forward only.\n */\nfunction parseCursor(req: Request): number | null {\n const lastEventId = req.headers.get('Last-Event-ID');\n if (lastEventId !== null && lastEventId.trim() !== '') {\n const n = Number(lastEventId);\n if (Number.isFinite(n) && n >= 0) return Math.floor(n);\n }\n\n const since = new URL(req.url).searchParams.get('since');\n if (since !== null && since.trim() !== '') {\n const n = Number(since);\n if (Number.isFinite(n) && n >= 0) return Math.floor(n);\n }\n\n return null;\n}\n"],"mappings":"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"}
@@ -4,11 +4,11 @@
4
4
  export type { CLIConfig, CLIContext } from './cli';
5
5
  export { CLIGenerator, getCLIHandler, setupCLI } from './cli';
6
6
  export { canonicalReadRepresentation, computeBodyEtag, computeTableVersionEtag, conditionalJsonResponse, ifNoneMatchHasConcreteMatch, ifNoneMatchSatisfied, PRIVATE_READ_CACHE_CONTROL, type ReadCacheControlOptions, resolveReadCacheControl, resolveTenantEtagDiscriminator, versionConditionalResponse, warnIfSharedCacheNeutralized, } from './conditional-get';
7
- export { buildChangeEventStream, type ChangeEventStreamOptions, DEFAULT_EVENTS_HEARTBEAT_MS, signalVisibleToTenant, } from './events-route';
7
+ export { buildChangeEventStream, type ChangeEventStreamOptions, changeEventSubscribersAtCapacity, DEFAULT_EVENTS_HEARTBEAT_MS, DEFAULT_EVENTS_MAX_SUBSCRIBERS, DEFAULT_EVENTS_RETRY_AFTER_SECONDS, eventStreamCapacityExceededResponse, normalizeEventsMaxSubscribers, signalVisibleToTenant, tryReserveChangeEventSubscriberSlot, } from './events-route';
8
8
  export type { MCPConfig, MCPContext, MCPRequest, MCPResponse, MCPTool, } from './mcp';
9
9
  export { MCPGenerator } from './mcp';
10
10
  export type { APIConfig, APIContext, RestServerConfig } from './rest';
11
- export { APIGenerator, createRestServer, startRestServer } from './rest';
11
+ export { APIGenerator, computeRuntimeWebManifestHash, createRestServer, startRestServer, } from './rest';
12
12
  export type { OpenAPIConfig } from './swagger';
13
13
  export { generateOpenAPISpec, setupSwaggerUI, } from './swagger';
14
14
  export { runWithTenantGate, setTenantEntryPointRunner, type TenantEntryPointRunner, type TenantGateOptions, } from './tenant-gate';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/generators/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,OAAO,CAAC;AAEnD,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAG9D,OAAO,EACL,2BAA2B,EAC3B,eAAe,EACf,uBAAuB,EACvB,uBAAuB,EACvB,2BAA2B,EAC3B,oBAAoB,EACpB,0BAA0B,EAC1B,KAAK,uBAAuB,EAC5B,uBAAuB,EACvB,8BAA8B,EAC9B,0BAA0B,EAC1B,4BAA4B,GAC7B,MAAM,mBAAmB,CAAC;AAK3B,OAAO,EACL,sBAAsB,EACtB,KAAK,wBAAwB,EAC7B,2BAA2B,EAC3B,qBAAqB,GACtB,MAAM,gBAAgB,CAAC;AACxB,YAAY,EACV,SAAS,EACT,UAAU,EACV,UAAU,EACV,WAAW,EACX,OAAO,GACR,MAAM,OAAO,CAAC;AAEf,OAAO,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AACrC,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,QAAQ,CAAC;AAEtE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,QAAQ,CAAC;AACzE,YAAY,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAE/C,OAAO,EACL,mBAAmB,EACnB,cAAc,GACf,MAAM,WAAW,CAAC;AAEnB,OAAO,EACL,iBAAiB,EACjB,yBAAyB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,iBAAiB,GACvB,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/generators/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,OAAO,CAAC;AAEnD,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAG9D,OAAO,EACL,2BAA2B,EAC3B,eAAe,EACf,uBAAuB,EACvB,uBAAuB,EACvB,2BAA2B,EAC3B,oBAAoB,EACpB,0BAA0B,EAC1B,KAAK,uBAAuB,EAC5B,uBAAuB,EACvB,8BAA8B,EAC9B,0BAA0B,EAC1B,4BAA4B,GAC7B,MAAM,mBAAmB,CAAC;AAK3B,OAAO,EACL,sBAAsB,EACtB,KAAK,wBAAwB,EAC7B,gCAAgC,EAChC,2BAA2B,EAC3B,8BAA8B,EAC9B,kCAAkC,EAClC,mCAAmC,EACnC,6BAA6B,EAC7B,qBAAqB,EACrB,mCAAmC,GACpC,MAAM,gBAAgB,CAAC;AACxB,YAAY,EACV,SAAS,EACT,UAAU,EACV,UAAU,EACV,WAAW,EACX,OAAO,GACR,MAAM,OAAO,CAAC;AAEf,OAAO,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AACrC,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,QAAQ,CAAC;AAEtE,OAAO,EACL,YAAY,EACZ,6BAA6B,EAC7B,gBAAgB,EAChB,eAAe,GAChB,MAAM,QAAQ,CAAC;AAChB,YAAY,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAE/C,OAAO,EACL,mBAAmB,EACnB,cAAc,GACf,MAAM,WAAW,CAAC;AAEnB,OAAO,EACL,iBAAiB,EACjB,yBAAyB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,iBAAiB,GACvB,MAAM,eAAe,CAAC"}
@@ -1,8 +1,8 @@
1
1
  import { runWithTenantGate, setTenantEntryPointRunner } from "./tenant-gate.js";
2
2
  import { CLIGenerator, getCLIHandler, setupCLI } from "./cli.js";
3
3
  import { PRIVATE_READ_CACHE_CONTROL, canonicalReadRepresentation, computeBodyEtag, computeTableVersionEtag, conditionalJsonResponse, ifNoneMatchHasConcreteMatch, ifNoneMatchSatisfied, resolveReadCacheControl, resolveTenantEtagDiscriminator, versionConditionalResponse, warnIfSharedCacheNeutralized } from "./conditional-get.js";
4
- import { DEFAULT_EVENTS_HEARTBEAT_MS, buildChangeEventStream, signalVisibleToTenant } from "./events-route.js";
4
+ import { DEFAULT_EVENTS_HEARTBEAT_MS, DEFAULT_EVENTS_MAX_SUBSCRIBERS, DEFAULT_EVENTS_RETRY_AFTER_SECONDS, buildChangeEventStream, changeEventSubscribersAtCapacity, eventStreamCapacityExceededResponse, normalizeEventsMaxSubscribers, signalVisibleToTenant, tryReserveChangeEventSubscriberSlot } from "./events-route.js";
5
5
  import { MCPGenerator } from "./mcp.js";
6
- import { APIGenerator, createRestServer, startRestServer } from "./rest.js";
6
+ import { APIGenerator, computeRuntimeWebManifestHash, createRestServer, startRestServer } from "./rest.js";
7
7
  import { generateOpenAPISpec, setupSwaggerUI } from "./swagger.js";
8
- export { APIGenerator, CLIGenerator, DEFAULT_EVENTS_HEARTBEAT_MS, MCPGenerator, PRIVATE_READ_CACHE_CONTROL, buildChangeEventStream, canonicalReadRepresentation, computeBodyEtag, computeTableVersionEtag, conditionalJsonResponse, createRestServer, generateOpenAPISpec, getCLIHandler, ifNoneMatchHasConcreteMatch, ifNoneMatchSatisfied, resolveReadCacheControl, resolveTenantEtagDiscriminator, runWithTenantGate, setTenantEntryPointRunner, setupCLI, setupSwaggerUI, signalVisibleToTenant, startRestServer, versionConditionalResponse, warnIfSharedCacheNeutralized };
8
+ export { APIGenerator, CLIGenerator, DEFAULT_EVENTS_HEARTBEAT_MS, DEFAULT_EVENTS_MAX_SUBSCRIBERS, DEFAULT_EVENTS_RETRY_AFTER_SECONDS, MCPGenerator, PRIVATE_READ_CACHE_CONTROL, buildChangeEventStream, canonicalReadRepresentation, changeEventSubscribersAtCapacity, computeBodyEtag, computeRuntimeWebManifestHash, computeTableVersionEtag, conditionalJsonResponse, createRestServer, eventStreamCapacityExceededResponse, generateOpenAPISpec, getCLIHandler, ifNoneMatchHasConcreteMatch, ifNoneMatchSatisfied, normalizeEventsMaxSubscribers, resolveReadCacheControl, resolveTenantEtagDiscriminator, runWithTenantGate, setTenantEntryPointRunner, setupCLI, setupSwaggerUI, signalVisibleToTenant, startRestServer, tryReserveChangeEventSubscriberSlot, versionConditionalResponse, warnIfSharedCacheNeutralized };