@nylorun/runtime 0.13.0-beta → 0.15.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 (309) hide show
  1. package/CHANGELOG.md +434 -0
  2. package/README.md +137 -41
  3. package/dist/adapters/execution/restate.d.ts +7 -2
  4. package/dist/adapters/execution/restate.js +40 -10
  5. package/dist/adapters/replication/pgoutput.d.ts +21 -0
  6. package/dist/adapters/replication/pgoutput.js +170 -0
  7. package/dist/adapters/streams/s2.js +37 -20
  8. package/dist/admin-openapi.json +570 -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 +8065 -0
  32. package/dist/api/components.js +82 -0
  33. package/dist/api/gate/keys.d.ts +14 -0
  34. package/dist/api/gate/keys.js +59 -0
  35. package/dist/api/gate/routes.d.ts +34 -0
  36. package/dist/api/gate/routes.js +235 -0
  37. package/dist/api/http/app.d.ts +18 -0
  38. package/dist/api/http/app.js +58 -0
  39. package/dist/api/http/body.d.ts +4 -0
  40. package/dist/api/http/body.js +37 -0
  41. package/dist/api/http/define.d.ts +39 -0
  42. package/dist/api/http/define.js +122 -0
  43. package/dist/api/http/respond.d.ts +9 -0
  44. package/dist/api/http/respond.js +44 -0
  45. package/dist/api/http/routes/access.d.ts +24 -0
  46. package/dist/api/http/routes/access.js +266 -0
  47. package/dist/api/http/routes/actions.d.ts +8 -0
  48. package/dist/api/http/routes/actions.js +76 -0
  49. package/dist/api/http/routes/endpoints.d.ts +7 -0
  50. package/dist/api/http/routes/endpoints.js +100 -0
  51. package/dist/api/http/routes/sessions.d.ts +3 -0
  52. package/dist/api/http/routes/sessions.js +205 -0
  53. package/dist/api/http/routes/tenant.d.ts +11 -0
  54. package/dist/api/http/routes/tenant.js +244 -0
  55. package/dist/api/http/routes/vaults.d.ts +8 -0
  56. package/dist/api/http/routes/vaults.js +167 -0
  57. package/dist/api/openapi.d.ts +19 -0
  58. package/dist/api/openapi.js +137 -0
  59. package/dist/api/route.d.ts +12 -0
  60. package/dist/api/route.js +4 -0
  61. package/dist/core/bearer.d.ts +8 -0
  62. package/dist/core/bearer.js +11 -0
  63. package/dist/core/flow-host.d.ts +7 -15
  64. package/dist/core/flow-host.js +16 -41
  65. package/dist/core/sandbox-routes.d.ts +12 -7
  66. package/dist/core/sandbox-routes.js +24 -25
  67. package/dist/execution/memory.d.ts +8 -5
  68. package/dist/execution/memory.js +33 -13
  69. package/dist/execution/types.d.ts +38 -6
  70. package/dist/execution/types.js +12 -3
  71. package/dist/gates/contract.d.ts +51 -0
  72. package/dist/gates/contract.js +37 -0
  73. package/dist/gates/handler.d.ts +21 -0
  74. package/dist/gates/handler.js +55 -0
  75. package/dist/gates/http-client.d.ts +12 -0
  76. package/dist/gates/http-client.js +160 -0
  77. package/dist/gates/in-process.d.ts +32 -0
  78. package/dist/gates/in-process.js +42 -0
  79. package/dist/gates/inflight.d.ts +39 -0
  80. package/dist/gates/inflight.js +70 -0
  81. package/dist/gates/mcp-handler.d.ts +35 -0
  82. package/dist/gates/mcp-handler.js +142 -0
  83. package/dist/gates/meter.d.ts +24 -0
  84. package/dist/gates/meter.js +165 -0
  85. package/dist/gates/model-gate.d.ts +45 -0
  86. package/dist/gates/model-gate.js +1 -0
  87. package/dist/gates/tenant-vaults.d.ts +64 -0
  88. package/dist/gates/tenant-vaults.js +94 -0
  89. package/dist/gates/tool-calls.d.ts +51 -0
  90. package/dist/gates/tool-calls.js +64 -0
  91. package/dist/gates/tool-client.d.ts +12 -0
  92. package/dist/gates/tool-client.js +201 -0
  93. package/dist/gates/tool-contract.d.ts +116 -0
  94. package/dist/gates/tool-contract.js +64 -0
  95. package/dist/gates/tool-gate.d.ts +45 -0
  96. package/dist/gates/tool-gate.js +10 -0
  97. package/dist/host/admin-api.d.ts +23 -0
  98. package/dist/host/admin-api.js +74 -0
  99. package/dist/host/app.d.ts +34 -0
  100. package/dist/host/app.js +181 -0
  101. package/dist/host/config-for.d.ts +3 -1
  102. package/dist/host/config-for.js +3 -2
  103. package/dist/host/config.d.ts +11 -0
  104. package/dist/host/cors.d.ts +27 -0
  105. package/dist/host/cors.js +64 -0
  106. package/dist/host/create-host.d.ts +36 -8
  107. package/dist/host/create-host.js +161 -330
  108. package/dist/host/execution.d.ts +20 -18
  109. package/dist/host/execution.js +2 -2
  110. package/dist/host/gates.d.ts +58 -0
  111. package/dist/host/gates.js +127 -0
  112. package/dist/host/http.d.ts +20 -4
  113. package/dist/host/http.js +81 -23
  114. package/dist/host/index.d.ts +1 -1
  115. package/dist/host/main.js +189 -58
  116. package/dist/host/stack-config.d.ts +91 -19
  117. package/dist/host/stack-config.js +246 -17
  118. package/dist/infra/database.d.ts +2 -2
  119. package/dist/infra/execution.d.ts +5 -5
  120. package/dist/infra/execution.js +8 -6
  121. package/dist/infra/index.d.ts +1 -1
  122. package/dist/infra/workers.d.ts +17 -17
  123. package/dist/infra/workers.js +9 -9
  124. package/dist/keys/client.d.ts +9 -0
  125. package/dist/keys/client.js +70 -0
  126. package/dist/keys/contract.d.ts +24 -0
  127. package/dist/keys/contract.js +12 -0
  128. package/dist/keys/keys.d.ts +41 -0
  129. package/dist/keys/keys.js +50 -0
  130. package/dist/mcp/connect.d.ts +43 -3
  131. package/dist/mcp/connect.js +12 -5
  132. package/dist/mcp/pool.d.ts +39 -3
  133. package/dist/mcp/pool.js +74 -11
  134. package/dist/model/catalog.d.ts +2 -10
  135. package/dist/model/classify.d.ts +13 -0
  136. package/dist/model/classify.js +61 -0
  137. package/dist/model/models.d.ts +10 -1
  138. package/dist/model/models.js +14 -6
  139. package/dist/model/pi-model.d.ts +17 -2
  140. package/dist/model/pi-model.js +366 -222
  141. package/dist/openapi.json +25889 -0
  142. package/dist/record/append.d.ts +51 -0
  143. package/dist/record/append.js +20 -0
  144. package/dist/record/envelope.d.ts +19 -0
  145. package/dist/record/envelope.js +49 -0
  146. package/dist/record/index.d.ts +11 -0
  147. package/dist/record/index.js +11 -0
  148. package/dist/sandbox/manager.d.ts +13 -1
  149. package/dist/sandbox/manager.js +8 -11
  150. package/dist/sandbox/resolve.d.ts +33 -0
  151. package/dist/sandbox/resolve.js +81 -0
  152. package/dist/sandbox/session-sandbox.d.ts +11 -0
  153. package/dist/sandbox/session-sandbox.js +51 -0
  154. package/dist/sandbox/share.d.ts +14 -2
  155. package/dist/sandbox/share.js +33 -3
  156. package/dist/sandbox/tenant-config.d.ts +37 -0
  157. package/dist/sandbox/tenant-config.js +51 -0
  158. package/dist/sandbox/tools.d.ts +7 -9
  159. package/dist/sandbox/tools.js +11 -3
  160. package/dist/sandbox/types.d.ts +8 -2
  161. package/dist/store/postgres/connect.d.ts +25 -9
  162. package/dist/store/postgres/connect.js +27 -10
  163. package/dist/store/postgres/db.d.ts +16 -0
  164. package/dist/store/postgres/db.js +28 -0
  165. package/dist/store/postgres/drizzle/0000_foundation.sql +22 -0
  166. package/dist/store/postgres/drizzle/0001_baseline.sql +255 -0
  167. package/dist/store/postgres/drizzle/0002_stream_relay.sql +4 -0
  168. package/dist/store/postgres/drizzle/0003_tool_crossings.sql +9 -0
  169. package/dist/store/postgres/drizzle/meta/_journal.json +34 -0
  170. package/dist/store/postgres/migrate.d.ts +68 -0
  171. package/dist/store/postgres/migrate.js +183 -0
  172. package/dist/store/postgres/record-writer.d.ts +4 -0
  173. package/dist/store/postgres/record-writer.js +34 -0
  174. package/dist/store/postgres/record.d.ts +5 -0
  175. package/dist/store/postgres/record.js +60 -0
  176. package/dist/store/postgres/schema.d.ts +3096 -0
  177. package/dist/store/postgres/schema.js +363 -0
  178. package/dist/store/postgres/store.d.ts +2 -4
  179. package/dist/store/postgres/store.js +590 -434
  180. package/dist/store/postgres/tenant.d.ts +56 -0
  181. package/dist/store/postgres/tenant.js +154 -0
  182. package/dist/store/types.d.ts +183 -132
  183. package/dist/store/types.js +0 -1
  184. package/dist/streams/basin.d.ts +18 -1
  185. package/dist/streams/basin.js +34 -2
  186. package/dist/streams/control.d.ts +18 -0
  187. package/dist/streams/control.js +33 -0
  188. package/dist/streams/memory.d.ts +3 -1
  189. package/dist/streams/memory.js +14 -8
  190. package/dist/streams/relay/core.d.ts +35 -0
  191. package/dist/streams/relay/core.js +366 -0
  192. package/dist/streams/relay/types.d.ts +78 -0
  193. package/dist/streams/relay/types.js +8 -0
  194. package/dist/streams/types.d.ts +71 -36
  195. package/dist/streams/types.js +18 -53
  196. package/dist/tenant/access-policy.d.ts +30 -0
  197. package/dist/tenant/access-policy.js +52 -0
  198. package/dist/tenant/actions.d.ts +10 -149
  199. package/dist/tenant/actions.js +5 -167
  200. package/dist/tenant/advance.d.ts +7 -2
  201. package/dist/tenant/advance.js +125 -14
  202. package/dist/tenant/auth.d.ts +19 -17
  203. package/dist/tenant/auth.js +90 -93
  204. package/dist/tenant/browser.d.ts +15 -0
  205. package/dist/tenant/browser.js +39 -0
  206. package/dist/tenant/cause.d.ts +25 -0
  207. package/dist/tenant/cause.js +45 -0
  208. package/dist/tenant/commands.d.ts +18 -2
  209. package/dist/tenant/commands.js +111 -115
  210. package/dist/tenant/context.d.ts +101 -21
  211. package/dist/tenant/context.js +14 -10
  212. package/dist/tenant/delivery-token.d.ts +41 -0
  213. package/dist/tenant/delivery-token.js +104 -0
  214. package/dist/tenant/delivery.d.ts +62 -0
  215. package/dist/tenant/delivery.js +481 -0
  216. package/dist/tenant/effects.d.ts +20 -1
  217. package/dist/tenant/effects.js +159 -42
  218. package/dist/tenant/endpoints.d.ts +67 -0
  219. package/dist/tenant/endpoints.js +90 -0
  220. package/dist/tenant/ephemeral.d.ts +22 -7
  221. package/dist/tenant/ephemeral.js +83 -125
  222. package/dist/tenant/history.d.ts +64 -0
  223. package/dist/tenant/history.js +120 -0
  224. package/dist/tenant/http.d.ts +8 -9
  225. package/dist/tenant/http.js +13 -27
  226. package/dist/tenant/jwt.d.ts +16 -0
  227. package/dist/tenant/jwt.js +50 -0
  228. package/dist/tenant/model-setting.d.ts +2 -2
  229. package/dist/tenant/module.d.ts +17 -28
  230. package/dist/tenant/module.js +133 -279
  231. package/dist/tenant/outbound.d.ts +44 -0
  232. package/dist/tenant/outbound.js +127 -0
  233. package/dist/tenant/paths.d.ts +11 -16
  234. package/dist/tenant/paths.js +24 -68
  235. package/dist/tenant/pool.d.ts +1 -3
  236. package/dist/tenant/pool.js +1 -19
  237. package/dist/tenant/principals.d.ts +23 -11
  238. package/dist/tenant/principals.js +61 -49
  239. package/dist/tenant/reset.d.ts +0 -4
  240. package/dist/tenant/reset.js +0 -5
  241. package/dist/tenant/runtime.d.ts +28 -20
  242. package/dist/tenant/runtime.js +95 -64
  243. package/dist/tenant/scheduler.d.ts +10 -3
  244. package/dist/tenant/scheduler.js +9 -5
  245. package/dist/tenant/session-streams.d.ts +127 -0
  246. package/dist/tenant/session-streams.js +404 -0
  247. package/dist/tenant/sessions.d.ts +28 -4
  248. package/dist/tenant/sessions.js +118 -24
  249. package/dist/tenant/signing-keys.d.ts +55 -0
  250. package/dist/tenant/signing-keys.js +172 -0
  251. package/dist/tenant/slim.d.ts +8 -0
  252. package/dist/tenant/slim.js +21 -0
  253. package/dist/tenant/status.d.ts +3 -3
  254. package/dist/tenant/status.js +20 -13
  255. package/dist/tenant/store-pg.d.ts +9 -6
  256. package/dist/tenant/store-pg.js +60 -125
  257. package/dist/tenant/streams.d.ts +62 -85
  258. package/dist/tenant/streams.js +143 -226
  259. package/dist/tenant/subject-limits.d.ts +16 -0
  260. package/dist/tenant/subject-limits.js +45 -0
  261. package/dist/tenant/sweep.d.ts +1 -10
  262. package/dist/tenant/sweep.js +29 -69
  263. package/dist/tenant/tokens.d.ts +14 -0
  264. package/dist/tenant/tokens.js +180 -0
  265. package/dist/tenant/transcript.d.ts +48 -5
  266. package/dist/tenant/transcript.js +58 -1
  267. package/dist/tenant/types.d.ts +69 -98
  268. package/dist/tenant/types.js +4 -15
  269. package/dist/tenant/worker.d.ts +3 -1
  270. package/dist/tenant/worker.js +13 -7
  271. package/dist/vault/host-model.d.ts +31 -0
  272. package/dist/vault/host-model.js +57 -0
  273. package/dist/vault/service.d.ts +44 -17
  274. package/dist/vault/service.js +126 -121
  275. package/dist/version.d.ts +1 -1
  276. package/dist/version.js +1 -1
  277. package/package.json +25 -9
  278. package/dist/core/executors.d.ts +0 -44
  279. package/dist/core/executors.js +0 -95
  280. package/dist/store/memory.d.ts +0 -57
  281. package/dist/store/memory.js +0 -645
  282. package/dist/store/postgres/migrations/001_initial.d.ts +0 -27
  283. package/dist/store/postgres/migrations/001_initial.js +0 -196
  284. package/dist/store/postgres/migrations/002_session_owner.d.ts +0 -6
  285. package/dist/store/postgres/migrations/002_session_owner.js +0 -13
  286. package/dist/store/postgres/migrations/index.d.ts +0 -49
  287. package/dist/store/postgres/migrations/index.js +0 -76
  288. package/dist/store/postgres/names.d.ts +0 -11
  289. package/dist/store/postgres/names.js +0 -42
  290. package/dist/store/postgres/tenants.d.ts +0 -90
  291. package/dist/store/postgres/tenants.js +0 -214
  292. package/dist/streams/relay.d.ts +0 -37
  293. package/dist/streams/relay.js +0 -254
  294. package/dist/tenant/envelope.d.ts +0 -7
  295. package/dist/tenant/envelope.js +0 -22
  296. package/dist/tenant/live.d.ts +0 -92
  297. package/dist/tenant/live.js +0 -290
  298. package/dist/tenant/quarantine-error.d.ts +0 -6
  299. package/dist/tenant/quarantine-error.js +0 -9
  300. package/dist/tenant/quarantine.d.ts +0 -27
  301. package/dist/tenant/quarantine.js +0 -72
  302. package/dist/tenant/routes-tenant.d.ts +0 -12
  303. package/dist/tenant/routes-tenant.js +0 -137
  304. package/dist/tenant/routes.d.ts +0 -9
  305. package/dist/tenant/routes.js +0 -151
  306. package/dist/tenant/store-memory.d.ts +0 -16
  307. package/dist/tenant/store-memory.js +0 -111
  308. /package/dist/{store → record}/cursor.d.ts +0 -0
  309. /package/dist/{store → record}/cursor.js +0 -0
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  The independent OSS **Runtime Host** consumes `@nylorun/harness/run` and
4
4
  `@nylorun/core/contracts`. Cloud installs published `@nylorun/harness` from npm
