@nylorun/runtime 0.12.0-beta → 0.14.0-beta

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 (223) hide show
  1. package/CHANGELOG.md +361 -0
  2. package/README.md +69 -15
  3. package/dist/adapters/execution/restate.d.ts +7 -2
  4. package/dist/adapters/execution/restate.js +38 -8
  5. package/dist/adapters/replication/pgoutput.d.ts +16 -0
  6. package/dist/adapters/replication/pgoutput.js +169 -0
  7. package/dist/adapters/streams/s2.js +37 -20
  8. package/dist/admin-openapi.json +1071 -0
  9. package/dist/api/a2a/card.d.ts +15 -0
  10. package/dist/api/a2a/card.js +24 -0
  11. package/dist/api/a2a/endpoint.d.ts +8 -0
  12. package/dist/api/a2a/endpoint.js +81 -0
  13. package/dist/api/a2a/protocol.d.ts +144 -0
  14. package/dist/api/a2a/protocol.js +188 -0
  15. package/dist/api/a2a/routes.d.ts +19 -0
  16. package/dist/api/a2a/routes.js +289 -0
  17. package/dist/api/a2a/tasks.d.ts +50 -0
  18. package/dist/api/a2a/tasks.js +186 -0
  19. package/dist/api/ag-ui/endpoint.d.ts +8 -0
  20. package/dist/api/ag-ui/endpoint.js +126 -0
  21. package/dist/api/ag-ui/history.d.ts +7 -0
  22. package/dist/api/ag-ui/history.js +60 -0
  23. package/dist/api/ag-ui/routes.d.ts +32 -0
  24. package/dist/api/ag-ui/routes.js +305 -0
  25. package/dist/api/ag-ui/session-id.d.ts +1 -0
  26. package/dist/api/ag-ui/session-id.js +12 -0
  27. package/dist/api/ag-ui/sse.d.ts +9 -0
  28. package/dist/api/ag-ui/sse.js +7 -0
  29. package/dist/api/ag-ui/translate.d.ts +38 -0
  30. package/dist/api/ag-ui/translate.js +222 -0
  31. package/dist/api/components.d.ts +7825 -0
  32. package/dist/api/components.js +83 -0
  33. package/dist/api/http/app.d.ts +18 -0
  34. package/dist/api/http/app.js +58 -0
  35. package/dist/api/http/body.d.ts +4 -0
  36. package/dist/api/http/body.js +37 -0
  37. package/dist/api/http/define.d.ts +39 -0
  38. package/dist/api/http/define.js +130 -0
  39. package/dist/api/http/respond.d.ts +9 -0
  40. package/dist/api/http/respond.js +44 -0
  41. package/dist/api/http/routes/access.d.ts +24 -0
  42. package/dist/api/http/routes/access.js +259 -0
  43. package/dist/api/http/routes/actions.d.ts +8 -0
  44. package/dist/api/http/routes/actions.js +76 -0
  45. package/dist/api/http/routes/endpoints.d.ts +7 -0
  46. package/dist/api/http/routes/endpoints.js +100 -0
  47. package/dist/api/http/routes/sessions.d.ts +3 -0
  48. package/dist/api/http/routes/sessions.js +205 -0
  49. package/dist/api/http/routes/tenant.d.ts +10 -0
  50. package/dist/api/http/routes/tenant.js +172 -0
  51. package/dist/api/http/routes/vaults.d.ts +8 -0
  52. package/dist/api/http/routes/vaults.js +167 -0
  53. package/dist/api/openapi.d.ts +19 -0
  54. package/dist/api/openapi.js +138 -0
  55. package/dist/api/route.d.ts +12 -0
  56. package/dist/api/route.js +4 -0
  57. package/dist/core/bearer.d.ts +8 -0
  58. package/dist/core/bearer.js +11 -0
  59. package/dist/core/flow-host.d.ts +7 -15
  60. package/dist/core/flow-host.js +19 -41
  61. package/dist/core/sandbox-routes.d.ts +12 -7
  62. package/dist/core/sandbox-routes.js +24 -25
  63. package/dist/execution/memory.d.ts +8 -5
  64. package/dist/execution/memory.js +33 -13
  65. package/dist/execution/types.d.ts +38 -6
  66. package/dist/execution/types.js +12 -3
  67. package/dist/host/admin-api.d.ts +22 -0
  68. package/dist/host/admin-api.js +178 -0
  69. package/dist/host/app.d.ts +34 -0
  70. package/dist/host/app.js +185 -0
  71. package/dist/host/config-for.d.ts +2 -0
  72. package/dist/host/config-for.js +1 -0
  73. package/dist/host/config.d.ts +11 -0
  74. package/dist/host/cors.d.ts +27 -0
  75. package/dist/host/cors.js +64 -0
  76. package/dist/host/create-host.d.ts +31 -3
  77. package/dist/host/create-host.js +168 -327
  78. package/dist/host/http.d.ts +14 -3
  79. package/dist/host/http.js +64 -23
  80. package/dist/host/main.js +55 -4
  81. package/dist/host/stack-config.d.ts +20 -0
  82. package/dist/host/stack-config.js +55 -0
  83. package/dist/mcp/pool.d.ts +19 -1
  84. package/dist/mcp/pool.js +60 -10
  85. package/dist/model/catalog.d.ts +2 -10
  86. package/dist/model/classify.d.ts +13 -0
  87. package/dist/model/classify.js +61 -0
  88. package/dist/model/models.d.ts +10 -1
  89. package/dist/model/models.js +14 -6
  90. package/dist/model/pi-model.d.ts +17 -2
  91. package/dist/model/pi-model.js +366 -222
  92. package/dist/openapi.json +25696 -0
  93. package/dist/sandbox/manager.d.ts +13 -1
  94. package/dist/sandbox/manager.js +8 -11
  95. package/dist/sandbox/resolve.d.ts +33 -0
  96. package/dist/sandbox/resolve.js +81 -0
  97. package/dist/sandbox/session-sandbox.d.ts +11 -0
  98. package/dist/sandbox/session-sandbox.js +51 -0
  99. package/dist/sandbox/share.d.ts +14 -2
  100. package/dist/sandbox/share.js +33 -3
  101. package/dist/sandbox/tenant-config.d.ts +37 -0
  102. package/dist/sandbox/tenant-config.js +51 -0
  103. package/dist/sandbox/tools.d.ts +7 -9
  104. package/dist/sandbox/tools.js +11 -3
  105. package/dist/sandbox/types.d.ts +8 -2
  106. package/dist/store/event.d.ts +19 -0
  107. package/dist/store/event.js +49 -0
  108. package/dist/store/memory.d.ts +20 -8
  109. package/dist/store/memory.js +265 -100
  110. package/dist/store/postgres/connect.d.ts +13 -0
  111. package/dist/store/postgres/connect.js +5 -0
  112. package/dist/store/postgres/migrations/003_subject_tokens.d.ts +8 -0
  113. package/dist/store/postgres/migrations/003_subject_tokens.js +42 -0
  114. package/dist/store/postgres/migrations/004_publishable_keys.d.ts +6 -0
  115. package/dist/store/postgres/migrations/004_publishable_keys.js +18 -0
  116. package/dist/store/postgres/migrations/005_action_endpoints.d.ts +6 -0
  117. package/dist/store/postgres/migrations/005_action_endpoints.js +32 -0
  118. package/dist/store/postgres/migrations/006_drop_executors.d.ts +11 -0
  119. package/dist/store/postgres/migrations/006_drop_executors.js +32 -0
  120. package/dist/store/postgres/migrations/007_event_time.d.ts +7 -0
  121. package/dist/store/postgres/migrations/007_event_time.js +14 -0
  122. package/dist/store/postgres/migrations/008_durable_streams.d.ts +13 -0
  123. package/dist/store/postgres/migrations/008_durable_streams.js +35 -0
  124. package/dist/store/postgres/migrations/index.d.ts +5 -1
  125. package/dist/store/postgres/migrations/index.js +22 -2
  126. package/dist/store/postgres/migrations/shared/index.d.ts +31 -0
  127. package/dist/store/postgres/migrations/shared/index.js +92 -0
  128. package/dist/store/postgres/record.d.ts +11 -0
  129. package/dist/store/postgres/record.js +64 -0
  130. package/dist/store/postgres/store.js +337 -95
  131. package/dist/store/postgres/tenants.js +14 -0
  132. package/dist/store/types.d.ts +176 -81
  133. package/dist/streams/basin.d.ts +18 -1
  134. package/dist/streams/basin.js +34 -2
  135. package/dist/streams/control.d.ts +18 -0
  136. package/dist/streams/control.js +29 -0
  137. package/dist/streams/memory.d.ts +3 -1
  138. package/dist/streams/memory.js +14 -8
  139. package/dist/streams/relay/core.d.ts +35 -0
  140. package/dist/streams/relay/core.js +366 -0
  141. package/dist/streams/relay/memory.d.ts +69 -0
  142. package/dist/streams/relay/memory.js +176 -0
  143. package/dist/streams/relay/types.d.ts +77 -0
  144. package/dist/streams/relay/types.js +7 -0
  145. package/dist/streams/types.d.ts +66 -36
  146. package/dist/streams/types.js +18 -53
  147. package/dist/tenant/access-policy.d.ts +30 -0
  148. package/dist/tenant/access-policy.js +52 -0
  149. package/dist/tenant/actions.d.ts +10 -149
  150. package/dist/tenant/actions.js +5 -167
  151. package/dist/tenant/advance.js +32 -0
  152. package/dist/tenant/auth.d.ts +19 -17
  153. package/dist/tenant/auth.js +90 -93
  154. package/dist/tenant/browser.d.ts +15 -0
  155. package/dist/tenant/browser.js +39 -0
  156. package/dist/tenant/commands.d.ts +18 -2
  157. package/dist/tenant/commands.js +121 -129
  158. package/dist/tenant/context.d.ts +75 -18
  159. package/dist/tenant/context.js +14 -10
  160. package/dist/tenant/delivery-token.d.ts +41 -0
  161. package/dist/tenant/delivery-token.js +105 -0
  162. package/dist/tenant/delivery.d.ts +62 -0
  163. package/dist/tenant/delivery.js +476 -0
  164. package/dist/tenant/effects.d.ts +5 -0
  165. package/dist/tenant/effects.js +138 -48
  166. package/dist/tenant/endpoints.d.ts +67 -0
  167. package/dist/tenant/endpoints.js +90 -0
  168. package/dist/tenant/ephemeral.d.ts +6 -0
  169. package/dist/tenant/ephemeral.js +60 -36
  170. package/dist/tenant/http.d.ts +8 -9
  171. package/dist/tenant/http.js +13 -27
  172. package/dist/tenant/jwt.d.ts +16 -0
  173. package/dist/tenant/jwt.js +50 -0
  174. package/dist/tenant/module.d.ts +3 -0
  175. package/dist/tenant/module.js +6 -12
  176. package/dist/tenant/outbound.d.ts +44 -0
  177. package/dist/tenant/outbound.js +127 -0
  178. package/dist/tenant/pool.d.ts +0 -2
  179. package/dist/tenant/pool.js +0 -18
  180. package/dist/tenant/reset.d.ts +0 -4
  181. package/dist/tenant/reset.js +0 -5
  182. package/dist/tenant/runtime.d.ts +10 -20
  183. package/dist/tenant/runtime.js +59 -42
  184. package/dist/tenant/scheduler.d.ts +7 -2
  185. package/dist/tenant/scheduler.js +7 -3
  186. package/dist/tenant/session-streams.d.ts +125 -0
  187. package/dist/tenant/session-streams.js +399 -0
  188. package/dist/tenant/sessions.d.ts +28 -4
  189. package/dist/tenant/sessions.js +118 -24
  190. package/dist/tenant/signing-keys.d.ts +55 -0
  191. package/dist/tenant/signing-keys.js +172 -0
  192. package/dist/tenant/slim.d.ts +8 -0
  193. package/dist/tenant/slim.js +21 -0
  194. package/dist/tenant/status.d.ts +0 -3
  195. package/dist/tenant/status.js +19 -12
  196. package/dist/tenant/streams.d.ts +60 -67
  197. package/dist/tenant/streams.js +140 -208
  198. package/dist/tenant/subject-limits.d.ts +16 -0
  199. package/dist/tenant/subject-limits.js +45 -0
  200. package/dist/tenant/sweep.d.ts +1 -10
  201. package/dist/tenant/sweep.js +29 -69
  202. package/dist/tenant/tokens.d.ts +14 -0
  203. package/dist/tenant/tokens.js +178 -0
  204. package/dist/tenant/transcript.d.ts +48 -5
  205. package/dist/tenant/transcript.js +58 -1
  206. package/dist/tenant/types.d.ts +29 -7
  207. package/dist/tenant/worker.d.ts +3 -1
  208. package/dist/tenant/worker.js +9 -3
  209. package/dist/vault/service.d.ts +18 -2
  210. package/dist/vault/service.js +57 -13
  211. package/dist/version.d.ts +1 -1
  212. package/dist/version.js +1 -1
  213. package/package.json +20 -8
  214. package/dist/core/executors.d.ts +0 -44
  215. package/dist/core/executors.js +0 -95
  216. package/dist/streams/relay.d.ts +0 -37
  217. package/dist/streams/relay.js +0 -254
  218. package/dist/tenant/live.d.ts +0 -92
  219. package/dist/tenant/live.js +0 -290
  220. package/dist/tenant/routes-tenant.d.ts +0 -12
  221. package/dist/tenant/routes-tenant.js +0 -137
  222. package/dist/tenant/routes.d.ts +0 -9
  223. package/dist/tenant/routes.js +0 -151
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Durable Session Execution on Restate (architecture §12.3).
3
3
  *
