okengine 0.15.2 → 0.17.0

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 (256) hide show
  1. package/manifest.v1.schema.json +21 -2
  2. package/package.json +1 -1
  3. package/site/content/docs/elements/clock.mdx +7 -0
  4. package/site/content/docs/elements/flow.mdx +20 -12
  5. package/site/content/docs/elements/gate.mdx +108 -3
  6. package/site/content/docs/elements/signal.mdx +46 -17
  7. package/site/content/docs/elements/store.mdx +36 -32
  8. package/site/content/docs/elements/vault.mdx +26 -0
  9. package/site/content/docs/get-started/project-structure.mdx +4 -4
  10. package/site/content/docs/reference/cli.md +1 -1
  11. package/site/content/docs/reference/client.mdx +75 -17
  12. package/site/content/docs/reference/configuration.mdx +7 -4
  13. package/site/content/docs/reference/errors.mdx +24 -17
  14. package/site/content/docs/reference/fx.mdx +39 -22
  15. package/site/content/docs/reference/security.md +1 -1
  16. package/src/auth/api-key-sql.test.ts +47 -0
  17. package/src/auth/api-key-sql.ts +261 -0
  18. package/src/auth/api-keys.ts +276 -14
  19. package/src/auth/auth.test.ts +56 -0
  20. package/src/auth/config.ts +24 -5
  21. package/src/auth/index.ts +62 -1
  22. package/src/auth/plugin.ts +6 -1
  23. package/src/auth/schema.ts +8 -0
  24. package/src/auth/sessions.ts +18 -0
  25. package/src/auth/tables.ts +32 -0
  26. package/src/auth/tenant-config.ts +74 -0
  27. package/src/auth/tenant-tables.ts +11 -0
  28. package/src/auth/tenants.test.ts +63 -0
  29. package/src/auth/tenants.ts +360 -0
  30. package/src/cli/attach-host-console.test.ts +37 -0
  31. package/src/cli/attach-host-console.ts +6 -0
  32. package/src/cli/build.ts +2 -2
  33. package/src/client/budget.test.ts +1 -1
  34. package/src/client/create.ts +75 -5
  35. package/src/client/index.ts +13 -0
  36. package/src/client/live.test.ts +422 -0
  37. package/src/client/live.ts +389 -0
  38. package/src/client/notes-contract.test.ts +41 -0
  39. package/src/client/types.ts +85 -6
  40. package/src/client-react/index.ts +85 -1
  41. package/src/client-react/use-live.test.ts +129 -0
  42. package/src/compiler/effects-infer.ts +37 -2
  43. package/src/compiler/extract.test.ts +143 -11
  44. package/src/compiler/extract.ts +210 -30
  45. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
  46. package/src/compiler/fixtures/skyport.expected.json +2 -3
  47. package/src/compiler/response.ts +41 -11
  48. package/src/console/server/access.test.ts +116 -8
  49. package/src/console/server/access.ts +70 -23
  50. package/src/console/server/app.ts +45 -11
  51. package/src/console/server/console.test.ts +6 -8
  52. package/src/console/server/dev-identities.test.ts +127 -0
  53. package/src/console/server/dev-identities.ts +229 -0
  54. package/src/console/server/flows-invoke.test.ts +21 -4
  55. package/src/console/server/flows.ts +113 -58
  56. package/src/console/server/gates.test.ts +24 -0
  57. package/src/console/server/gates.ts +10 -30
  58. package/src/console/server/index.ts +1 -0
  59. package/src/console/server/live.test.ts +26 -1
  60. package/src/console/server/live.ts +4 -2
  61. package/src/console/server/operator-db.ts +2 -0
  62. package/src/console/server/state.ts +29 -55
  63. package/src/console/server/store.test.ts +17 -0
  64. package/src/console/server/store.ts +43 -1
  65. package/src/console/ui-next/dist/assets/access-page-CXVWWMmD.js +4 -0
  66. package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
  67. package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
  68. package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
  69. package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
  70. package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
  71. package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
  72. package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
  73. package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
  74. package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
  75. package/src/console/ui-next/dist/assets/flows-page-XDpAJO8f.js +1 -0
  76. package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
  77. package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
  78. package/src/console/ui-next/dist/assets/index-BBj2QJCu.js +66 -0
  79. package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
  80. package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
  81. package/src/console/ui-next/dist/assets/observability-page-BFaay44m.js +4 -0
  82. package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
  83. package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
  84. package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
  85. package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
  86. package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
  87. package/src/console/ui-next/dist/assets/store-page-CS5-aETQ.js +41 -0
  88. package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
  89. package/src/console/ui-next/dist/assets/tree-expand-toggle-BtyhmWb4.js +55 -0
  90. package/src/console/ui-next/dist/assets/units-page-Ca2Z-E52.js +1 -0
  91. package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
  92. package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
  93. package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
  94. package/src/console/ui-next/dist/assets/vault-page-BmOeAFwg.js +2 -0
  95. package/src/console/ui-next/dist/index.html +11 -9
  96. package/src/console/ui-next/index.html +3 -3
  97. package/src/console/ui-next/src/client.ts +167 -0
  98. package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
  99. package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
  100. package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
  101. package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
  102. package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
  103. package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
  104. package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
  105. package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
  106. package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
  107. package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
  108. package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
  109. package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
  110. package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
  111. package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
  112. package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
  113. package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
  114. package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
  115. package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
  116. package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
  117. package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
  118. package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
  119. package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
  120. package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
  121. package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
  122. package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
  123. package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
  124. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
  125. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
  126. package/src/console/ui-next/src/lib/document-meta.test.ts +1 -0
  127. package/src/console/ui-next/src/lib/document-meta.ts +2 -1
  128. package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
  129. package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
  130. package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
  131. package/src/console/ui-next/src/lib/shortcut.ts +3 -0
  132. package/src/console/ui-next/src/router.tsx +10 -1
  133. package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
  134. package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
  135. package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
  136. package/src/console/ui-next/ui-next-seed-store.ts +43 -15
  137. package/src/console/xss.gate.test.ts +1 -0
  138. package/src/drivers/index.ts +2 -0
  139. package/src/drivers/journal-postgres.ts +12 -3
  140. package/src/drivers/pg-rls.ts +26 -2
  141. package/src/drivers/pg-vault-rls.ts +68 -0
  142. package/src/drivers/signal-engine.ts +69 -15
  143. package/src/drivers/signal-live-iter.ts +65 -0
  144. package/src/drivers/signal-nats.ts +2 -1
  145. package/src/drivers/signal-postgres.ts +95 -12
  146. package/src/drivers/signal-redis.ts +2 -1
  147. package/src/drivers/signal-retention.ts +64 -0
  148. package/src/drivers/signal-types.ts +35 -5
  149. package/src/elements/clock/declare.ts +64 -2
  150. package/src/elements/clock/reconcile.ts +98 -25
  151. package/src/elements/clock/runtime.ts +13 -2
  152. package/src/elements/clock.test.ts +28 -0
  153. package/src/elements/clock.ts +9 -1
  154. package/src/elements/gate/declare.ts +2 -0
  155. package/src/elements/gate/permissions.ts +12 -0
  156. package/src/elements/gate.ts +6 -1
  157. package/src/elements/signal/declare.ts +44 -10
  158. package/src/elements/signal/delivery-modes.test.ts +90 -4
  159. package/src/elements/signal/order-lifecycle.test.ts +20 -4
  160. package/src/elements/signal/runtime.ts +42 -0
  161. package/src/elements/signal.test.ts +21 -8
  162. package/src/elements/signal.ts +1 -1
  163. package/src/elements/store/cache.ts +1 -1
  164. package/src/elements/store/declare.ts +7 -0
  165. package/src/elements/store/rls-identity.test.ts +29 -0
  166. package/src/elements/store/rls-identity.ts +3 -0
  167. package/src/elements/store/runtime.ts +14 -0
  168. package/src/elements/store/schema-decl.ts +162 -44
  169. package/src/elements/store/schema-tenant.ts +41 -0
  170. package/src/elements/store/sql-rls-isolation.test.ts +39 -0
  171. package/src/elements/store/sql-select.types.test.ts +71 -0
  172. package/src/elements/store/sql-session.ts +54 -32
  173. package/src/elements/store.ts +5 -0
  174. package/src/elements/vault/builtin-adapter.ts +15 -2
  175. package/src/elements/vault/declare.ts +8 -0
  176. package/src/elements/vault/runtime.ts +7 -0
  177. package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
  178. package/src/elements/vault/storage.ts +9 -0
  179. package/src/elements/vault/test-helpers.ts +2 -1
  180. package/src/i18n/catalogs/ar.ts +19 -0
  181. package/src/i18n/catalogs/en.ts +19 -0
  182. package/src/index.ts +2 -0
  183. package/src/kernel/adopt-routes.ts +56 -8
  184. package/src/kernel/api-key-host-persist.test.ts +143 -0
  185. package/src/kernel/app-tenant.ts +122 -0
  186. package/src/kernel/app.ts +196 -57
  187. package/src/kernel/auth-resolve.ts +13 -5
  188. package/src/kernel/boot-bind/clock.ts +9 -3
  189. package/src/kernel/boot.test.ts +4 -4
  190. package/src/kernel/boot.ts +2 -0
  191. package/src/kernel/budget.test.ts +1 -1
  192. package/src/kernel/clock-durable.ts +8 -0
  193. package/src/kernel/clock-per-tenant-name.ts +5 -0
  194. package/src/kernel/clock-reconcile.ts +8 -0
  195. package/src/kernel/errors-live-resume.ts +15 -0
  196. package/src/kernel/errors-tenant.ts +29 -0
  197. package/src/kernel/errors.registry.test.ts +31 -3
  198. package/src/kernel/errors.ts +43 -3
  199. package/src/kernel/flow.ts +26 -5
  200. package/src/kernel/fx-auth-keys.test.ts +88 -0
  201. package/src/kernel/fx-auth-keys.ts +203 -0
  202. package/src/kernel/fx-auth-tenants.test.ts +87 -0
  203. package/src/kernel/fx-auth-tenants.ts +286 -0
  204. package/src/kernel/fx-live-stream.ts +149 -0
  205. package/src/kernel/fx-live.test.ts +157 -0
  206. package/src/kernel/fx-runtime.ts +15 -0
  207. package/src/kernel/fx-tenant-store.ts +213 -0
  208. package/src/kernel/fx.test.ts +108 -1
  209. package/src/kernel/fx.ts +206 -28
  210. package/src/kernel/hooks.ts +2 -2
  211. package/src/kernel/http-resource.ts +9 -18
  212. package/src/kernel/index.ts +2 -0
  213. package/src/kernel/journal.ts +12 -0
  214. package/src/kernel/live-http.test.ts +78 -0
  215. package/src/kernel/live-http.ts +114 -0
  216. package/src/kernel/live-resume.test.ts +125 -0
  217. package/src/kernel/on.ts +51 -0
  218. package/src/kernel/pipeline-api-key.test.ts +192 -7
  219. package/src/kernel/pipeline-tenant.ts +49 -0
  220. package/src/kernel/pipeline.test.ts +1 -1
  221. package/src/kernel/pipeline.ts +41 -4
  222. package/src/kernel/resource-mount.test.ts +10 -27
  223. package/src/kernel/tenant-resolve.test.ts +101 -0
  224. package/src/kernel/tenant-resolve.ts +124 -0
  225. package/src/kernel/tenant-roles.test.ts +87 -0
  226. package/src/kernel/triggers.ts +59 -21
  227. package/src/manifest/diff.test.ts +11 -2
  228. package/src/manifest/diff.ts +53 -11
  229. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  230. package/src/manifest/fixtures/skyport.manifest.json +0 -1
  231. package/src/manifest/types.ts +58 -6
  232. package/src/release/build-lib.ts +14 -1
  233. package/src/release/limits.ts +2 -2
  234. package/src/release/measure.ts +55 -1
  235. package/src/client/live-gap.test.ts +0 -35
  236. package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
  237. package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
  238. package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
  239. package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
  240. package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
  241. package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
  242. package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
  243. package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
  244. package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
  245. package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
  246. package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
  247. package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
  248. package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
  249. package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
  250. package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
  251. package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
  252. package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
  253. package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
  254. package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
  255. package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
  256. package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +0 -2