5
5
  and does not import this package. Client authoring and sessions belong to
6
- `@nylorun/agents`; Tenant lifecycle management belongs to `@nylorun/admin`.
6
+ `@nylorun/agents`; the Host's status and derived keys belong to `@nylorun/admin`.
7
7
  Vocabulary: [src/CONTEXT.md](./src/CONTEXT.md).
8
8
 
9
9
  Requires Node 24+. Build from the repository root:
@@ -24,33 +24,66 @@ package is a library with no bin; its Host entry is `@nylorun/runtime/server`
24
24
  ephemeral embeds, use `startEphemeralRuntime()` from `@nylorun/runtime/core`.
25
25
  See [MIGRATION.md](../MIGRATION.md).
26
26
 
27
- No Tenant state lives only in the Runtime process. Each Tenant's data is the
28
- Postgres schema `tenant_<id>` (the Session Store); Restate runs one advance of a session at a
29
- time and holds the Tenant's sweep timer (Durable Session Execution); every
30
- session's events are relayed from the schema's outbox to its own S2 stream,
31
- which history and SSE read (Durable Streams). An API node serves the Tenant API,
32
- Admin API and SSE, and a Worker runs advances; `--role api|worker|all` picks
33
- them, and the local stack runs one process with `all`.
27
+ A Runtime serves one **Tenant**: an installation is one Runtime with its own
28
+ Postgres database, Restate and S2 basin, and two Tenants are two installations. No
29
+ Tenant state lives only in the Runtime process. The Tenant's data is its Postgres
30
+ database, in the fixed schemas `nylorun` (its state: the Session Store) and
31
+ `nylorun_streams` (the record of its session events). Drizzle defines the tables
32
+ (`src/store/postgres/schema.ts`) and generates the migrations, which ship in the package
33
+ (`dist/store/postgres/drizzle/`). At startup the Host applies the missing ones in one
34
+ transaction under an advisory lock, records them in `nylorun.__drizzle_migrations`, and
35
+ refuses a database holding a migration it does not ship (`schema-too-new`); then, on
36
+ first start, it creates the Tenant there: its id (`NYLORUN_TENANT_ID`, default a
37
+ new one), its name (`NYLORUN_TENANT_NAME`, default `default`), its Studio principal and
38
+ the derived principals (`NYLORUN_DERIVED_PRINCIPALS`, default `project`) whose keys the
39
+ admin key derives. A database written by a Runtime that kept several Tenants in one
40
+ database (`tenant_<id>` schemas), or by a pre-release build of one Tenant per database
41
+ (`schema_version` tables), is refused: this release starts fresh on a new database. Restate runs one advance of a session at a time and holds the Tenant's sweep
42
+ timer (Durable Session Execution); every session's events are relayed from the record to
43
+ its own S2 stream, which history and SSE read (Durable Streams). One image runs every **service**,
44
+ and `--service` picks what a process runs: `core` serves the Tenant API, Admin
45
+ API and SSE and runs the stream relay, `loop` runs advances (the Worker), and
46
+ `gates` is the Model Gate, the only process that reads a model credential and
47
+ calls providers. `core` and `loop` may share a process; `gates` never joins
48
+ them. The local stack packs `core,loop` into the `runtime` container and
49
+ `gates` into the `gateway` container (the combined packing). `--role
50
+ api|worker|all` is the deprecated name of `--service core`, `loop` and
51
+ `core,loop`.
34
52
 