4
- * The only module that imports the Restate SDK. It serves three virtual
4
+ * The only module that imports the Restate SDK. It serves four virtual
5
5
  * objects on the Worker endpoint, all named with `servicePrefix`:
6
6
  *
7
7
  * - `<prefix>NylorunSession`, key `<tenantId>:<sessionId>`. Its exclusive
@@ -17,9 +17,14 @@
17
17
  * - `<prefix>NylorunTimer`, key `<tenantId>:<timer key>`. `set` records the
18
18
  * latest time and sends a delayed `fire`; a `fire` for an older time is a
19
19
  * no-op, which is how setting a key again replaces it.
20
+ * - `<prefix>NylorunAction`, key `<tenantId>:<actionId>`. Its exclusive
21
+ * `deliver` handler calls `WorkerHandlers.deliver`. Like `advance`, it keeps
22
+ * no state and makes no `ctx.run`: its only journal entry is the delayed
23
+ * self-send after a `retry` result, so an endpoint that is down never spends
24
+ * the retry budget, which is for infrastructure errors.
20
25
  *
21
- * Wakes, timers and sweep arming go through the ingress as one-way sends, so
22
- * an API node can call them without ever calling `start`.
26
+ * Wakes, deliveries, timers and sweep arming go through the ingress as one-way
27
+ * sends, so an API node can call them without ever calling `start`.
23
28
  */