@@ -80,6 +80,7 @@ type DeclaredSchemaTable = {
80
80
  readonly columns: Record<string, DeclaredColumn>;
81
81
  readonly rls?: boolean;
82
82
  readonly policies?: Record<string, TablePolicy>;
83
+ readonly tenantScoped?: boolean;
83
84
  };
84
85
 
85
86
  interface ProjectScope {
@@ -212,6 +213,9 @@ export async function extractManifest(options: ExtractManifestOptions = {}): Pro
212
213
  if (scope.topology) manifest.topology = scope.topology;
213
214
  if (scope.images) manifest.images = scope.images;
214
215
 
216
+ applyTenancyDefaults(manifest);
217
+ assertTenantSafeSchema(manifest);
218
+
215
219
  return manifest;
216
220
  }
217
221
 
@@ -258,7 +262,7 @@ function collectDeclarations(filePath: string, program: AstNode, scope: ProjectS
258
262
  }
259
263
  });
260
264
 
261
- // oke({ name })
265
+ // oke({ name, gate: { auth: { tenant } } })
262
266
  walk(program, (node) => {
263
267
  if (node.type !== "CallExpression") return;
264
268
  const call = node as CallExpression;
@@ -266,6 +270,7 @@ function collectDeclarations(filePath: string, program: AstNode, scope: ProjectS
266
270
  const opts = objectArg(call.arguments[0]);
267
271
  const name = stringProp(opts, "name");
268
272
  if (name) scope.app = name;
273
+ applyOkeTenantConfig(opts, scope);
269
274
  });
270
275
  }