35
53
  The container is configured by its environment, which the stack's Compose file
36
54
  sets: `NYLORUN_DATABASE_URL` (required), `NYLORUN_RESTATE_INGRESS_URL`,
37
55
  `NYLORUN_RESTATE_ADMIN_URL`, `NYLORUN_WORKER_URL` and
38
56
  `NYLORUN_RESTATE_IDENTITY_KEY` (Restate), `NYLORUN_S2_ENDPOINT` and
39
57
  `NYLORUN_S2_TOKEN`, and in container mode `NYLORUN_LISTEN_HOST`,
40
- `NYLORUN_LISTEN_PORT`, `NYLORUN_ALLOWED_HOSTS` and `NYLORUN_PUBLIC_URL`.
58
+ `NYLORUN_LISTEN_PORT`, `NYLORUN_ALLOWED_HOSTS` and `NYLORUN_PUBLIC_URL`, plus
59
+ `NYLORUN_BROWSER_ACCESS` (`on` or `off`) and the operator listener
60
+ (`NYLORUN_ADMIN_LISTEN_PORT`, `NYLORUN_ADMIN_LISTEN_HOST`,
61
+ `NYLORUN_ADMIN_ALLOWED_HOSTS`). A process that runs `loop` sends its model
62
+ calls to the gate at `NYLORUN_GATES_URL` with `NYLORUN_GATES_TOKEN`; in a
63
+ container it refuses to start without them. A `gates` process needs only
64
+ `NYLORUN_DATABASE_URL`, `NYLORUN_GATES_TOKEN`, its listener
65
+ (`NYLORUN_GATES_LISTEN_HOST`, `NYLORUN_GATES_LISTEN_PORT`, default 4100, and
66
+ `NYLORUN_GATES_ALLOWED_HOSTS`) and the Host's `tenant/` directory, which it
67
+ never writes; it serves the database's one Tenant. `NYLORUN_PACKING` (`combined` or `split`) is logged at startup.
68
+
69
+ With an operator listener (the stack's default: container port 4001), the Host
70
+ serves two ports. The public one serves the Tenant API, to browsers too when
71
+ browser access is on, and answers admin routes with the opaque `404`. The
72
+ operator one serves the Admin API, Host shutdown and the Tenant API, never to
73
+ browsers; keep it on loopback or a private network and never proxy it. Outside
74
+ a container, `adminPort` in `host.json` does the same on loopback.
41
75
 
42
76
  The Host root is `NYLORUN_HOME` or `~/.nylorun` (bind-mounted at `/nylorun` in
43
- the stack). What stays on the Host is under `tenants/<tenantId>/`.
77
+ the stack). What stays on the Host is under `tenant/`.
44
78
 