24
29
  import { createServer } from "node:http2";
25
30
  import * as restate from "@restatedev/restate-sdk";
@@ -49,6 +54,7 @@ export class RestateExecution {
49
54
  session: `${prefix}NylorunSession`,
50
55
  tenant: `${prefix}NylorunTenant`,
51
56
  timer: `${prefix}NylorunTimer`,
57
+ action: `${prefix}NylorunAction`,
52
58
  };
53
59
  this.ingressUrl = options.ingressUrl.replace(/\/+$/, "");
54
60
  this.adminUrl = options.adminUrl.replace(/\/+$/, "");
@@ -63,6 +69,9 @@ export class RestateExecution {
63
69
  throw new Error(`Unknown wake reason: ${String(wake.reason)}`);
64
70
  await this.send(this.names.session, sessionKey(tenantId, sessionId), "advance", { reason: wake.reason }, wake.dedupeKey);
65
71
  }
72
+ async deliver(tenantId, actionId) {
73
+ await this.send(this.names.action, sessionKey(tenantId, actionId), "deliver", {});
74
+ }
66
75
  async timer(tenantId, key, at) {
67
76
  await this.send(this.names.timer, sessionKey(tenantId, key), "set", {
68
77
  at: at.getTime(),
@@ -161,7 +170,7 @@ export class RestateExecution {
161
170
  handlers: {
162
171
  advance: async (ctx, _wake) => {
163
172
  const { tenantId, sessionId } = parseSessionKey(ctx.key);
164
- const result = await this.runAdvance(ctx.request().attemptCompletedSignal, (handlers, signal) => handlers.advance(tenantId, sessionId, signal));
173
+ const result = await this.runAborted(ctx.request().attemptCompletedSignal, (handlers, signal) => handlers.advance(tenantId, sessionId, signal));
165
174
  if (result.status === "busy")
166
175
  ctx
167
176
  .objectSendClient({ name: names.session }, ctx.key)
@@ -232,10 +241,31 @@ export class RestateExecution {
232
241
  },
233
242
  options: serviceOptions,
234
243
  });
235
- return [session, tenant, timer];
244
+ const action = restate.object({
245
+ name: names.action,
246
+ handlers: {
247
+ deliver: async (ctx, _input) => {
248
+ const { tenantId, sessionId: actionId } = parseSessionKey(ctx.key);
249
+ const result = await this.runAborted(ctx.request().attemptCompletedSignal, (handlers, signal) => {
250
+ if (!handlers.deliver)
251
+ throw new Error("WorkerHandlers.deliver is required for deliveries");
252
+ return handlers.deliver(tenantId, actionId, signal);
253
+ });
254
+ if (result.status === "retry")
255
+ ctx
256
+ .objectSendClient({ name: names.action }, ctx.key)
257
+ .deliver({}, restate.rpc.sendOpts({ delay: Math.max(0, result.retryAfterMs) }));
258
+ },
259
+ },
260
+ options: serviceOptions,
261
+ });
262
+ return [session, tenant, timer, action];
236
263
  }
237
- /** Runs `advance` with a signal aborted by `stop` or by the end of the Restate attempt. */
238
- async runAdvance(attemptDone, fn) {
264
+ /**
265
+ * Runs a Worker handler with a signal aborted by `stop` or by the end of the Restate
266
+ * attempt (`advance` and `deliver`).
267
+ */
268
+ async runAborted(attemptDone, fn) {
239
269
  const controller = new AbortController();
240
270
  const onAttemptDone = () => controller.abort(new Error("Restate attempt ended"));
241
271
  if (attemptDone.aborted)
@@ -335,7 +365,7 @@ export async function listStuckInvocations(options) {
335
365
  const prefix = options.servicePrefix ?? "";
336
366
  if (!/^[A-Za-z0-9_]*$/.test(prefix))
337
367
  throw new Error(`Invalid Restate service prefix: ${prefix}`);
338
- const services = ["NylorunSession", "NylorunTenant", "NylorunTimer"].map((name) => `'${prefix}${name}'`);
368
+ const services = ["NylorunSession", "NylorunTenant", "NylorunTimer", "NylorunAction"].map((name) => `'${prefix}${name}'`);
339
369
  const clauses = [
340
370
  `target_service_name IN (${services.join(", ")})`,
341
371
  `status IN ('paused', 'backing-off')`,
@@ -0,0 +1,16 @@
1
+ import type { ChangeSource } from "../../streams/relay/types.js";
2
+ export interface PgoutputSourceOptions {
3
+ /** A Postgres URL whose role may replicate (`REPLICATION`) and read `nylorun_streams`. */
4
+ connectionString: string;
5
+ /** Default `nylorun_stream_relay`. */
6
+ slot?: string;
7
+ /** Default `nylorun_stream_relay`. */
8
+ publication?: string;
9
+ /** The schema of the record tables. Default `nylorun_streams`. */
10
+ schema?: string;
11
+ /** Wait between attempts to take the slot. Default 5 s. */
12
+ retryMs?: number;
13
+ log?: (message: string, fields?: Record<string, unknown>) => void;
14
+ }
15
+ export declare const DEFAULT_RELAY_SLOT = "nylorun_stream_relay";
16
+ export declare function createPgoutputSource(options: PgoutputSourceOptions): ChangeSource;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The stream relay's change source on Postgres logical replication (Durable Streams §7.1,
3
+ * §7.4, §7.6): a persistent `pgoutput` slot on the `nylorun_stream_relay` publication, read
4
+ * with `pg-logical-replication`. The only file importing it (with `pg`, which it needs).
5
+ *
6
+ * - **One reader per slot.** Postgres lets one connection hold a slot, so every Runtime
7
+ * process can run a source and exactly one is active; the others retry every few seconds
8
+ * and take over when it goes away. The slot is the leader election.
9
+ * - **Acknowledged only on request.** The library acknowledges the last position it
10
+ * *received* on a timer; that is turned off (`timeoutSeconds: 0`) and keepalives are
11
+ * answered by the relay, which acknowledges only what S2 has.
12
+ * - **Always from the confirmed position.** Each attempt is a new connection that starts at
13
+ * `0/0`, which Postgres reads as the slot's confirmed position, so a reconnect replays
14
+ * whatever was received but not acknowledged.
15
+ * - **Fresh slots.** A missing slot is created; a slot Postgres invalidated
16
+ * (`max_slot_wal_keep_size`) is dropped and created again. Either way the relay must
17
+ * reconcile the record with S2; that is recorded in `relay_slots` until `reconciled()`, so a
18
+ * crash during reconciliation, or another process winning the slot, does not lose it.
19
+ */
20
+ import pg from "pg";
21
+ import { LogicalReplicationService, PgoutputPlugin } from "pg-logical-replication";
22
+ export const DEFAULT_RELAY_SLOT = "nylorun_stream_relay";
23
+ export function createPgoutputSource(options) {
24
+ const slot = options.slot ?? DEFAULT_RELAY_SLOT;
25
+ const publication = options.publication ?? "nylorun_stream_relay";
26
+ const schema = options.schema ?? "nylorun_streams";
27
+ const retryMs = options.retryMs ?? 5000;
28
+ const log = options.log ?? (() => { });
29
+ const pool = new pg.Pool({
30
+ connectionString: options.connectionString,
31
+ max: 2,
32
+ application_name: "nylorun-stream-relay",
33
+ });
34
+ let service;
35
+ let stopped = false;
36
+ let running;
37
+ let wake;
38
+ /** Creates the slot when missing or lost; true when the record must be reconciled. */
39
+ async function prepareSlot() {
40
+ const client = await pool.connect();
41
+ try {
42
+ const { rows } = await client.query("SELECT wal_status, active FROM pg_replication_slots WHERE slot_name = $1", [slot]);
43
+ const existing = rows[0];
44
+ if (existing?.wal_status === "lost" && !existing.active) {
45
+ log("replication slot was invalidated; creating it again", { slot });
46
+ await client.query("SELECT pg_drop_replication_slot($1)", [slot]);
47
+ }
48
+ if (!existing || existing.wal_status === "lost") {
49
+ try {
50
+ await client.query("SELECT pg_create_logical_replication_slot($1, 'pgoutput')", [slot]);
51
+ log("replication slot created", { slot });
52
+ await client.query(`INSERT INTO ${schema}.relay_slots (slot_name, reconcile_pending) VALUES ($1, true)
53
+ ON CONFLICT (slot_name) DO UPDATE SET reconcile_pending = true`, [slot]);
54
+ }
55
+ catch (error) {
56
+ // Another process created it first; its row says whether to reconcile.
57
+ if (error.code !== "42710")
58
+ throw error;
59
+ }
60
+ }
61
+ const pending = await client.query(`SELECT reconcile_pending FROM ${schema}.relay_slots WHERE slot_name = $1`, [slot]);
62
+ // A slot created before this table knew it: reconcile once to be sure.
63
+ return pending.rows[0]?.reconcile_pending ?? true;
64
+ }
65
+ finally {
66
+ client.release();
67
+ }
68
+ }
69
+ async function attempt(handlers) {
70
+ const fresh = await prepareSlot();
71
+ // `stop()` during `prepareSlot` had no service to stop: do not start one it cannot.
72
+ if (stopped)
73
+ return;
74
+ const current = new LogicalReplicationService({ connectionString: options.connectionString, application_name: "nylorun-stream-relay" }, { acknowledge: { auto: false, timeoutSeconds: 0 }, flowControl: { enabled: false } });
75
+ service = current;
76
+ let decoding;
77
+ current.on("start", () => handlers.onActive({ fresh }));
78
+ current.on("data", (_lsn, message) => {
79
+ switch (message.tag) {
80
+ case "begin":
81
+ decoding = { rows: [] };
82
+ break;
83
+ case "insert":
84
+ if (decoding &&
85
+ message.relation?.schema === schema &&
86
+ message.relation?.name === "session_events")
87
+ decoding.rows.push(rowOf(message.new));
88
+ break;
89
+ case "commit": {
90
+ const tx = { endLsn: message.commitEndLsn, rows: decoding?.rows ?? [] };
91
+ decoding = undefined;
92
+ // A transaction with no record rows still moves the slot once acknowledged.
93
+ handlers.onTx(tx);
94
+ break;
95
+ }
96
+ }
97
+ });
98
+ current.on("heartbeat", (lsn, _timestamp, shouldRespond) => {
99
+ // Mid-transaction, the server's position may be past rows not yet delivered.
100
+ if (!shouldRespond || decoding)
101
+ return;
102
+ const position = handlers.onKeepalive(lsn);
103
+ if (position)
104
+ void current.acknowledge(position).catch(() => { });
105
+ });
106
+ current.on("error", () => { });
107
+ const plugin = new PgoutputPlugin({ protoVersion: 1, publicationNames: [publication] });
108
+ // Resolves when replication ends, rejects when it fails; `0/0` = the slot's confirmed position.
109
+ await current.subscribe(plugin, slot, "0/00000000");
110
+ }
111
+ async function run(handlers) {
112
+ while (!stopped) {
113
+ let failure;
114
+ try {
115
+ await attempt(handlers);
116
+ }
117
+ catch (error) {
118
+ failure = error;
119
+ }
120
+ if (stopped)
121
+ break;
122
+ const busy = failure?.code === "55006";
123
+ // Another process holds the slot: it is the relay. Anything else: report and retry.
124
+ handlers.onInactive(busy ? undefined : failure ?? new Error("replication ended"));
125
+ await new Promise((resolve) => {
126
+ wake = resolve;
127
+ setTimeout(resolve, retryMs).unref?.();
128
+ });
129
+ wake = undefined;
130
+ }
131
+ }
132
+ return {
133
+ start(handlers) {
134
+ if (running)
135
+ throw new Error("The change source is already started");
136
+ running = run(handlers);
137
+ },
138
+ acknowledge(lsn) {
139
+ void service?.acknowledge(lsn).catch(() => { });
140
+ },
141
+ async reconciled() {
142
+ await pool.query(`UPDATE ${schema}.relay_slots SET reconcile_pending = false WHERE slot_name = $1`, [
143
+ slot,
144
+ ]);
145
+ },
146
+ async lag() {
147
+ const { rows } = await pool.query(`SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)::text AS lag
148
+ FROM pg_replication_slots WHERE slot_name = $1`, [slot]);
149
+ return rows[0]?.lag == null ? undefined : Number(rows[0].lag);
150
+ },
151
+ async stop() {
152
+ stopped = true;
153
+ wake?.();
154
+ await service?.stop().catch(() => undefined);
155
+ await running?.catch(() => undefined);
156
+ await pool.end().catch(() => undefined);
157
+ },
158
+ };
159
+ }
160
+ /** A decoded `session_events` row (`pg` parses `json`; `bigint` arrives as text). */
161
+ function rowOf(values) {
162
+ return {
163
+ tenantId: values.tenant_id,
164
+ sessionId: values.session_id,
165
+ seq: Number(values.seq),
166
+ generation: Number(values.generation),
167
+ body: typeof values.body === "string" ? JSON.parse(values.body) : values.body,
168
+ };
169
+ }
@@ -5,12 +5,12 @@
5
5
  *
6
6
  * ## Layout
7
7
  *
8
- * - One basin per Tenant, named by `tenantBasinName` (`streams/basin.ts`):
9
- * `<basinPrefix>tn-<ulid>` for a Tenant id `tn_<ulid>`.
10
- * - The basin is created with `createStreamOnAppend`, so `sessions/<id>/<incarnation>`,
11
- * `tenant/work` and `tenant/control` come into existence on their first
12
- * append. Its default stream config sets infinite retention, which session
13
- * streams keep; the work and control streams are created with a one-day
8
+ * - One basin per Tenant basin generation, named by `tenantBasinName` (`streams/basin.ts`):
9
+ * `<basinPrefix>tn-<ulid>` for a Tenant id `tn_<ulid>`, `…-<g base36>` for generation g.
10
+ * - The basin is created with `createStreamOnAppend` and `createStreamOnRead`, so
11
+ * `sessions/<id>` and `tenant/control` come into existence on first use. Its
12
+ * default stream config sets infinite retention, which session streams
13
+ * keep; the control stream is created with a one-day
14
14
  * age-based retention instead (signals are latency hints, not history).
15
15
  * - Bodies are JSON text in string records. Record timestamps are S2 arrival
16
16
  * times.
@@ -27,7 +27,7 @@
27
27
  * explicit `{ infinite: {} }` retention to keep session history; this adapter
28
28
  * sets it as the basin default, and s2-lite honours it (a new stream reports
29
29
  * `infinite`). A `streamConfig` sent with an append that creates the stream
30
- * is honoured too (used for the work and control streams). `deleteOnEmpty`
30
+ * is honoured too (used for the control stream). `deleteOnEmpty`
31
31
  * defaults to disabled (`minAgeSecs: 0`).
32
32
  * - **Stream deletion** is immediate for our purposes: right after `DELETE`,
33
33
  * `checkTail` and reads return 404 `stream_not_found` (new read sessions
@@ -58,9 +58,9 @@
58
58
  * use the SDK's HTTP/2 (s2s) transport, which s2-lite serves over cleartext.
59
59
  */
60
60
  import { AppendInput, AppendRecord, RangeNotSatisfiableError, S2, S2Error, SeqNumMismatchError, } from "@s2-dev/streamstore";
61
- import { tenantBasinName, validateBasinPrefix } from "../../streams/basin.js";
62
- import { CONTROL_STREAM, WORK_STREAM, } from "../../streams/types.js";
63
- /** Retention of the work and control signal streams. */
61
+ import { parseBasin, tenantBasinName, validateBasinPrefix } from "../../streams/basin.js";
62
+ import { CONTROL_STREAM, } from "../../streams/types.js";
63
+ /** Retention of the control signal stream. */
64
64
  const SIGNAL_RETENTION_SECS = 24 * 60 * 60;
65
65
  /** Records per unary read while reading history. */
66
66
  const HISTORY_PAGE = 1000;
@@ -99,7 +99,7 @@ class S2Streams {
99
99
  return AppendRecord.string({ body });
100
100
  }), {
101
101
  ...(options.matchSeq !== undefined ? { matchSeqNum: options.matchSeq } : {}),
102
- ...(stream === WORK_STREAM || stream === CONTROL_STREAM
102
+ ...(stream === CONTROL_STREAM
103
103
  ? { streamConfig: { retentionPolicy: { ageSecs: SIGNAL_RETENTION_SECS } } }
104
104
  : {}),
105
105
  });
@@ -155,6 +155,9 @@ class S2Streams {
155
155
  basin,
156
156
  config: {
157
157
  createStreamOnAppend: true,
158
+ // A reader that arrives before a session's first event creates the
159
+ // stream and follows it live, instead of polling until it exists.
160
+ createStreamOnRead: true,
158
161
  defaultStreamConfig: { retentionPolicy: { infinite: {} } },
159
162
  },
160
163
  }, { signal: this.closing.signal });
@@ -171,15 +174,24 @@ class S2Streams {
171
174
  }
172
175
  }
173
176
  }
174
- async deleteTenant(tenantId) {
177
+ async deleteTenant(tenantId, options = {}) {
175
178
  this.checkOpen();
176
- try {
177
- await this.s2.basins.delete({ basin: this.basinName(tenantId) }, { signal: this.closing.signal });
178
- }
179
- catch (error) {
180
- if (!isMissing(error))
181
- throw error;
179
+ const basins = [this.basinName(tenantId)];
180
+ // Every later generation's basin too: `<basin>-<g base36>`.
181
+ if (options.allGenerations && parseBasin(tenantId).generation === 0) {
182
+ const later = new RegExp(`^${escapeRegExp(basins[0])}-[0-9a-z]{1,2}$`);
183
+ for await (const info of this.s2.basins.listAll({ prefix: `${basins[0]}-` }, { signal: this.closing.signal }))
184
+ if (later.test(info.name) && info.deletedAt == null)
185
+ basins.push(info.name);
182
186
  }
187
+ for (const basin of basins)
188
+ try {
189
+ await this.s2.basins.delete({ basin }, { signal: this.closing.signal });
190
+ }
191
+ catch (error) {
192
+ if (!isMissing(error))
193
+ throw error;
194
+ }
183
195
  }
184
196
  async deleteStream(tenantId, stream) {
185
197
  this.checkOpen();
@@ -256,7 +268,9 @@ class S2Streams {
256
268
  /**
257
269
  * Follows `stream` from `fromSeq` with S2 read sessions, reopening a session
258
270
  * from the next sequence whenever one ends or fails, until aborted, closed,
259
- * or the Tenant's basin is gone. A missing stream is polled until it exists.
271
+ * or the Tenant's basin is gone. Basins create a stream on read, so a reader
272
+ * that arrives before the first append waits on the live session instead of
273
+ * polling.
260
274
  */
261
275
  async *readLive(tenantId, stream, fromSeq, signal) {
262
276
  const aborted = anySignal(signal, this.closing.signal);
@@ -288,7 +302,7 @@ class S2Streams {
288
302
  if ((isMissing(error) || isCode(error, "stream_deletion_pending")) &&
289
303
  (await this.basinGone(tenantId, aborted)))
290
304
  return;
291
- // Stream not created yet, or S2 unreachable: retry below.
305
+ // S2 unreachable, or a stream being deleted: retry below.
292
306
  }
293
307
  await sleep(delay, aborted);
294
308
  delay = Math.min(delay * 2, RETRY_MAX_MS);
@@ -343,6 +357,9 @@ function isTransactionConflict(error) {
343
357
  function isTenantGone(error) {
344
358
  return isCode(error, "basin_not_found") || isCode(error, "basin_deletion_pending");
345
359
  }
360
+ function escapeRegExp(text) {
361
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
362
+ }
346
363
  function anySignal(...signals) {
347
364
  return AbortSignal.any(signals.filter((s) => s !== undefined));
348
365
  }