271
276
 
@@ -464,21 +469,24 @@ function tableFromDeclared(t: DeclaredSchemaTable): {
464
469
  columns: Record<string, DeclaredColumn>;
465
470
  rls?: boolean;
466
471
  policies?: Record<string, TablePolicy>;
472
+ tenantScoped?: boolean;
467
473
  } {
468
474
  return {
469
475
  columns: t.columns,
470
476
  ...(t.rls ? { rls: true } : {}),
471
477
  ...(t.policies ? { policies: t.policies } : {}),
478
+ ...(t.tenantScoped === false ? { tenantScoped: false } : {}),
472
479
  };
473
480
  }
474
481
 
475
482
  function parseSchemaTableExtras(
476
483
  node: AstNode | undefined,
477
- ): Pick<DeclaredSchemaTable, "rls" | "policies"> {
484
+ ): Pick<DeclaredSchemaTable, "rls" | "policies" | "tenantScoped"> {
478
485
  if (!node || node.type !== "ArrayExpression") return {};
479
486
  const els = (node as AstNode & { elements?: AstNode[] }).elements ?? [];
480
487
  const policies: Record<string, TablePolicy> = {};
481
488
  let rls = false;
489
+ let tenantScoped: boolean | undefined;
482
490
  for (const el of els) {
483
491
  if (!el || el.type !== "CallExpression") continue;
484
492
  const extra = parseSchemaTableExtra(el as CallExpression);
@@ -487,18 +495,27 @@ function parseSchemaTableExtras(
487
495
  rls = true;
488
496
  continue;
489
497
  }
498
+ if (extra.kind === "unscoped") {
499
+ tenantScoped = false;
500
+ continue;
501
+ }
490
502
  policies[extra.name] = extra.policy;
491
503
  rls = true;
492
504
  }
493
505
  return {
494
506
  ...(rls ? { rls: true } : {}),
495
507
  ...(Object.keys(policies).length > 0 ? { policies } : {}),
508
+ ...(tenantScoped === false ? { tenantScoped: false } : {}),
496
509
  };
497
510
  }
498
511
 