45
79
  ## Layout
46
80
 
47
81
  ```text
48
82
  <host root>/
49
- host.json # format 1: hostId, host, port, runtimeVersion, …
83
+ host.json # format 1: hostId, host, port, adminPort?, runtimeVersion, …
50
84
  host-credentials.json # adminKey (0600)
51
- stack/ # compose.yaml, .env (0600), Restate identity key
52
- tenants/<tenantId>/ # vault-kek, plugin-data, logs, home, tmp, sandboxes
53
- trash/ # SQLite Tenants from before the Postgres switch
85
+ docker/ # compose.yaml, .env (0600), Restate identity key
86
+ tenant/ # vault-kek, plugin-data, logs, home, tmp, sandboxes
54
87
  ```
55
88
 
56
89
  ## HTTP surface
@@ -58,29 +91,90 @@ the stack). What stays on the Host is under `tenants/<tenantId>/`.
58
91
  | Route | Auth | Notes |
59
92
  | --- | --- | --- |
60
93
  | `GET /health` | none | `service: "nylorun-runtime"`, `hostId`, protocol `{min,max,features}`, pid |
61
- | `GET /ready` | none | Listener up, Tenants discovered, and Postgres, Restate and S2 answer (`checks`) |
62
- | `GET /v1/admin/status` | admin key | `AdminStatusSchema`; alias `GET /v1/admin/host` |
63
- | `/v1/admin/tenants*` | admin key | Create / list / get / delete Tenants |
94
+ | `GET /ready` | none | Listener up, the Tenant open, and Postgres, Restate and S2 answer (`checks`) |
95
+ | `GET /openapi.json` | none | The Tenant API's OpenAPI 3.2 document (below); refuses an `Origin` |
96
+ | `GET /v1/admin/status` | admin key | `AdminStatusSchema`: the Host, its protocol and its Tenant (`tenant`: id, name, `open` or `unavailable`, and the cause when it could not be opened); alias `GET /v1/admin/host`. On the operator listener when there is one |
64
97
  | `POST /v1/admin/host/shutdown` | admin key | Host-private; not in `@nylorun/admin` |
65
- | `/v1/*` Tenant routes | application or executor | Require `Nylorun-Tenant` + `Nylorun-Protocol` |
98
+ | `GET /v1/admin/openapi.json` | admin key | The Admin API's OpenAPI 3.2 document |
99
+ | `/v1/*` Tenant routes | application key, subject token or delivery token | Require `Nylorun-Protocol`; nothing names the Tenant |
100
+
101
+ Every route checks `Host` first (`421 host_rejected`) and rejects non-JSON bodies
102
+ with `415 unsupported_media_type`. An `Origin` is `403 origin_rejected` on
103
+ `/health`, `/ready`, admin routes, and everywhere when browser access is off.
104
+ With browser access on (feature `browser-access`: the stack's default, or
105
+ `browserAccess` in `host.json`), the Host answers preflights for browser routes
106
+ from the route alone, and the Tenant admits an `Origin` only with a publishable
107
+ key (`Nylorun-Key`) that lists it, adding CORS headers only then; Tenant keys
108
+ and delivery tokens are refused from browsers before they are looked up. Missing or
109
+ unsupported protocol → `426` before authentication (the Host serves protocols 4 and 5).
110
+ Protocol 5 clients name no Tenant. A protocol 4 client's `Nylorun-Tenant` naming
111
+ another Tenant (or malformed), a publishable key of another Tenant, a Tenant that
112
+ could not be opened and rejected credentials → opaque `404` with identical body. A path or method no route serves is `404 Route not found`
113
+ once the caller is known.
114
+
115
+ ## API reference (OpenAPI)
116
+
117
+ The Runtime describes its APIs as OpenAPI 3.2 documents, generated from the routes it serves,
118
+ so they cannot drift from its answers:
119
+
120
+ | Document | In the package | Served | On each release |
121
+ | --- | --- | --- | --- |
122
+ | Tenant API | `@nylorun/runtime/openapi.json` | `GET /openapi.json` | `openapi.json` |
123
+ | Admin API | `@nylorun/runtime/admin-openapi.json` | `GET /v1/admin/openapi.json` (admin key) | `admin-openapi.json` |
124
+
125
+ The release assets are on the `@nylorun/runtime@<version>` GitHub Release. Point any OpenAPI 3.2
126
+ tool at one of them; for [Scalar](https://scalar.com), the package file of a version works as is:
66
127
 
67
- Every route checks `Host` first (`421 host_rejected`), rejects any `Origin`
68
- (`403 origin_rejected`, no CORS headers), and rejects non-JSON bodies with
69
- `415 unsupported_media_type`. Missing or unsupported protocol → `426` before
70
- authentication. Unknown, quarantined or rejected Tenant credentials → opaque
71
- `404` with identical body.
128
+ ```text
129
+ https://cdn.jsdelivr.net/npm/@nylorun/runtime@<version>/dist/openapi.json
130
+ ```
131
+
132
+ Each operation's `security` says which credentials it takes (application key, subject token,
133
+ publishable key, delivery token, admin key), and its `x-nylorun-credentials`,
134
+ `x-nylorun-scopes` (the subject scopes that reach it) and `x-nylorun-browser` fields say who
135
+ may call it. Event streams are `text/event-stream` with an `itemSchema`. `runtime/openapi/` holds the committed snapshots: a change to a route changes
136
+ them (`node scripts/build-openapi.mjs --write`), and `check-package` fails until they are
137
+ updated.
138
+
139
+ ## HTTP layer
140
+
141
+ Routes are Hono routes (`@hono/zod-openapi`), each declared once with who may call it
142
+ (`api/http/define.ts`): that declaration serves the route, checks subject scopes, answers
143
+ browser preflights and makes the OpenAPI document. `host/` holds the listeners, the `Host`
144
+ check (in Node, before Hono) and the Host pipeline (`host/app.ts`); `api/` the Tenant and
145
+ Admin routes (`api/http/routes/`, `api/ag-ui/`, `api/a2a/`, `host/admin-api.ts`). Only `host/`
146
+ and `api/` import Hono.
72
147
 