499
512
  function parseSchemaTableExtra(
500
513
  call: CallExpression,
501
- ): { kind: "rls" } | { kind: "policy"; name: string; policy: TablePolicy } | undefined {
514
+ ):
515
+ | { kind: "rls" }
516
+ | { kind: "unscoped" }
517
+ | { kind: "policy"; name: string; policy: TablePolicy }
518
+ | undefined {
502
519
  const callee = call.callee;
503
520
  if (callee.type !== "MemberExpression") return undefined;
504
521
  const member = callee as AstNode & { object: AstNode; property: AstNode };
@@ -506,13 +523,16 @@ function parseSchemaTableExtra(
506
523
  if (method === "rls" && isStoreSchemaCallee(member.object)) {
507
524
  return { kind: "rls" };
508
525
  }
526
+ if (method === "unscoped" && isStoreSchemaCallee(member.object)) {
527
+ return { kind: "unscoped" };
528
+ }
509
529
  if (method === "policy" && isStoreSchemaCallee(member.object)) {
510
530
  const name = stringArg(call.arguments[0]);
511
531
  if (!name) return undefined;
512
532
  return { kind: "policy", name, policy: parseTablePolicyOptions(objectArg(call.arguments[1])) };
513
533
  }
514
534
  if (
515
- (method === "gate" || method === "owner" || method === "scope") &&
535
+ (method === "gate" || method === "owner" || method === "scope" || method === "tenant") &&
516
536
  isStoreSchemaPolicyCallee(member.object)
517
537
  ) {
518
538
  const key = stringArg(call.arguments[0]);
@@ -521,9 +541,11 @@ function parseSchemaTableExtra(
521
541
  const command = (stringProp(opts, "for") ??
522
542
  (method === "owner"
523
543
  ? "all"
524
- : method === "scope"
525
- ? "insert"
526
- : "select")) as TablePolicy["for"];
544
+ : method === "tenant"
545
+ ? "all"
546
+ : method === "scope"
547
+ ? "insert"
548
+ : "select")) as TablePolicy["for"];
527
549
  const slug = key.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_|_$/g, "");
528
550
  const name = `${method}_${slug}_${command}`;
529
551
  const lit = `'${key.replaceAll("'", "''")}'`;
@@ -532,7 +554,9 @@ function parseSchemaTableExtra(
532
554
  ? `oke.gate() = ${lit}`
533
555
  : method === "owner"
534
556
  ? `${key} = oke.user()`
535
- : `oke.has_scope(${lit})`;
557
+ : method === "tenant"
558
+ ? `${key} = oke.tenant()`
559
+ : `oke.has_scope(${lit})`;
536
560
  const policy: TablePolicy = {
537
561
  for: command,
538
562
  ...(command === "insert"
@@ -798,6 +822,8 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
798
822
  namespaces.add(storeName);
799
823
  storeEntry.namespaces = [...namespaces].sort();
800
824
  if (boolProp(storeOpts, "durable")) storeEntry.durable = true;
825
+ if (boolProp(storeOpts, "tenantScoped") === false) storeEntry.tenantScoped = false;
826
+ else if (boolProp(storeOpts, "tenantScoped") === true) storeEntry.tenantScoped = true;
801
827
  } else if (facet === "files") {
802
828
  const buckets = new Set(storeEntry.buckets ?? []);
803
829
  buckets.add(storeName);
@@ -844,6 +870,24 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
844
870
  }
845
871
  }
846
872
 
873
+ if (obj === "clock" && prop === "perTenant") {
874
+ const name = stringArg(call.arguments[0]);
875
+ const opts = objectArg(call.arguments[1]);
876
+ if (name) {
877
+ scope.clocks[name] = {
878
+ ...(stringProp(opts, "every") ? { every: stringProp(opts, "every") } : {}),
879
+ ...(stringProp(opts, "cron") ? { cron: stringProp(opts, "cron") } : {}),
880
+ ...(boolProp(opts, "overridable") !== undefined
881
+ ? { overridable: boolProp(opts, "overridable") }
882
+ : {}),
883
+ ...(stringProp(opts, "description")
884
+ ? { description: stringProp(opts, "description") }
885
+ : {}),
886
+ perTenant: true,
887
+ };
888
+ }
889
+ }
890
+
847
891
  if (obj === "gate" && prop === "policy") {
848
892
  const policyName = stringArg(call.arguments[0]);
849
893
  if (policyName) {
@@ -1070,6 +1114,8 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
1070
1114
  : {}),
1071
1115
  ...(stringProp(opts, "rotate") ? { rotate: stringProp(opts, "rotate") } : {}),
1072
1116
  ...(prop === "config" ? { sensitive: false } : {}),
1117
+ ...(boolProp(opts, "perTenant") === true ? { perTenant: true } : {}),
1118
+ ...(boolProp(opts, "perTenant") === false ? { perTenant: false } : {}),
1073
1119
  };
1074
1120
  const bindingName = enclosingConstName(call, program);
1075
1121
  if (bindingName) {
@@ -1089,6 +1135,16 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
1089
1135
  if (signalName) {
1090
1136
  const delivery = stringProp(opts, "delivery") as SignalDelivery | undefined;
1091
1137
  if (delivery) {
1138
+ const retentionObj = objectProp(opts, "retention");
1139
+ const maxAge = retentionObj ? stringProp(retentionObj, "maxAge") : undefined;
1140
+ const maxCount = retentionObj ? numberProp(retentionObj, "maxCount") : undefined;
1141
+ const retention =
1142
+ maxAge !== undefined || maxCount !== undefined
1143
+ ? {
1144
+ ...(maxAge !== undefined ? { maxAge } : {}),
1145
+ ...(maxCount !== undefined ? { maxCount } : {}),
1146
+ }
1147
+ : undefined;
1092
1148
  const signal: Signal = {
1093
1149
  delivery,
1094
1150
  ...(stringProp(opts, "description")
@@ -1100,6 +1156,7 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
1100
1156
  ...(boolProp(opts, "deadLetter") !== undefined
1101
1157
  ? { deadLetter: boolProp(opts, "deadLetter") }
1102
1158
  : {}),
1159
+ ...(retention !== undefined ? { retention } : {}),
1103
1160
  };
1104
1161
  scope.signals[signalName] = signal;
1105
1162
  const bindingName = enclosingConstName(call, program);
@@ -1127,6 +1184,7 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
1127
1184
  ...(stringProp(opts, "description")
1128
1185
  ? { description: stringProp(opts, "description") }
1129
1186
  : {}),
1187
+ ...(boolProp(opts, "perTenant") === true ? { perTenant: true } : {}),
1130
1188
  };
1131
1189
  }
1132
1190
  }
@@ -1177,6 +1235,8 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
1177
1235
  ? { description: stringProp(opts, "description") }
1178
1236
  : {}),
1179
1237
  ...(stringProp(opts, "rotate") ? { rotate: stringProp(opts, "rotate") } : {}),
1238
+ ...(boolProp(opts, "perTenant") === true ? { perTenant: true } : {}),
1239
+ ...(boolProp(opts, "perTenant") === false ? { perTenant: false } : {}),
1180
1240
  };
1181
1241
  const bindingName = enclosingConstName(call, program);
1182
1242
  if (bindingName) {
@@ -1250,7 +1310,7 @@ function collectConfig(opts: AstNode | undefined, scope: ProjectScope): void {
1250
1310
  if (tenancy) {
1251
1311
  const isolation = stringProp(tenancy, "isolation");
1252
1312
  if (isolation === "row" || isolation === "schema" || isolation === "database") {
1253
- scope.tenancy = { isolation };
1313
+ scope.tenancy = { ...scope.tenancy, isolation };
1254
1314
  }
1255
1315
  }
1256
1316
 
@@ -1363,8 +1423,9 @@ function collectFlows(file: SourceFile, program: AstNode, scope: ProjectScope):
1363
1423
  const flowNode = call.arguments[1];
1364
1424
  if (!triggerNode) return;
1365
1425
 
1366
- // on(http.resource(path, bag)) expand the mount into five bindings.
1426
+ // on(http.resource(...)) or on(http.get(...).live(signal))
1367
1427
  if (!flowNode) {
1428
+ if (registerLiveHttpMount(call, triggerNode, file, program, scope)) return;
1368
1429
  registerResourceMount(call, triggerNode, file, program, scope);
1369
1430
  return;
1370
1431
  }
@@ -1523,7 +1584,7 @@ function registerFlow(args: {
1523
1584
  flow.source = `${args.file.path}:${line}`;
1524
1585
 
1525
1586
  if (boolProp(opts, "durable")) flow.durable = true;
1526
- if (boolProp(opts, "live") || liveFromTrigger) flow.live = true;
1587
+ if (typeof liveFromTrigger === "string") flow.live = liveFromTrigger;
1527
1588
  if (boolProp(opts, "breaking")) flow.breaking = true;
1528
1589
 
1529
1590
  const slo = parseSlo(objectProp(opts, "slo"));
@@ -1573,6 +1634,8 @@ function registerFlow(args: {
1573
1634
 
1574
1635
  const plane = stringProp(opts, "plane");
1575
1636
  if (plane === "user" || plane === "operator") flow.plane = plane;
1637
+ if (boolProp(opts, "tenantScoped") === false) flow.tenantScoped = false;
1638
+ else if (boolProp(opts, "tenantScoped") === true) flow.tenantScoped = true;
1576
1639
 
1577
1640
  if (inferred.cacheIneligible) {
1578
1641
  flow.cache = false;
@@ -1592,7 +1655,7 @@ function registerFlow(args: {
1592
1655
  const cacheKeys = stringProp(opts, "cacheKeys");
1593
1656
  if (cacheKeys) {
1594
1657
  flow.cacheKeys = cacheKeys;
1595
- } else if (flow.live && inferred.readsUserId && effects?.reads?.[0]) {
1658
+ } else if (inferred.readsUserId && effects?.reads?.[0]) {
1596
1659
  flow.cacheKeys = `computed:${effects.reads[0]}/userId`;
1597
1660
  }
1598
1661
 
@@ -1600,7 +1663,50 @@ function registerFlow(args: {
1600
1663
  }
1601
1664
 
1602
1665
  /**
1603
- * `http.resource(path, bag).gate(...).public().live()` — find the resource call
1666
+ * Synthesize a Manifest flow for one-arg `on(http.get(path).live(signal))`.
1667
+ *
1668
+ * @param onCall - The outer `on(…)` call
1669
+ * @param triggerNode - HTTP trigger expression
1670
+ * @param file - Source file
1671
+ * @param program - Program root (for export-name lookup)
1672
+ * @param scope - Project scope
1673
+ * @returns True when a live HTTP exposure was registered
1674
+ */
1675
+ function registerLiveHttpMount(
1676
+ onCall: CallExpression,
1677
+ triggerNode: AstNode,
1678
+ file: SourceFile,
1679
+ program: AstNode,
1680
+ scope: ProjectScope,
1681
+ ): boolean {
1682
+ const parsed = parseHttpTrigger(triggerNode, scope, file.path);
1683
+ if (typeof parsed?.live !== "string" || parsed.trigger.http === undefined) {
1684
+ return false;
1685
+ }
1686
+ const exportName = enclosingConstName(onCall, program);
1687
+ const name =
1688
+ nameFromFlowFile(file.path, exportName) ??
1689
+ exportName ??
1690
+ `flow_${Object.keys(scope.flows).length + 1}`;
1691
+ if (exportName) {
1692
+ scope.flowExports.set(exportName, name);
1693
+ scope.bindings.set(exportName, { kind: "flow", ref: name });
1694
+ }
1695
+ scope.bindings.set(name, { kind: "flow", ref: name });
1696
+ const line = lineAt(file.source, onCall.start ?? 0);
1697
+ const flow: Flow = {
1698
+ trigger: { http: parsed.trigger.http },
1699
+ live: parsed.live,
1700
+ effects: { reads: [`signal:${parsed.live}`] },
1701
+ source: `${file.path}:${line}`,
1702
+ };
1703
+ if (parsed.gates && parsed.gates.length > 0) flow.gates = parsed.gates;
1704
+ scope.flows[name] = flow;
1705
+ return true;
1706
+ }
1707
+
1708
+ /**
1709
+ * `http.resource(path, bag).gate(...).public()` — find the resource call
1604
1710
  * and collect the same chain {@link parseHttpTrigger} reads on a single verb.
1605
1711
  *
1606
1712
  * @param node - First argument to `on(…)`
@@ -1609,11 +1715,10 @@ function registerFlow(args: {
1609
1715
  function unwrapResourceMount(
1610
1716
  node: AstNode,
1611
1717
  scope: ProjectScope,
1612
- ): { httpCall: CallExpression; gates?: string[]; live?: boolean } | undefined {
1718
+ ): { httpCall: CallExpression; gates?: string[] } | undefined {
1613
1719
  const chain = flattenMemberCallChain(node);
1614
1720
  let httpCall: CallExpression | undefined;
1615
1721
  const gateNames: string[] = [];
1616
- let live = false;
1617
1722
  for (const item of chain) {
1618
1723
  if (item.type === "CallExpression") {
1619
1724
  const c = item as CallExpression;
@@ -1633,14 +1738,12 @@ function unwrapResourceMount(
1633
1738
  continue;
1634
1739
  }
1635
1740
  if (prop === "public") gateNames.push("public");
1636
- if (prop === "live") live = true;
1637
1741
  }
1638
1742
  }
1639
1743
  if (!httpCall) return undefined;
1640
1744
  return {
1641
1745
  httpCall,
1642
1746
  gates: gateNames.length > 0 ? gateNames : undefined,
1643
- live: live || undefined,
1644
1747
  };
1645
1748
  }
1646
1749
 
@@ -1666,7 +1769,7 @@ function registerResourceMount(
1666
1769
  ): void {
1667
1770
  const parsed = unwrapResourceMount(triggerNode, scope);
1668
1771
  if (!parsed) return;
1669
- const { httpCall, gates, live } = parsed;
1772
+ const { httpCall, gates } = parsed;
1670
1773
 
1671
1774
  const path = stringArg(httpCall.arguments[0]);
1672
1775
  if (!path) return;
@@ -1713,7 +1816,6 @@ function registerResourceMount(
1713
1816
  trigger: { http: { method: v.method as never, path: v.p } },
1714
1817
  ...(v.eff ? { effects: v.eff as Effects } : {}),
1715
1818
  ...(gates && gates.length > 0 ? { gates } : {}),
1716
- ...(live && v.method === "GET" ? { live: true } : {}),
1717
1819
  ...(resource?.breaking ? { breaking: true } : {}),
1718
1820
  source: `${file.path}:${line}`,
1719
1821
  };
@@ -1753,7 +1855,6 @@ function registerNamedTableCrud(call: CallExpression, file: SourceFile, scope: P
1753
1855
  : undefined;
1754
1856
  const idPath = `${path}/:id`;
1755
1857
  const line = lineAt(file.source, call.start ?? 0);
1756
- const live = boolProp(opts, "liveList") === true;
1757
1858
  const skipCreate = boolProp(opts, "skipCreate") === true;
1758
1859
  const ctx = schemaExpandContext(scope, file.path);
1759
1860
  const createIn = jsonSchemaFromAst(objectProp(opts, "createIn"), ctx);
@@ -1775,7 +1876,6 @@ function registerNamedTableCrud(call: CallExpression, file: SourceFile, scope: P
1775
1876
  readonly method: string;
1776
1877
  readonly p: string;
1777
1878
  readonly eff: Effects | undefined;
1778
- readonly live?: boolean;
1779
1879
  readonly in?: JsonSchema;
1780
1880
  readonly out?: JsonSchema;
1781
1881
  }[] = [
@@ -1784,7 +1884,6 @@ function registerNamedTableCrud(call: CallExpression, file: SourceFile, scope: P
1784
1884
  method: "GET",
1785
1885
  p: path,
1786
1886
  eff: storeRef ? { reads: [storeRef] } : undefined,
1787
- live,
1788
1887
  in: defaultListInSchema(),
1789
1888
  ...(itemOut ? { out: { type: "array", items: itemOut } } : {}),
1790
1889
  },
@@ -1831,7 +1930,6 @@ function registerNamedTableCrud(call: CallExpression, file: SourceFile, scope: P
1831
1930
  const flow: Flow = {
1832
1931
  trigger: { http: { method: v.method as never, path: v.p } },
1833
1932
  ...(v.eff ? { effects: v.eff as Effects } : {}),
1834
- ...(v.live ? { live: true } : {}),
1835
1933
  ...(v.in !== undefined ? { in: v.in } : {}),
1836
1934
  ...(v.out !== undefined ? { out: v.out } : {}),
1837
1935
  source: `${file.path}:${line}`,
@@ -1844,7 +1942,8 @@ function registerNamedTableCrud(call: CallExpression, file: SourceFile, scope: P
1844
1942
  interface ParsedTrigger {
1845
1943
  trigger: Trigger;
1846
1944
  gates?: string[];
1847
- live?: boolean;
1945
+ /** Live signal name from `.live(signal)` / `http.live(signal)`. */
1946
+ live?: string;
1848
1947
  }
1849
1948
 
1850
1949
  function parseTrigger(
@@ -1864,7 +1963,7 @@ function parseTrigger(
1864
1963
  return { trigger: {} };
1865
1964
  }
1866
1965
 
1867
- // http.post("/x").gate(...).live() / http.get("/x").public()
1966
+ // http.post("/x").gate(...) / http.get("/x").live(signal) / http.live(signal)
1868
1967
  const http = parseHttpTrigger(call, scope, filePath);
1869
1968
  if (http) return http;
1870
1969
 
@@ -1894,16 +1993,27 @@ function parseTrigger(
1894
1993
  return undefined;
1895
1994
  }
1896
1995
 
1996
+ function resolveSignalName(node: AstNode | undefined, scope: ProjectScope): string | undefined {
1997
+ const lit = stringArg(node);
1998
+ if (lit) return lit;
1999
+ const id = identifierName(node);
2000
+ if (!id) return undefined;
2001
+ const binding = scope.bindings.get(id);
2002
+ if (binding?.kind === "signal") return binding.ref;
2003
+ if (scope.signals[id]) return id;
2004
+ return id;
2005
+ }
2006
+
1897
2007
  function parseHttpTrigger(
1898
2008
  leaf: AstNode,
1899
2009
  scope: ProjectScope,
1900
2010
  filePath?: string,
1901
2011
  ): ParsedTrigger | undefined {
1902
- // Walk: http.METHOD(path).gate(...).live() / .public()
2012
+ // Walk: http.METHOD(path).gate(...).live(signal) / http.live(signal) / .public()
1903
2013
  const chain = flattenMemberCallChain(leaf);
1904
2014
  let method: string | undefined;
1905
2015
  let path: string | undefined;
1906
- let live = false;
2016
+ let live: string | undefined;
1907
2017
  const gateNames: string[] = [];
1908
2018
 
1909
2019
  for (const node of chain) {
@@ -1916,6 +2026,15 @@ function parseHttpTrigger(
1916
2026
  const obj = member.object;
1917
2027
 
1918
2028
  if (obj.type === "Identifier" && (obj as Identifier).name === "http" && prop) {
2029
+ if (prop === "live") {
2030
+ method = "GET";
2031
+ const signalName = resolveSignalName(c.arguments[0], scope);
2032
+ if (signalName) {
2033
+ live = signalName;
2034
+ path = `/_oke/live/${encodeURIComponent(signalName)}`;
2035
+ }
2036
+ continue;
2037
+ }
1919
2038
  method = prop.toUpperCase();
1920
2039
  path = stringArg(c.arguments[0]);
1921
2040
  continue;
@@ -1929,7 +2048,10 @@ function parseHttpTrigger(
1929
2048
  }
1930
2049
 
1931
2050
  if (prop === "public") gateNames.push("public");
1932
- if (prop === "live") live = true;
2051
+ if (prop === "live") {
2052
+ const signalName = resolveSignalName(c.arguments[0], scope);
2053
+ if (signalName) live = signalName;
2054
+ }
1933
2055
  }
1934
2056
  }
1935
2057
 
@@ -1959,7 +2081,7 @@ function parseHttpTrigger(
1959
2081
  },
1960
2082
  },
1961
2083
  gates: gateNames.length > 0 ? gateNames : undefined,
1962
- live: live || undefined,
2084
+ ...(live !== undefined ? { live } : {}),
1963
2085
  };
1964
2086
  }
1965
2087
 
@@ -2126,7 +2248,7 @@ function schemaProp(
2126
2248
 
2127
2249
  // ── AST helpers ────────────────────────────────────────────────────────────
2128
2250
 
2129
- /** Leaf-to-root walk of `http.get("/x").public().live()`. */
2251
+ /** Leaf-to-root walk of `http.get("/x").public().live(signal)`. */
2130
2252
  function flattenMemberCallChain(leaf: AstNode): AstNode[] {
2131
2253
  const chain: AstNode[] = [];
2132
2254
  let current: AstNode = leaf;
@@ -2334,6 +2456,64 @@ export function lineAt(source: string, offset: number): number {
2334
2456
  return line;
2335
2457
  }
2336
2458
 
2459
+ function applyOkeTenantConfig(opts: AstNode | undefined, scope: ProjectScope): void {
2460
+ const gate = objectProp(opts, "gate");
2461
+ const auth = objectProp(gate, "auth");
2462
+ const tenant = objectProp(auth, "tenant");
2463
+ if (!tenant) return;
2464
+ if (tenant.type === "Literal" && (tenant as Literal).value === true) {
2465
+ scope.tenancy = {
2466
+ ...scope.tenancy,
2467
+ enabled: true,
2468
+ isolation: scope.tenancy?.isolation ?? "row",
2469
+ membershipRequired: false,
2470
+ };
2471
+ return;
2472
+ }
2473
+ if (tenant.type === "ObjectExpression") {
2474
+ scope.tenancy = {
2475
+ ...scope.tenancy,
2476
+ enabled: true,
2477
+ isolation: scope.tenancy?.isolation ?? "row",
2478
+ membershipRequired: boolProp(tenant, "required") === true,
2479
+ };
2480
+ }
2481
+ }
2482
+
2483
+ function applyTenancyDefaults(manifest: Manifest): void {
2484
+ if (manifest.tenancy?.enabled !== true) return;
2485
+ for (const flow of Object.values(manifest.flows ?? {})) {
2486
+ if (flow.tenantScoped === undefined) flow.tenantScoped = true;
2487
+ }
2488
+ for (const store of Object.values(manifest.stores ?? {})) {
2489
+ if (store.facet === "kv" && store.tenantScoped === undefined) store.tenantScoped = true;
2490
+ }
2491
+ for (const secret of Object.values(manifest.vault ?? {})) {
2492
+ if (secret.perTenant === undefined) secret.perTenant = true;
2493
+ }
2494
+ }
2495
+
2496
+ function assertTenantSafeSchema(manifest: Manifest): void {
2497
+ if (manifest.tenancy?.enabled !== true) return;
2498
+ for (const [storeName, store] of Object.entries(manifest.stores ?? {})) {
2499
+ if (store.facet !== "sql" || !store.tables) continue;
2500
+ for (const [tableName, table] of Object.entries(store.tables)) {
2501
+ if (table.tenantScoped === false) continue;
2502
+ const policies = Object.values(table.policies ?? {});
2503
+ const hasTenant = policies.some(
2504
+ (p) =>
2505
+ (typeof p.using === "string" && p.using.includes("oke.tenant()")) ||
2506
+ (typeof p.withCheck === "string" && p.withCheck.includes("oke.tenant()")),
2507
+ );
2508
+ if (!hasTenant) {
2509
+ throw new Error(
2510
+ `extract: table "${storeName}.${tableName}" needs store.schema.policy.tenant(...) or store.schema.unscoped() when gate.auth.tenant is on`,
2511
+ );
2512
+ }
2513
+ }
2514
+ }
2515
+ }
2516
+
2337
2517
  function sortRecord<T>(record: Record<string, T>): Record<string, T> {
2338
2518
  const out: Record<string, T> = {};
2339
2519
  for (const key of Object.keys(record).sort()) {
@@ -33,9 +33,8 @@ export const create = on(
33
33
  );
34
34
 
35
35
  export const mine = on(
36
- http.get("/bookings").gate(member).live(),
36
+ http.get("/bookings").gate(member),
37
37
  flow("bookings.mine", {
38
- live: true,
39
38
  do: (_, fx) =>
40
39
  fx
41
40
  .store(db)
@@ -32,7 +32,7 @@
32
32
  "effects": {
33
33
  "reads": ["sql:bookings"]
34
34
  },
35
- "source": "src/flows/bookings/index.ts:48"
35
+ "source": "src/flows/bookings/index.ts:47"
36
36
  },
37
37
  "bookings.mine": {
38
38
  "trigger": {
@@ -46,7 +46,6 @@
46
46
  "reads": ["sql:bookings"]
47
47
  },
48
48
  "source": "src/flows/bookings/index.ts:37",
49
- "live": true,
50
49
  "cacheKeys": "computed:sql:bookings/userId"
51
50
  },
52
51
  "bookings.refundBooking": {
@@ -54,7 +53,7 @@
54
53
  "reads": ["sql:bookings"],
55
54
  "writes": ["sql:bookings"]
56
55
  },
57
- "source": "src/flows/bookings/index.ts:52"
56
+ "source": "src/flows/bookings/index.ts:51"
58
57
  },
59
58
  "payments.chargeBooking": {
60
59
  "effects": {
@@ -5,8 +5,9 @@
5
5
  */
6
6
 
7
7
  import type { FlowFailure } from "../kernel/errors.ts";
8
- import { isJsonResult, isJsonStreamResult, type JsonStreamResult } from "../kernel/fx.ts";
8
+ import type { JsonResult, JsonStreamResult, SseFrame } from "../kernel/fx.ts";
9
9
  import { isFlowFailure } from "../kernel/hooks.ts";
10
+ import { lazyRequire } from "../kernel/lazy-require.ts";
10
11
  import { VALIDATION_ERROR_CODE } from "../validation/standard-schema.ts";
11
12
 
12
13
  /** Successful envelope. */
@@ -53,10 +54,11 @@ export function statusForFailure(failure: FlowFailure): number {
53
54
  * @param output - Handler output (`undefined` → 204)
54
55
  */
55
56
  export function encodeSuccess(output: unknown): Response {
56
- if (isJsonStreamResult(output)) {
57
+ const json = loadFxJson();
58
+ if (json.isJsonStreamResult(output)) {
57
59
  return encodeSseStream(output);
58
60
  }
59
- if (isJsonResult(output)) {
61
+ if (json.isJsonResult(output)) {
60
62
  if (output.status === 204) {
61
63
  return new Response(null, { status: 204 });
62
64
  }
@@ -81,23 +83,46 @@ export function encodeFailure(failure: FlowFailure): Response {
81
83
  });
82
84
  }
83
85
 
86
+ function loadFxLiveStream(): typeof import("../kernel/fx-live-stream.ts") {
87
+ return lazyRequire(`${import.meta.dir}/../kernel`, ["fx", "live", "stream"].join("-"));
88
+ }
89
+
90
+ function loadFxJson(): {
91
+ isJsonResult: (value: unknown) => value is JsonResult;
92
+ isJsonStreamResult: (value: unknown) => value is JsonStreamResult;
93
+ isSseFrame: (value: unknown) => value is SseFrame;
94
+ } {
95
+ return lazyRequire(`${import.meta.dir}/../kernel`, ["fx", "runtime"].join("-"));
96
+ }
97
+
84
98
  /**
85
99
  * Encode an execute-style result (response / failure / output).
86
100
  *
101
+ * Awaits {@link JsonStreamResult.ready} before the 200 SSE body so OKE1014
102
+ * can return 410 instead of a half-open stream.
103
+ *
87
104
  * @param result - Pipeline outcome pieces
88
105
  */
89
- export function encodeExecuteResult(result: {
106
+ export async function encodeExecuteResult(result: {
90
107
  readonly response?: Response | undefined;
91
108
  readonly failure?: FlowFailure | undefined;
92
109
  readonly output?: unknown;
93
110
  readonly error?: unknown;
94
- }): Response {
111
+ }): Promise<Response> {
95
112
  if (result.response) return result.response;
96
113
  if (result.failure) return encodeFailure(result.failure);
97
114
  if (result.error !== undefined) {
98
115
  if (isFlowFailure(result.error)) {
99
116
  return encodeFailure(result.error);
100
117
  }
118
+ const gap =
119
+ result.error !== null &&
120
+ typeof result.error === "object" &&
121
+ "code" in result.error &&
122
+ (result.error as { code: unknown }).code === 1014
123
+ ? loadFxLiveStream().encodeGap(result.error)
124
+ : undefined;
125
+ if (gap) return gap;
101
126
  // Unhandled throws must never look like success (`undefined` → 204).
102
127
  return Response.json(
103
128
  {
@@ -111,14 +136,13 @@ export function encodeExecuteResult(result: {
111
136
  { status: 500 },
112
137
  );
113
138
  }
139
+ if (loadFxJson().isJsonStreamResult(result.output) && result.output.ready) {
140
+ const early = await loadFxLiveStream().awaitLiveReady(result.output);
141
+ if (early) return early;
142
+ }
114
143
  return encodeSuccess(result.output);
115
144
  }
116
145
 
117
- /**
118
- * Encode {@link JsonStreamResult} as SSE (`text/event-stream`).
119
- *
120
- * @param carrier - Stream carrier from `fx.json.stream`
121
- */
122
146
  function encodeSseStream(carrier: JsonStreamResult): Response {
123
147
  const encoder = new TextEncoder();
124
148
  let finalized = false;
@@ -131,7 +155,13 @@ function encodeSseStream(carrier: JsonStreamResult): Response {
131
155
  async start(controller) {
132
156
  try {
133
157
  for await (const chunk of carrier.chunks) {
134
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
158
+ const frame = loadFxJson().isSseFrame(chunk)
159
+ ? chunk
160
+ : { data: chunk as unknown, id: undefined as string | undefined };
161
+ const lines: string[] = [];
162
+ if (frame.id !== undefined) lines.push(`id: ${frame.id}`);
163
+ lines.push(`data: ${JSON.stringify(frame.data)}`);
164
+ controller.enqueue(encoder.encode(`${lines.join("\n")}\n\n`));
135
165
  }
136
166
  controller.enqueue(encoder.encode("data: [DONE]\n\n"));
137
167
  controller.close();