73
148
  ## Embedding and tests
74
149
 
75
150
  Tests and ephemeral embeds use `startEphemeralRuntime()` from
76
151
  `@nylorun/runtime/core`: private Host on port 0, temporary Host root, one Tenant,
77
- returns `{ url, tenantId, applicationKey, adminKey, close() }`. It is not durable:
78
- its Tenants live in memory and are gone after `close()`. The smoke checks'
79
- temporary Tenants (`scripts/lib/temporary-tenant.mjs`) do not use it; they are
80
- created on the Docker stack.
152
+ returns `{ url, tenantId, applicationKey, adminKey, close() }`. Its Tenant is the
153
+ one Tenant of the Postgres database you pass as `database` (required): a pool, which
154
+ you end, or a URL, for which it opens a pool and ends it on `close()`. It creates the
155
+ Tenant there on first start, as a Host does (with `tenantId`, `name` and an application
156
+ principal for `applicationKey`), and serves the Tenant a database already holds. The
157
+ data stays after `close()`; give each test Tenant its own database and drop it
158
+ afterwards. Its
159
+ streams and scheduling are in process and gone after `close()`. The smoke checks do
160
+ not use it; they reset and seed the Tenant of a temporary Docker stack
161
+ (`scripts/lib/stack-tenant.mjs`).
162
+
163
+ ```ts
164
+ const runtime = await startEphemeralRuntime({
165
+ hostRoot, // a temporary directory
166
+ database: "postgres://nylorun:nylorun@127.0.0.1:55432/my_test_db",
167
+ });
168
+ ```
169
+
170
+ The package's own tests run every Session Store on Postgres: `npm test` gives each
171
+ test file a database on the Docker test stack (`test/stack/compose.yaml`) and starts
172
+ the stack when it is down; see [CONTRIBUTING.md](../CONTRIBUTING.md).
81
173
 
82
- Register executors with `PUT /v1/executors` using the application principal
83
- (application-mode `connectAgents` does this with derived tokens). Model gateway
174
+ Register Action endpoints with `PUT /v1/endpoints` using the application principal
175
+ (`createActionHandler(...).register({ url })` in `@nylorun/agents` does this). The
176
+ Runtime delivers each Action there with an `http` or `https` POST, so the URL must be
177
+ reachable from the Runtime. Model gateway
84
178
  and sandbox backend are Tenant configuration (vault / seed), not Host process
85
179
  env.
86
180
 
@@ -88,12 +182,12 @@ env.
88
182
 
89
183
  The session-first `/v1` API is specified in
90
184
  [HOST_CONTRACT.md](../harness/HOST_CONTRACT.md). Application tokens authorize
91
- definition/session APIs; executor credentials authorize authenticated SSE
92
- connect, action discovery, claims, renewal and results. Session observers cannot
93
- claim actions.
185
+ definition/session APIs. The Runtime delivers Actions to Action endpoints, and a
186
+ delivery token authorizes only its own Action's heartbeat, result and sandbox calls.
187
+ Session observers cannot submit action results.
94
188
 
95
189
  Postgres transactions persist session checkpoints, command receipts, individual
96
- effects/actions, waits and the event outbox — **per Tenant schema**; history is
190
+ effects/actions, waits and the record of session events — in the Tenant's database; history is
97
191
  read from Durable Streams (S2). An advance owns its session through a lease with
98
192
  an epoch; a Worker that takes over after a crash marks in-flight effects
99
193
  `uncertain`. Vault ciphertext needs that Tenant's own KEK.
@@ -103,22 +197,24 @@ Tenant owns each sandbox; backend names are prefixed `nylorun-<tenant-id>-`.
103
197
 
104
198
  ## Local Project workflow
105
199
 
106
- A project depends on `@nylorun/agents` only. `npx nylorun up` runs the local
107
- stack (Docker); `npx @nylorun/cli tenant create` (`nylo`) creates the
108
- project's Tenant and writes its Project link; the project's `npm run dev` runs
109
- `src/main.ts` under `tsx watch`; `npx nylorun studio` opens Studio.
200
+ A project depends on `@nylorun/agents` only. `npx nylorun start` in the project
201
+ runs its local stack (Docker), whose Runtime creates the stack's one Tenant, and
202
+ writes the Project link; the project's `npm run dev` runs `src/main.ts` under
203
+ `tsx watch`; `npx nylorun studio` opens Studio on that Tenant.
110
204
 
111
205
  ## Troubleshooting
112
206
 
113
207
  | Symptom | What to do |
114
208
  | --- | --- |
115
209
  | `kek-missing` | Restore `vault-kek` in the Tenant directory |
116
- | `corrupt` / `migration-failed` / `envelope-invalid` / `open-failed` / `open-timeout` | Follow the `nylo tenant status` repair string |
117
- | `schema-too-new` | Run a Runtime at least as new as the one that migrated the schema |
210
+ | `GET /ready` is 503 and `/v1/admin/status` names a `tenant.cause` | The Tenant could not be opened; every Tenant request is the opaque `404`. Follow the cause's `repair`, then restart the Runtime |
211
+ | `corrupt` / `migration-failed` / `envelope-invalid` / `open-failed` / `open-timeout` | Follow the cause's `repair` string |
212
+ | `schema-too-new` | Run a Runtime at least as new as the one that migrated the database |
213
+ | `database-layout-old` | The database holds `tenant_<id>` schemas of an older Runtime, or the `schema_version` tables of a pre-release one: point the Runtime at a new database (a new stack); the old one is left as it is |
118
214
  | `426 protocol_unsupported` | Upgrade clients or Host to a compatible set |
119
- | `421 host_rejected` / `403 origin_rejected` | Call from a server process, not a browser; in a container, list the `Host` in `NYLORUN_ALLOWED_HOSTS` |
215
+ | `421 host_rejected` / `403 origin_rejected` | In a container, list the `Host` in `NYLORUN_ALLOWED_HOSTS`. From a browser, use a subject token and a publishable key that lists the page's origin, never a Tenant key |
120
216
  | `503` for a Tenant | Postgres or Restate is unreachable; `GET /ready` names which |
121
- | Port in use | Change `NYLORUN_PORT` in `<Host root>/stack/.env` and run `nylorun up` |
217
+ | Port in use | Change `NYLORUN_PORT` in `~/.nylorun/stacks/<name>/docker/.env` and run `nylorun start` |
122
218
  | Logs | `nylorun logs runtime` |
123
219
 
124
220
  Definitions have no `agent.run()`; applications use `@nylorun/agents`.
@@ -90,8 +90,10 @@ export declare class RestateExecution implements DurableExecution {
90
90
  session: string;
91
91
  tenant: string;
92
92
  timer: string;
93
+ action: string;
93
94
  };
94
95
  wake(tenantId: string, sessionId: string, wake: Wake): Promise<void>;
96
+ deliver(tenantId: string, actionId: string): Promise<void>;
95
97
  timer(tenantId: string, key: string, at: Date): Promise<void>;
96
98
  armSweep(tenantId: string): Promise<void>;
97
99
  disarmSweep(tenantId: string): Promise<void>;
@@ -106,8 +108,11 @@ export declare class RestateExecution implements DurableExecution {
106
108
  start(handlers: WorkerHandlers): Promise<void>;
107
109
  stop(): Promise<void>;
108
110
  private definitions;
109
- /** Runs `advance` with a signal aborted by `stop` or by the end of the Restate attempt. */
110
- private runAdvance;
111
+ /**
112
+ * Runs a Worker handler with a signal aborted by `stop` or by the end of the Restate
113
+ * attempt (`advance` and `deliver`).
114
+ */
115
+ private runAborted;
111
116
  /** Runs a Worker handler so that `stop` waits for it. Refuses once stopping; Restate retries. */
112
117
  private runTracked;
113
118
  private staleSweepMs;
@@ -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
@@ -12,14 +12,19 @@
12
12
  * - `<prefix>NylorunTenant`, key `<tenantId>`. `arm`, `sweep` and `disarm`
13
13
  * keep one self-re-arming sweep chain per Tenant. The chain's generation is
14
14
  * kept in the object's state, so arming is idempotent and a disarmed or
15
- * replaced chain dies at its next link. The state is rebuildable: Workers
16
- * re-arm every Tenant at startup (§14.8).
15
+ * replaced chain dies at its next link. The state is rebuildable: a Host
16
+ * re-arms its Tenant's sweep when it opens it, at every start (§14.8).
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,21 @@
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
+ /**
6
+ * The Tenant the database holds. The record has no Tenant column (one Tenant per
7
+ * database), so every row read is this Tenant's.
8
+ */
9
+ tenantId: string;
10
+ /** Default `nylorun_stream_relay`. */
11
+ slot?: string;
12
+ /** Default `nylorun_stream_relay`. */
13
+ publication?: string;
14
+ /** The schema of the record tables. Default `nylorun_streams`. */
15
+ schema?: string;
16
+ /** Wait between attempts to take the slot. Default 5 s. */
17
+ retryMs?: number;
18
+ log?: (message: string, fields?: Record<string, unknown>) => void;
19
+ }
20
+ export declare const DEFAULT_RELAY_SLOT = "nylorun_stream_relay";
21
+ export declare function createPgoutputSource(options: PgoutputSourceOptions): ChangeSource;
@@ -0,0 +1,170 @@
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 { tenantId } = options;
29
+ const log = options.log ?? (() => { });
30
+ const pool = new pg.Pool({
31
+ connectionString: options.connectionString,
32
+ max: 2,
33
+ application_name: "nylorun-stream-relay",
34
+ });
35
+ let service;
36
+ let stopped = false;
37
+ let running;
38
+ let wake;
39
+ /** Creates the slot when missing or lost; true when the record must be reconciled. */
40
+ async function prepareSlot() {
41
+ const client = await pool.connect();
42
+ try {
43
+ const { rows } = await client.query("SELECT wal_status, active FROM pg_replication_slots WHERE slot_name = $1", [slot]);
44
+ const existing = rows[0];
45
+ if (existing?.wal_status === "lost" && !existing.active) {
46
+ log("replication slot was invalidated; creating it again", { slot });
47
+ await client.query("SELECT pg_drop_replication_slot($1)", [slot]);
48
+ }
49
+ if (!existing || existing.wal_status === "lost") {
50
+ try {
51
+ await client.query("SELECT pg_create_logical_replication_slot($1, 'pgoutput')", [slot]);
52
+ log("replication slot created", { slot });
53
+ await client.query(`INSERT INTO ${schema}.relay_slots (slot_name, reconcile_pending) VALUES ($1, true)
54
+ ON CONFLICT (slot_name) DO UPDATE SET reconcile_pending = true`, [slot]);
55
+ }
56
+ catch (error) {
57
+ // Another process created it first; its row says whether to reconcile.
58
+ if (error.code !== "42710")
59
+ throw error;
60
+ }
61
+ }
62
+ const pending = await client.query(`SELECT reconcile_pending FROM ${schema}.relay_slots WHERE slot_name = $1`, [slot]);
63
+ // A slot created before this table knew it: reconcile once to be sure.
64
+ return pending.rows[0]?.reconcile_pending ?? true;
65
+ }
66
+ finally {
67
+ client.release();
68
+ }
69
+ }
70
+ async function attempt(handlers) {
71
+ const fresh = await prepareSlot();
72
+ // `stop()` during `prepareSlot` had no service to stop: do not start one it cannot.
73
+ if (stopped)
74
+ return;
75
+ const current = new LogicalReplicationService({ connectionString: options.connectionString, application_name: "nylorun-stream-relay" }, { acknowledge: { auto: false, timeoutSeconds: 0 }, flowControl: { enabled: false } });
76
+ service = current;
77
+ let decoding;
78
+ current.on("start", () => handlers.onActive({ fresh }));
79
+ current.on("data", (_lsn, message) => {
80
+ switch (message.tag) {
81
+ case "begin":
82
+ decoding = { rows: [] };
83
+ break;
84
+ case "insert":
85
+ if (decoding &&
86
+ message.relation?.schema === schema &&
87
+ message.relation?.name === "session_events")
88
+ decoding.rows.push(rowOf(message.new, tenantId));
89
+ break;
90
+ case "commit": {
91
+ const tx = { endLsn: message.commitEndLsn, rows: decoding?.rows ?? [] };
92
+ decoding = undefined;
93
+ // A transaction with no record rows still moves the slot once acknowledged.
94
+ handlers.onTx(tx);
95
+ break;
96
+ }
97
+ }
98
+ });
99
+ current.on("heartbeat", (lsn, _timestamp, shouldRespond) => {
100
+ // Mid-transaction, the server's position may be past rows not yet delivered.
101
+ if (!shouldRespond || decoding)
102
+ return;
103
+ const position = handlers.onKeepalive(lsn);
104
+ if (position)
105
+ void current.acknowledge(position).catch(() => { });
106
+ });
107
+ current.on("error", () => { });
108
+ const plugin = new PgoutputPlugin({ protoVersion: 1, publicationNames: [publication] });
109
+ // Resolves when replication ends, rejects when it fails; `0/0` = the slot's confirmed position.
110
+ await current.subscribe(plugin, slot, "0/00000000");
111
+ }
112
+ async function run(handlers) {
113
+ while (!stopped) {
114
+ let failure;
115
+ try {
116
+ await attempt(handlers);
117
+ }
118
+ catch (error) {
119
+ failure = error;
120
+ }
121
+ if (stopped)
122
+ break;
123
+ const busy = failure?.code === "55006";
124
+ // Another process holds the slot: it is the relay. Anything else: report and retry.
125
+ handlers.onInactive(busy ? undefined : failure ?? new Error("replication ended"));
126
+ await new Promise((resolve) => {
127
+ wake = resolve;
128
+ setTimeout(resolve, retryMs).unref?.();
129
+ });
130
+ wake = undefined;
131
+ }
132
+ }
133
+ return {
134
+ start(handlers) {
135
+ if (running)
136
+ throw new Error("The change source is already started");
137
+ running = run(handlers);
138
+ },
139
+ acknowledge(lsn) {
140
+ void service?.acknowledge(lsn).catch(() => { });
141
+ },
142
+ async reconciled() {
143
+ await pool.query(`UPDATE ${schema}.relay_slots SET reconcile_pending = false WHERE slot_name = $1`, [
144
+ slot,
145
+ ]);
146
+ },
147
+ async lag() {
148
+ const { rows } = await pool.query(`SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)::text AS lag
149
+ FROM pg_replication_slots WHERE slot_name = $1`, [slot]);
150
+ return rows[0]?.lag == null ? undefined : Number(rows[0].lag);
151
+ },
152
+ async stop() {
153
+ stopped = true;
154
+ wake?.();
155
+ await service?.stop().catch(() => undefined);
156
+ await running?.catch(() => undefined);
157
+ await pool.end().catch(() => undefined);
158
+ },
159
+ };
160
+ }
161
+ /** A decoded `session_events` row (`pg` parses `json`; `bigint` arrives as text). */
162
+ function rowOf(values, tenantId) {
163
+ return {
164
+ tenantId,
165
+ sessionId: values.session_id,
166
+ seq: Number(values.seq),
167
+ generation: Number(values.generation),
168
+ body: typeof values.body === "string" ? JSON.parse(values.body) : values.body,
169
+ };
170
+ }