okengine 0.13.0 → 0.15.2

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 (277) hide show
  1. package/manifest.v1.schema.json +7 -1
  2. package/package.json +4 -5
  3. package/site/content/docs/ai/mcp.mdx +27 -2
  4. package/site/content/docs/elements/ai.mdx +59 -11
  5. package/site/content/docs/elements/channel.mdx +1 -0
  6. package/site/content/docs/elements/clock.mdx +30 -12
  7. package/site/content/docs/elements/flow.mdx +9 -3
  8. package/site/content/docs/elements/gate.mdx +25 -22
  9. package/site/content/docs/elements/signal.mdx +71 -25
  10. package/site/content/docs/elements/store.mdx +87 -28
  11. package/site/content/docs/elements/vault.mdx +1 -0
  12. package/site/content/docs/get-started/basic-usage.mdx +22 -13
  13. package/site/content/docs/get-started/index.mdx +5 -0
  14. package/site/content/docs/get-started/installation.mdx +2 -2
  15. package/site/content/docs/get-started/introduction.mdx +11 -3
  16. package/site/content/docs/get-started/meta.json +1 -1
  17. package/site/content/docs/get-started/project-structure.mdx +932 -0
  18. package/site/content/docs/reference/cli.md +13 -10
  19. package/site/content/docs/reference/client.mdx +25 -21
  20. package/site/content/docs/reference/fx.mdx +27 -22
  21. package/site/content/docs/reference/security.md +1 -1
  22. package/src/auth/bindings.ts +14 -5
  23. package/src/auth/cross-plane.ts +10 -3
  24. package/src/cli/ai-setup/ai-setup.test.ts +62 -0
  25. package/src/cli/ai-setup/apply.ts +32 -1
  26. package/src/cli/ai-setup/recommend.test.ts +25 -0
  27. package/src/cli/ai-setup/recommend.ts +8 -3
  28. package/src/cli/attach-host-console.test.ts +3 -2
  29. package/src/cli/attach-host-console.ts +3 -0
  30. package/src/cli/build.test.ts +4 -2
  31. package/src/cli/db-seed.ts +48 -15
  32. package/src/cli/db.test.ts +104 -0
  33. package/src/cli/db.ts +122 -10
  34. package/src/cli/dev-controls.test.ts +20 -0
  35. package/src/cli/dev-controls.ts +13 -0
  36. package/src/cli/dev.test.ts +15 -4
  37. package/src/cli/dev.ts +102 -29
  38. package/src/cli/ensure-drizzle-config.ts +2 -1
  39. package/src/cli/tui/DevLive.tsx +12 -2
  40. package/src/cli/tui/keys.test.ts +2 -1
  41. package/src/cli/tui/keys.ts +3 -2
  42. package/src/compiler/aot.test.ts +1 -1
  43. package/src/compiler/effects-infer.ts +58 -3
  44. package/src/compiler/extract.test.ts +458 -29
  45. package/src/compiler/extract.ts +387 -69
  46. package/src/compiler/flow-path.test.ts +96 -0
  47. package/src/compiler/flow-path.ts +173 -0
  48. package/src/compiler/generate-adopt.test.ts +112 -3
  49. package/src/compiler/generate-adopt.ts +280 -26
  50. package/src/compiler/response.ts +45 -1
  51. package/src/compiler/schema-from-ast.ts +609 -0
  52. package/src/console/server/ai.ts +5 -2
  53. package/src/console/server/bind.ts +2 -2
  54. package/src/console/server/console.test.ts +3 -0
  55. package/src/console/server/flows.ts +121 -40
  56. package/src/console/server/invoke-user-flow.test.ts +3 -1
  57. package/src/console/server/invoke-user-flow.ts +17 -2
  58. package/src/console/server/iso-at.test.ts +9 -0
  59. package/src/console/server/iso-at.ts +11 -0
  60. package/src/console/server/security-headers.ts +6 -3
  61. package/src/console/server/serve.ts +17 -2
  62. package/src/console/server/spa-proxy.test.ts +1 -0
  63. package/src/console/server/sql-catalog.ts +147 -7
  64. package/src/console/server/sql-rls.test.ts +16 -0
  65. package/src/console/server/state.ts +8 -0
  66. package/src/console/server/store.test.ts +7 -4
  67. package/src/console/server/store.ts +142 -58
  68. package/src/console/ui-next/dist/assets/agent-disclosure-C0X1fbWF.js +1 -0
  69. package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +1 -0
  70. package/src/console/ui-next/dist/assets/{call-pii-button-bqkxMrJH.js → call-pii-button-DEDCl_j3.js} +1 -1
  71. package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +1 -0
  72. package/src/console/ui-next/dist/assets/{confirm-sheet-DDCRmG62.js → confirm-sheet-3ptDzXbz.js} +1 -1
  73. package/src/console/ui-next/dist/assets/{duration-tone-oiRxPVsZ.js → duration-tone-sC3lGABz.js} +1 -1
  74. package/src/console/ui-next/dist/assets/{explorer-empty-HjTnVQoR.js → explorer-empty-BvYrygyO.js} +1 -1
  75. package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +1 -0
  76. package/src/console/ui-next/dist/assets/{highlighted-json-CvDPvveV.js → highlighted-json-CS_O8L-r.js} +1 -1
  77. package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +1 -0
  78. package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +66 -0
  79. package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +2 -0
  80. package/src/console/ui-next/dist/assets/{link-DF7SZ9Ek.js → link-COeyggt-.js} +1 -1
  81. package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +4 -0
  82. package/src/console/ui-next/dist/assets/react-D8E3mtu1.js +1 -0
  83. package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +18 -0
  84. package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +1 -0
  85. package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +1 -0
  86. package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +1 -0
  87. package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +41 -0
  88. package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +2 -0
  89. package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +54 -0
  90. package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +1 -0
  91. package/src/console/ui-next/dist/assets/{use-vault-list-Cl79j_ku.js → use-vault-list-BLbZhzyF.js} +1 -1
  92. package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +2 -0
  93. package/src/console/ui-next/dist/index.html +6 -6
  94. package/src/console/ui-next/seed-invoke-host.ts +2 -0
  95. package/src/console/ui-next/src/client.ts +35 -7
  96. package/src/console/ui-next/src/components/motion/table/index.tsx +16 -0
  97. package/src/console/ui-next/src/components/motion/table/types.ts +4 -0
  98. package/src/console/ui-next/src/components/ui/kbd.tsx +2 -2
  99. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
  100. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
  101. package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
  102. package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
  103. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
  104. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  105. package/src/console/ui-next/src/features/flows/traces/http-method.ts +19 -0
  106. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
  107. package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +29 -2
  108. package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +10 -3
  109. package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
  110. package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
  111. package/src/console/ui-next/src/features/store/detail/browse-section.tsx +45 -1
  112. package/src/console/ui-next/src/features/store/detail/resource-panel.tsx +78 -16
  113. package/src/console/ui-next/src/features/store/detail/store-row-detail-sheet.tsx +155 -14
  114. package/src/console/ui-next/src/features/store/explorer/store-tree.tsx +33 -133
  115. package/src/console/ui-next/src/features/store/files/file-preview.tsx +4 -1
  116. package/src/console/ui-next/src/features/store/grid/rls-policy-sheet.tsx +326 -165
  117. package/src/console/ui-next/src/features/store/grid/sql-insert-sheet.tsx +6 -0
  118. package/src/console/ui-next/src/features/store/grid/store-data-grid.tsx +50 -3
  119. package/src/console/ui-next/src/features/store/lib/files-meta.test.ts +3 -0
  120. package/src/console/ui-next/src/features/store/lib/files-meta.ts +12 -0
  121. package/src/console/ui-next/src/features/store/lib/query-defaults.test.ts +25 -0
  122. package/src/console/ui-next/src/features/store/lib/query-defaults.ts +85 -3
  123. package/src/console/ui-next/src/features/store/lib/query-gate.ts +30 -4
  124. package/src/console/ui-next/src/features/store/lib/rls-policy.test.ts +139 -0
  125. package/src/console/ui-next/src/features/store/lib/rls-policy.ts +223 -21
  126. package/src/console/ui-next/src/features/store/lib/schema-graph.ts +4 -0
  127. package/src/console/ui-next/src/features/store/lib/sql-catalog.test.ts +24 -0
  128. package/src/console/ui-next/src/features/store/lib/sql-catalog.ts +19 -0
  129. package/src/console/ui-next/src/features/store/lib/store-tree.test.ts +6 -0
  130. package/src/console/ui-next/src/features/store/lib/store-tree.ts +13 -0
  131. package/src/console/ui-next/src/features/store/query/query-console.tsx +48 -14
  132. package/src/console/ui-next/src/features/store/query/query-gate-menu.tsx +19 -20
  133. package/src/console/ui-next/src/features/store/query/query-gate-parts.tsx +2 -1
  134. package/src/console/ui-next/src/features/store/query/query-results.tsx +110 -83
  135. package/src/console/ui-next/src/features/store/schema/schema-table-node.tsx +17 -0
  136. package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +112 -72
  137. package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
  138. package/src/console/ui-next/src/features/units/lib/contract-input.test.ts +206 -1
  139. package/src/console/ui-next/src/features/units/lib/contract-input.ts +55 -0
  140. package/src/console/ui-next/src/features/vault/lib/types.ts +1 -1
  141. package/src/docker/compose-health.test.ts +27 -0
  142. package/src/docker/compose-health.ts +19 -3
  143. package/src/docker/docker.test.ts +4 -1
  144. package/src/docker/dockerfile.ts +1 -1
  145. package/src/docker/recipes/pgdog.ts +2 -0
  146. package/src/drivers/ai-anthropic.ts +5 -0
  147. package/src/drivers/ai-ollama.ts +49 -30
  148. package/src/drivers/ai-openai-compatible.ts +57 -46
  149. package/src/drivers/ai-providers.test.ts +3 -0
  150. package/src/drivers/bun-native-completeness.test.ts +7 -9
  151. package/src/drivers/clock-postgres.ts +13 -9
  152. package/src/drivers/instances-postgres.ts +4 -6
  153. package/src/drivers/journal-postgres.ts +13 -9
  154. package/src/drivers/pg-rls.test.ts +87 -0
  155. package/src/drivers/pg-rls.ts +233 -7
  156. package/src/drivers/pglite.ts +26 -3
  157. package/src/drivers/postgres.test.ts +23 -0
  158. package/src/drivers/postgres.ts +133 -9
  159. package/src/drivers/redis.ts +11 -4
  160. package/src/drivers/signal-redis.ts +24 -14
  161. package/src/drivers/signal-types.ts +2 -1
  162. package/src/drivers/types.ts +10 -0
  163. package/src/elements/ai/declare.ts +109 -0
  164. package/src/elements/ai/errors.test.ts +5 -1
  165. package/src/elements/ai/errors.ts +30 -2
  166. package/src/elements/ai/eval.ts +4 -6
  167. package/src/elements/ai/mcp-client.test.ts +206 -0
  168. package/src/elements/ai/mcp-client.ts +362 -0
  169. package/src/elements/ai/mcp-http.ts +159 -0
  170. package/src/elements/ai/mcp-mock.ts +134 -0
  171. package/src/elements/ai/mcp-protocol.ts +234 -0
  172. package/src/elements/ai/mcp-stdio.test.ts +50 -0
  173. package/src/elements/ai/mcp-stdio.ts +212 -0
  174. package/src/elements/ai/mcp-transport.ts +70 -0
  175. package/src/elements/ai/runtime.ts +159 -29
  176. package/src/elements/ai.test.ts +139 -0
  177. package/src/elements/ai.ts +16 -0
  178. package/src/elements/clock/declare.ts +26 -1
  179. package/src/elements/clock/health.test.ts +43 -0
  180. package/src/elements/clock/runtime.ts +55 -2
  181. package/src/elements/clock/schedule.ts +71 -142
  182. package/src/elements/clock.test.ts +9 -40
  183. package/src/elements/clock.ts +6 -1
  184. package/src/elements/gate/boot.ts +5 -5
  185. package/src/elements/gate/declare.ts +36 -4
  186. package/src/elements/gate/flatten.ts +1 -1
  187. package/src/elements/gate/runtime.ts +1 -1
  188. package/src/elements/index.ts +2 -0
  189. package/src/elements/signal/declare.ts +2 -1
  190. package/src/elements/signal/runtime.ts +16 -1
  191. package/src/elements/signal.ts +1 -0
  192. package/src/elements/store/cache.test.ts +2 -0
  193. package/src/elements/store/cache.ts +3 -3
  194. package/src/elements/store/declare.ts +9 -6
  195. package/src/elements/store/emit-drizzle.ts +44 -8
  196. package/src/elements/store/resource.test.ts +1 -1
  197. package/src/elements/store/rls-identity.test.ts +95 -0
  198. package/src/elements/store/rls-identity.ts +116 -0
  199. package/src/elements/store/runtime.ts +4 -0
  200. package/src/elements/store/schema-decl.test.ts +37 -0
  201. package/src/elements/store/schema-decl.ts +161 -0
  202. package/src/elements/store/seed.ts +1 -1
  203. package/src/elements/store/sql-condition.test.ts +12 -0
  204. package/src/elements/store/sql-condition.ts +26 -2
  205. package/src/elements/store/sql-rls-isolation.test.ts +165 -0
  206. package/src/elements/store/sql-rls-stamp.test.ts +117 -0
  207. package/src/elements/store/sql-session.ts +81 -3
  208. package/src/elements/store/upsert-app.test.ts +1 -1
  209. package/src/elements/store.ts +14 -0
  210. package/src/elements/vault/builtin-adapter.ts +17 -0
  211. package/src/full.ts +2 -0
  212. package/src/http.ts +20 -3
  213. package/src/index.ts +14 -1
  214. package/src/kernel/adopt-barrel-fresh.test.ts +1 -1
  215. package/src/kernel/adopt-routes.ts +30 -2
  216. package/src/kernel/app.ts +227 -77
  217. package/src/kernel/auto-cache.test.ts +8 -8
  218. package/src/kernel/auto-registry.test.ts +54 -9
  219. package/src/kernel/boot-bind/ai.ts +24 -0
  220. package/src/kernel/boot-bind/clock.ts +2 -0
  221. package/src/kernel/boot.test.ts +4 -4
  222. package/src/kernel/boot.ts +5 -3
  223. package/src/kernel/effects-stamping.test.ts +3 -3
  224. package/src/kernel/element-registries.ts +15 -5
  225. package/src/kernel/errors.ts +24 -0
  226. package/src/kernel/flow-units.ts +64 -0
  227. package/src/kernel/flow.ts +29 -17
  228. package/src/kernel/fx-dead-letters.test.ts +77 -0
  229. package/src/kernel/fx.test.ts +22 -0
  230. package/src/kernel/fx.ts +135 -6
  231. package/src/kernel/horizontal-child.ts +4 -4
  232. package/src/kernel/http-path-pending.ts +20 -0
  233. package/src/kernel/http-resource.ts +4 -0
  234. package/src/kernel/http-stream.test.ts +174 -0
  235. package/src/kernel/index.ts +2 -0
  236. package/src/kernel/pipeline.test.ts +2 -2
  237. package/src/kernel/plugin-elements.test.ts +1 -1
  238. package/src/kernel/plugin-needs.test.ts +1 -1
  239. package/src/kernel/ready.test.ts +2 -2
  240. package/src/kernel/resource-mount.test.ts +14 -0
  241. package/src/kernel/run-duration.test.ts +2 -2
  242. package/src/kernel/stamp-http.test.ts +79 -0
  243. package/src/kernel/stamp-http.ts +75 -0
  244. package/src/kernel/triggers.ts +52 -27
  245. package/src/manifest/mcp-ref.ts +88 -0
  246. package/src/manifest/types.ts +41 -2
  247. package/src/manifest/validate.test.ts +20 -0
  248. package/src/mcp/docs-server.ts +1 -1
  249. package/src/mcp/server.ts +1 -1
  250. package/src/plugins/compression.test.ts +21 -0
  251. package/src/plugins/compression.ts +1 -0
  252. package/src/release/measure.ts +2 -2
  253. package/src/runtime/bun.ts +41 -4
  254. package/src/runtime/json-code-block.test.ts +2 -2
  255. package/src/runtime/serve.test.ts +4 -4
  256. package/src/term.test.ts +52 -4
  257. package/src/term.ts +125 -17
  258. package/src/test/reset-element-registries.ts +10 -3
  259. package/src/cli/ask-seed.test.ts +0 -96
  260. package/src/cli/ask-seed.ts +0 -82
  261. package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
  262. package/src/console/ui-next/dist/assets/collapsible-DYb0xU8C.js +0 -1
  263. package/src/console/ui-next/dist/assets/flows-page-Dg8CTE29.js +0 -1
  264. package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
  265. package/src/console/ui-next/dist/assets/index-Bp-R7jtM.js +0 -66
  266. package/src/console/ui-next/dist/assets/index-D4Ldtj79.css +0 -2
  267. package/src/console/ui-next/dist/assets/observability-page-DAnpEaq1.js +0 -4
  268. package/src/console/ui-next/dist/assets/query-gate-parts-1m8m1iNp.js +0 -2
  269. package/src/console/ui-next/dist/assets/react-B1ML8gxg.js +0 -1
  270. package/src/console/ui-next/dist/assets/replica-lag-C8_BRt2x.js +0 -18
  271. package/src/console/ui-next/dist/assets/sheet-form-D-ata7jy.js +0 -1
  272. package/src/console/ui-next/dist/assets/shortcut-keys-DO4IsVqv.js +0 -1
  273. package/src/console/ui-next/dist/assets/skeleton-CL_X0GCj.js +0 -1
  274. package/src/console/ui-next/dist/assets/store-page-v3LXdYpr.js +0 -45
  275. package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
  276. package/src/console/ui-next/dist/assets/units-page-BtQ0bqMe.js +0 -1
  277. package/src/console/ui-next/dist/assets/vault-page-Ca-MvcmJ.js +0 -2
package/src/kernel/fx.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  * for deterministic tests (§7.6).
10
10
  */
11
11
 
12
- import type { Effects, ResourceRef } from "../manifest/types.ts";
12
+ import type { Effects, ResourceRef, SignalResourceRef } from "../manifest/types.ts";
13
+ import { isMcpToolRef } from "../manifest/mcp-ref.ts";
13
14
  import type { QueryPageSpec } from "./list-page.ts";
14
15
  import { schemaTableName, sqlTableRef } from "../manifest/sql-resource.ts";
15
16
  import type {
@@ -26,9 +27,11 @@ import type {
26
27
  StoreRuntime,
27
28
  SqlStoreHandle,
28
29
  } from "../elements/store.ts";
30
+ import { rlsIdentityFromAuth } from "../elements/store.ts";
31
+ import type { RlsIdentity } from "../drivers/pg-rls.ts";
29
32
  import type { SqlRow } from "../drivers/types.ts";
30
- import type { SignalRuntime } from "../elements/signal.ts";
31
- import type { SignalEmitOptions } from "../drivers/signal-types.ts";
33
+ import type { SignalDecl, SignalRuntime } from "../elements/signal.ts";
34
+ import type { DeadLetter, SignalEmitOptions } from "../drivers/signal-types.ts";
32
35
  import type { VaultActor, VaultAdapter, VaultRuntime } from "../elements/vault.ts";
33
36
  import type { ChannelRuntime } from "../elements/channel.ts";
34
37
  import type { AiRuntime } from "../elements/ai.ts";
@@ -73,6 +76,15 @@ async function loadRunsWindow(): Promise<typeof import("../runs/window.ts")> {
73
76
  /** Resource ref Flows declare to read the Runs store via {@link Fx.runs}. */
74
77
  export const RUNS_RESOURCE = "runs";
75
78
 
79
+ /**
80
+ * Capability ref for {@link Fx.deadLetters} — `signal:<name>`, never a store facet.
81
+ *
82
+ * @param name - Signal name
83
+ */
84
+ export function signalReadRef(name: string): SignalResourceRef {
85
+ return `signal:${name}`;
86
+ }
87
+
76
88
  export type { FxRetryOptions, FxThunk } from "./concurrency.ts";
77
89
 
78
90
  /** Named ref: plain string or `{ name }` element handle (optional pin). */
@@ -140,6 +152,27 @@ export interface FxTenant {
140
152
  export interface FxClock {
141
153
  /** Current epoch-ms (injectable via {@link CreateFxOptions.now}). */
142
154
  now(): number;
155
+ /**
156
+ * Instant `duration` before {@link FxClock.now} (`"30d"` → now − 30 days).
157
+ * Uses the same duration strings as {@link FxClock.sleep} / `every()`.
158
+ *
159
+ * @param duration - Duration string (e.g. `"30d"`, `"2h"`)
160
+ */
161
+ ago(duration: string): number;
162
+ /**
163
+ * Instant `duration` after {@link FxClock.now} (`"14d"` → now + 14 days).
164
+ * Uses the same duration strings as {@link FxClock.sleep} / `every()`.
165
+ *
166
+ * @param duration - Duration string (e.g. `"14d"`, `"15m"`)
167
+ */
168
+ fromNow(duration: string): number;
169
+ /**
170
+ * Span in milliseconds (`"30d"` → `2_592_000_000`). Compose with a stored
171
+ * instant: `createdAt + fx.clock.duration("7d")`. Unknown strings are `0`.
172
+ *
173
+ * @param duration - Duration string (e.g. `"30d"`, `"200ms"`)
174
+ */
175
+ duration(duration: string): number;
143
176
  /**
144
177
  * Durable sleep — when the flow is `durable`, journals the wake time and
145
178
  * survives restart / deploy. Without a journal, resolves immediately
@@ -323,12 +356,36 @@ export interface JsonResult<T = unknown> {
323
356
  readonly status: number;
324
357
  readonly value?: T;
325
358
  readonly meta?: Record<string, unknown>;
359
+ readonly kind?: undefined;
360
+ }
361
+
362
+ /** SSE carrier from {@link FxJson.stream}. */
363
+ export interface JsonStreamResult {
364
+ readonly [jsonResultBrand]: true;
365
+ readonly kind: "stream";
366
+ readonly status: 200;
367
+ readonly chunks: AsyncIterable<string>;
368
+ /** Set by the kernel to commit journal / Runs after the stream settles. */
369
+ finalize?: () => Promise<void>;
326
370
  }
327
371
 
328
- /** True when `value` is an {@link FxJson} carrier. */
372
+ /** True when `value` is an {@link FxJson} JSON-envelope carrier. */
329
373
  export function isJsonResult(value: unknown): value is JsonResult {
330
374
  return (
331
- typeof value === "object" && value !== null && (value as JsonResult)[jsonResultBrand] === true
375
+ typeof value === "object" &&
376
+ value !== null &&
377
+ (value as JsonResult)[jsonResultBrand] === true &&
378
+ (value as JsonStreamResult).kind !== "stream"
379
+ );
380
+ }
381
+
382
+ /** True when `value` is an SSE stream carrier from {@link FxJson.stream}. */
383
+ export function isJsonStreamResult(value: unknown): value is JsonStreamResult {
384
+ return (
385
+ typeof value === "object" &&
386
+ value !== null &&
387
+ (value as JsonStreamResult)[jsonResultBrand] === true &&
388
+ (value as JsonStreamResult).kind === "stream"
332
389
  );
333
390
  }
334
391
 
@@ -363,6 +420,11 @@ export interface FxJson {
363
420
  * every string field; extra keys auto-eq except `id`.
364
421
  */
365
422
  withQuery<T>(rows: readonly T[], input: unknown, spec?: QueryPageSpec<T>): JsonResult<T[]>;
423
+ /**
424
+ * 200 — `text/event-stream` of JSON `data:` frames, then `data: [DONE]`.
425
+ * Pass {@link Fx.stream} or any async iterable of token strings.
426
+ */
427
+ stream(chunks: AsyncIterable<string>): JsonStreamResult;
366
428
  }
367
429
 
368
430
  /**
@@ -567,6 +629,15 @@ export interface Fx {
567
629
  * @param options - Optional emit options (`key` for per-key once ordering)
568
630
  */
569
631
  emit(signal: NamedRef, payload?: unknown, options?: SignalEmitOptions): Promise<void>;
632
+ /**
633
+ * Query dead-lettered messages for one signal (records `read` on `signal:<name>`).
634
+ *
635
+ * Requires a bound signal runtime and `effects.reads` including `signal:<name>`.
636
+ *
637
+ * @param signal - Signal name or handle
638
+ */
639
+ deadLetters<T>(signal: SignalDecl<T>): Promise<readonly DeadLetter<T>[]>;
640
+ deadLetters(signal: NamedRef): Promise<readonly DeadLetter[]>;
570
641
  /**
571
642
  * Call another flow (records `call`). Stub returns `undefined`.
572
643
  *
@@ -651,7 +722,11 @@ export interface Fx {
651
722
  */
652
723
  stream(
653
724
  model: NamedRef,
654
- opts?: { readonly prompt?: string; readonly data?: unknown },
725
+ opts?: {
726
+ readonly prompt?: string;
727
+ readonly data?: unknown;
728
+ readonly via?: readonly NamedRef[];
729
+ },
655
730
  ): AsyncIterable<string>;
656
731
  /** Logger. */
657
732
  readonly log: FxLog;
@@ -838,6 +913,12 @@ export interface CreateFxOptions {
838
913
  readonly runId?: string;
839
914
  /** Reveal PII through the store runtime (requires `pii:reveal` upstream). */
840
915
  readonly revealPii?: boolean;
916
+ /** Trigger gate names for RLS (`oke.gate` = first policy/public). */
917
+ readonly rlsGateNames?: readonly string[];
918
+ /** Skip RLS stamp (Operator / `bypassGates` / operator-plane flows). */
919
+ readonly rlsBypass?: boolean;
920
+ /** Forced bag (Console / Call API). Wins over {@link rlsGateNames}. */
921
+ readonly rls?: import("../drivers/pg-rls.ts").RlsIdentity;
841
922
  /**
842
923
  * Active journal session when the flow is durable. Every `fx` call is
843
924
  * recorded; `step` / `sleep` replay from the journal on resume.
@@ -952,6 +1033,19 @@ export function createFxContext(options: CreateFxOptions): FxContext {
952
1033
  } satisfies FxPrincipal);
953
1034
  const journal = options.journal;
954
1035
 
1036
+ function rlsInvokeContext(): { rls?: RlsIdentity } {
1037
+ if (options.rlsBypass === true) return {};
1038
+ if (options.rls) return { rls: options.rls };
1039
+ const identity = rlsIdentityFromAuth({
1040
+ userId: auth.userId,
1041
+ scopes: auth.scopes,
1042
+ gateNames: options.rlsGateNames ?? [],
1043
+ bypass: false,
1044
+ operator: false,
1045
+ });
1046
+ return identity ? { rls: identity } : {};
1047
+ }
1048
+
955
1049
  async function gated<T>(
956
1050
  kind: Parameters<CapabilityToken["assert"]>[0],
957
1051
  resource: string,
@@ -1269,6 +1363,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1269
1363
  cache.handle = await runtime.open(decl, {
1270
1364
  effects: options.effects ?? {},
1271
1365
  revealPii: options.revealPii,
1366
+ ...rlsInvokeContext(),
1272
1367
  });
1273
1368
  }
1274
1369
  return cache.handle;
@@ -1288,6 +1383,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1288
1383
  {
1289
1384
  effects: options.effects ?? {},
1290
1385
  revealPii: options.revealPii,
1386
+ ...rlsInvokeContext(),
1291
1387
  },
1292
1388
  {
1293
1389
  gate: gated,
@@ -1336,6 +1432,15 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1336
1432
 
1337
1433
  const clock: FxClock = {
1338
1434
  now,
1435
+ ago(duration: string): number {
1436
+ return now() - parseDurationMs(duration);
1437
+ },
1438
+ fromNow(duration: string): number {
1439
+ return now() + parseDurationMs(duration);
1440
+ },
1441
+ duration(duration: string): number {
1442
+ return parseDurationMs(duration);
1443
+ },
1339
1444
  async sleep(label: string, duration: string): Promise<void> {
1340
1445
  if (journal) {
1341
1446
  await journal.sleep(label, duration, parseDurationMs);
@@ -1591,9 +1696,24 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1591
1696
  }
1592
1697
  });
1593
1698
  },
1699
+ deadLetters(signal: NamedRef) {
1700
+ const name = resolveName(signal);
1701
+ return gated("read", signalReadRef(name), async () => {
1702
+ if (!options.signalRuntime) {
1703
+ throw new Error("fx.deadLetters requires a bound signal runtime");
1704
+ }
1705
+ return options.signalRuntime.deadLetters(name);
1706
+ });
1707
+ },
1594
1708
  call(flow, input) {
1595
1709
  const name = resolveName(flow);
1596
1710
  return gated("call", name, async () => {
1711
+ if (isMcpToolRef(name)) {
1712
+ if (!options.aiRuntime) {
1713
+ throw new Error(`fx.call: AI runtime is not configured for MCP tool "${name}"`);
1714
+ }
1715
+ return options.aiRuntime.callMcp(name, input, currentAbortSignal());
1716
+ }
1597
1717
  if (options.callHandler) {
1598
1718
  return options.callHandler(name, input);
1599
1719
  }
@@ -1785,6 +1905,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1785
1905
  prompt: opts?.prompt,
1786
1906
  data: opts?.data,
1787
1907
  signal: local.signal,
1908
+ via: opts?.via?.map(resolveName),
1788
1909
  })) {
1789
1910
  if (local.signal.aborted) break;
1790
1911
  yield text;
@@ -1832,6 +1953,14 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1832
1953
  },
1833
1954
  with: jsonWith,
1834
1955
  withQuery: jsonWithQuery,
1956
+ stream(chunks) {
1957
+ return {
1958
+ [jsonResultBrand]: true,
1959
+ kind: "stream" as const,
1960
+ status: 200 as const,
1961
+ chunks,
1962
+ };
1963
+ },
1835
1964
  },
1836
1965
  async step<T>(name: string, fn: () => T | Promise<T>, opts?: StepOptions<T>): Promise<T> {
1837
1966
  if (journal) {
@@ -190,11 +190,11 @@ const charge = flow("horizontal.charge", {
190
190
  });
191
191
 
192
192
  const bindings: Binding[] = [
193
- { trigger: http.get("/ping").gate.public, flow: ping as AnyFlowDef },
193
+ { trigger: http.get("/ping").public(), flow: ping as AnyFlowDef },
194
194
  { trigger: http.get("/rate").gate(rateGate), flow: rate as AnyFlowDef },
195
- { trigger: http.post("/write").gate.public, flow: write as AnyFlowDef },
196
- { trigger: http.post("/emit").gate.public, flow: emit as AnyFlowDef },
197
- { trigger: http.post("/charge").gate.public, flow: charge as AnyFlowDef },
195
+ { trigger: http.post("/write").public(), flow: write as AnyFlowDef },
196
+ { trigger: http.post("/emit").public(), flow: emit as AnyFlowDef },
197
+ { trigger: http.post("/charge").public(), flow: charge as AnyFlowDef },
198
198
  ];
199
199
 
200
200
  const app = oke({
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Branded sentinel for `http.get()` with no path — never `"/"`.
3
+ * Lives in a leaf module so `oke()` can refuse leftovers without pulling
4
+ * the HTTP trigger constructors onto a Store-only graph.
5
+ */
6
+
7
+ /** Unresolved path token. {@link stampHttpPath} replaces it from the file tree. */
8
+ export const HTTP_PATH_PENDING = "__oke_http_path_pending__" as const;
9
+
10
+ /** Type of {@link HTTP_PATH_PENDING}. */
11
+ export type HttpPathPending = typeof HTTP_PATH_PENDING;
12
+
13
+ /**
14
+ * True when an HTTP trigger still carries the unresolved path sentinel.
15
+ *
16
+ * @param path - Trigger path
17
+ */
18
+ export function isPendingHttpPath(path: string): boolean {
19
+ return path === HTTP_PATH_PENDING;
20
+ }
@@ -3,6 +3,7 @@
3
3
  * apps do not pay for the five-verb CRUD helper.
4
4
  */
5
5
 
6
+ import { GATE_PUBLIC_NAME } from "../elements/gate/flatten.ts";
6
7
  import {
7
8
  createGateAttach,
8
9
  createHttpTrigger,
@@ -47,6 +48,9 @@ export function httpResource<P extends string>(
47
48
  { trigger: verb("DELETE", id, false), flow: ops.remove },
48
49
  ],
49
50
  gate: createGateAttach((next) => httpResource(path, ops, next, isLive), gates),
51
+ public() {
52
+ return httpResource(path, ops, [...gates, GATE_PUBLIC_NAME], isLive);
53
+ },
50
54
  live() {
51
55
  return httpResource(path, ops, gates, true);
52
56
  },
@@ -0,0 +1,174 @@
1
+ /**
2
+ * HTTP SSE via `fx.json.stream` + client-disconnect abort of the provider fetch.
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
6
+ import { ai } from "../elements/ai.ts";
7
+ import type { AiDriver } from "../drivers/ai-types.ts";
8
+ import { createRunsRuntime } from "../runs/runtime.ts";
9
+ import { createBunRuntime } from "../runtime/bun.ts";
10
+ import type { ServerHandle } from "../runtime/types.ts";
11
+ import { oke } from "./app.ts";
12
+ import { flow, resetFlowSeq } from "./flow.ts";
13
+ import { on, resetBindings } from "./on.ts";
14
+ import { http } from "./triggers.ts";
15
+
16
+ beforeEach(() => {
17
+ resetBindings();
18
+ resetFlowSeq();
19
+ });
20
+
21
+ function tokenDriver(chunks: readonly string[], delayMs = 0): AiDriver {
22
+ return {
23
+ id: "mock",
24
+ async open() {
25
+ return {
26
+ driverId: "mock" as const,
27
+ model: "smart",
28
+ async complete() {
29
+ return { text: "", model: "smart", driverId: "mock" as const };
30
+ },
31
+ async *stream() {
32
+ for (const text of chunks) {
33
+ if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
34
+ yield { text };
35
+ }
36
+ },
37
+ };
38
+ },
39
+ };
40
+ }
41
+
42
+ function hangingDriver(onAbort: () => void): AiDriver {
43
+ return {
44
+ id: "mock",
45
+ async open() {
46
+ return {
47
+ driverId: "mock" as const,
48
+ model: "smart",
49
+ async complete() {
50
+ return { text: "", model: "smart", driverId: "mock" as const };
51
+ },
52
+ async *stream(opts) {
53
+ yield { text: "start" };
54
+ await new Promise<void>((resolve, reject) => {
55
+ const t = setTimeout(resolve, 8_000);
56
+ opts.signal?.addEventListener(
57
+ "abort",
58
+ () => {
59
+ onAbort();
60
+ clearTimeout(t);
61
+ const err = new Error("aborted");
62
+ err.name = "AbortError";
63
+ reject(err);
64
+ },
65
+ { once: true },
66
+ );
67
+ });
68
+ yield { text: "never" };
69
+ },
70
+ };
71
+ },
72
+ };
73
+ }
74
+
75
+ describe("fx.json.stream HTTP SSE", () => {
76
+ let handle: ServerHandle | undefined;
77
+
78
+ afterEach(() => {
79
+ handle?.stop(true);
80
+ handle = undefined;
81
+ });
82
+
83
+ test("encodes token chunks as SSE and leaves JSON flows buffered", async () => {
84
+ const smart = ai.model("smart");
85
+ on(
86
+ http.get("/chat").public(),
87
+ flow("chat.stream", {
88
+ do: (_input, fx) => fx.json.stream(fx.stream(smart, { prompt: "hi" })),
89
+ }),
90
+ );
91
+ on(http.get("/ping").public(), flow("ping", { do: () => ({ ok: true as const }) }));
92
+
93
+ const app = oke({
94
+ name: "http-stream",
95
+ ai: { models: [smart], defaultDriver: tokenDriver(["Hel", "lo"]) },
96
+ });
97
+
98
+ const streamed = await app.fetch(new Request("http://localhost/chat"));
99
+ expect(streamed.headers.get("content-type")).toMatch(/text\/event-stream/);
100
+ const body = await streamed.text();
101
+ expect(body).toContain('data: "Hel"');
102
+ expect(body).toContain('data: "lo"');
103
+ expect(body).toContain("data: [DONE]");
104
+
105
+ const json = await app.fetch(new Request("http://localhost/ping"));
106
+ expect(json.headers.get("content-type")).toMatch(/application\/json/);
107
+ expect(await json.json()).toEqual({ data: { ok: true }, error: null });
108
+ });
109
+
110
+ test("client abort mid-stream aborts the upstream provider fetch", async () => {
111
+ let providerAborted = false;
112
+ const smart = ai.model("smart");
113
+ on(
114
+ http.get("/slow").public(),
115
+ flow("chat.slow", {
116
+ do: (_input, fx) => fx.json.stream(fx.stream(smart, { prompt: "long" })),
117
+ }),
118
+ );
119
+
120
+ const app = oke({
121
+ name: "http-stream-abort",
122
+ ai: { models: [smart], defaultDriver: hangingDriver(() => (providerAborted = true)) },
123
+ });
124
+
125
+ handle = createBunRuntime().serve(app, { port: 0, hostname: "127.0.0.1" });
126
+ const ac = new AbortController();
127
+ const res = await fetch(`http://127.0.0.1:${handle.port}/slow`, { signal: ac.signal });
128
+ expect(res.headers.get("content-type")).toMatch(/text\/event-stream/);
129
+ const reader = res.body!.getReader();
130
+ const first = await reader.read();
131
+ expect(first.done).toBe(false);
132
+ ac.abort();
133
+ await new Promise((r) => setTimeout(r, 80));
134
+ expect(providerAborted).toBe(true);
135
+ });
136
+
137
+ test("Traces stay empty until stream close; duration covers the open stream", async () => {
138
+ const runs = createRunsRuntime({ driver: "memory" });
139
+ await runs.open();
140
+ const smart = ai.model("smart");
141
+ on(
142
+ http.get("/chat").public(),
143
+ flow("chat.stream", {
144
+ do: (_input, fx) => fx.json.stream(fx.stream(smart, { prompt: "hi" })),
145
+ }),
146
+ );
147
+
148
+ const app = oke({
149
+ name: "http-stream-traces",
150
+ ai: { models: [smart], defaultDriver: tokenDriver(["Hel", "lo"], 25) },
151
+ runs,
152
+ env: "test",
153
+ });
154
+ await app.boot({ env: "test", runs, startScheduler: false });
155
+
156
+ const streamed = await app.fetch(new Request("http://localhost/chat"));
157
+ expect(streamed.headers.get("content-type")).toMatch(/text\/event-stream/);
158
+ await runs.flush();
159
+ expect(await runs.all()).toHaveLength(0);
160
+
161
+ const body = await streamed.text();
162
+ expect(body).toContain("data: [DONE]");
163
+
164
+ await runs.flush();
165
+ const events = await runs.all();
166
+ expect(events).toHaveLength(1);
167
+ expect(events[0]!.error ?? null).toBeNull();
168
+ expect(events[0]!.output).toEqual({ streamed: true });
169
+ expect(events[0]!.durationMs).toBeGreaterThanOrEqual(40);
170
+
171
+ await app.stop();
172
+ await runs.close();
173
+ });
174
+ });
@@ -137,6 +137,7 @@ export {
137
137
  createFxContext,
138
138
  freezePrincipal,
139
139
  isJsonResult,
140
+ isJsonStreamResult,
140
141
  jsonResultBrand,
141
142
  resolveName,
142
143
  resolveStoreRef,
@@ -160,6 +161,7 @@ export {
160
161
  type FxThunk,
161
162
  type JsonPage,
162
163
  type JsonResult,
164
+ type JsonStreamResult,
163
165
  type NamedRef,
164
166
  type StepOptions,
165
167
  } from "./fx.ts";
@@ -65,9 +65,9 @@ describe("gateDenialFailure", () => {
65
65
  });
66
66
  });
67
67
 
68
- describe("http trigger — .gate.public", () => {
68
+ describe("http trigger — .public()", () => {
69
69
  test("attaches the public sentinel without calling gate()", () => {
70
- const trigger = http.get("/health").gate.public;
70
+ const trigger = http.get("/health").public();
71
71
  expect(trigger.gates.map((g) => (typeof g === "string" ? g : g.name))).toEqual(["public"]);
72
72
  expect(trigger.live().isLive).toBe(true);
73
73
  });
@@ -32,7 +32,7 @@ describe("plugin element contributions", () => {
32
32
  .table("otp_codes")
33
33
  .needs("store.sql");
34
34
 
35
- on(http.get("/ok").gate.public, flow("ok", { do: () => ({ ok: true }) }));
35
+ on(http.get("/ok").public(), flow("ok", { do: () => ({ ok: true }) }));
36
36
 
37
37
  const app = oke({
38
38
  name: "plugin-elements",
@@ -75,7 +75,7 @@ describe("plugin .needs() resolution", () => {
75
75
  test("boot fails when plugin needs missing peer plugin", async () => {
76
76
  resetBindings();
77
77
  resetFlowSeq();
78
- on(http.get("/x").gate.public, flow("x", { do: () => ({ ok: true }) }));
78
+ on(http.get("/x").public(), flow("x", { do: () => ({ ok: true }) }));
79
79
  const dependent = plugin("needs-auth", { version: "0.0.1" }).needs("auth");
80
80
  const app = oke({ name: "needs-boot", gate: { policies: [gate.public] } }).plug(dependent);
81
81
  await expect(app.boot({ env: "test" })).rejects.toThrow(PluginNeedsError);
@@ -38,7 +38,7 @@ describe("GET /_/ready", () => {
38
38
  });
39
39
  const bindings: Binding[] = [
40
40
  {
41
- trigger: http.post("/charge").gate.public,
41
+ trigger: http.post("/charge").public(),
42
42
  flow: charge as AnyFlowDef,
43
43
  },
44
44
  ];
@@ -82,7 +82,7 @@ describe("GET /_oke/client.json", () => {
82
82
  startScheduler: false,
83
83
  env: "test",
84
84
  gate: { unguardedHttp: "allow" },
85
- bindings: [{ trigger: http.get("/ping").gate.public, flow: ping as AnyFlowDef }],
85
+ bindings: [{ trigger: http.get("/ping").public(), flow: ping as AnyFlowDef }],
86
86
  });
87
87
  await app.boot();
88
88
  const res = await app.fetch(new Request("http://127.0.0.1/_oke/client.json"));
@@ -63,6 +63,20 @@ describe("http.resource — gate / live", () => {
63
63
  expect(remove?.isLive).toBe(false);
64
64
  });
65
65
 
66
+ test(".public().live() stamps the sentinel on every verb", () => {
67
+ resetBindings();
68
+ resetFlowSeq();
69
+ on(http.resource("/notes", bag()).public().live());
70
+ expect(httpOf("/notes", "GET")?.gates.map((g) => (typeof g === "string" ? g : g.name))).toEqual(
71
+ ["public"],
72
+ );
73
+ expect(
74
+ httpOf("/notes", "POST")?.gates.map((g) => (typeof g === "string" ? g : g.name)),
75
+ ).toEqual(["public"]);
76
+ expect(httpOf("/notes", "GET")?.isLive).toBe(true);
77
+ expect(httpOf("/notes", "POST")?.isLive).toBe(false);
78
+ });
79
+
66
80
  test(".live().gate(member) order does not matter", () => {
67
81
  resetBindings();
68
82
  resetFlowSeq();
@@ -23,7 +23,7 @@ describe("execute run duration", () => {
23
23
  await runs.open();
24
24
 
25
25
  on(
26
- http.get("/ping").gate.public,
26
+ http.get("/ping").public(),
27
27
  flow("demo.ping", {
28
28
  do: () => ({ ok: true }),
29
29
  }),
@@ -68,7 +68,7 @@ describe("execute run duration", () => {
68
68
  await runs.open();
69
69
  try {
70
70
  on(
71
- http.get("/ping").gate.public,
71
+ http.get("/ping").public(),
72
72
  flow("demo.ping", {
73
73
  do: () => ({ ok: true }),
74
74
  }),
@@ -0,0 +1,79 @@
1
+ /**
2
+ * In-place path / name stamps and fail-loud HTTP boot.
3
+ */
4
+
5
+ import { describe, expect, test, beforeEach } from "bun:test";
6
+ import type { RuntimeRouteMap } from "./adopt-routes.ts";
7
+ import { oke } from "./app.ts";
8
+ import { flow, resetFlowSeq } from "./flow.ts";
9
+ import { resetRegisteredFlowUnits, registerFlowUnits } from "./flow-units.ts";
10
+ import { listBindings, on, resetBindings } from "./on.ts";
11
+ import { stampFlowName, stampHttpPath } from "./stamp-http.ts";
12
+ import { HTTP_PATH_PENDING } from "./http-path-pending.ts";
13
+ import { http } from "./triggers.ts";
14
+
15
+ beforeEach(() => {
16
+ resetBindings();
17
+ resetFlowSeq();
18
+ resetRegisteredFlowUnits();
19
+ });
20
+
21
+ describe("stampHttpPath", () => {
22
+ test("mutates the on() binding in place — listBindings sees the stamped path", () => {
23
+ const f = on(http.get().public(), flow({ do: () => ({ ok: true }) }));
24
+ expect(f.$trigger && f.$trigger.kind === "http" && f.$trigger.path).toBe(HTTP_PATH_PENDING);
25
+ stampHttpPath(f, "/notes/:id");
26
+ expect(f.$trigger && f.$trigger.kind === "http" && f.$trigger.path).toBe("/notes/:id");
27
+ const bound = listBindings()[0];
28
+ expect(bound?.trigger.kind === "http" && bound.trigger.path).toBe("/notes/:id");
29
+ });
30
+
31
+ test("explicit http.get('/x') wins — stamp does not overwrite", () => {
32
+ const f = on(http.get("/me/tasks").public(), flow("notes.get", { do: () => 1 }));
33
+ stampHttpPath(f, "/notes/:id");
34
+ expect(f.$trigger && f.$trigger.kind === "http" && f.$trigger.path).toBe("/me/tasks");
35
+ });
36
+ });
37
+
38
+ describe("stampFlowName", () => {
39
+ test("fills a nameless flow as unit.export", () => {
40
+ const f = flow({ do: () => 1 });
41
+ expect(f.name).toBe("");
42
+ stampFlowName(f, "notes.get");
43
+ expect(f.name).toBe("notes.get");
44
+ expect(f.unit).toBe("notes");
45
+ });
46
+
47
+ test("explicit flow('notes.get') wins", () => {
48
+ const f = flow("notes.get", { do: () => 1 });
49
+ stampFlowName(f, "notes.list");
50
+ expect(f.name).toBe("notes.get");
51
+ });
52
+ });
53
+
54
+ describe("oke — unresolved sentinel and duplicate routes", () => {
55
+ test("unresolved http.get() fails construction", () => {
56
+ on(http.get().public(), flow("notes.get", { do: () => 1 }));
57
+ expect(() => oke({ name: "t", autoBoot: false })).toThrow(/OKE1010/);
58
+ });
59
+
60
+ test("duplicate GET /notes fails construction", () => {
61
+ on(http.get("/notes").public(), flow("notes.list", { do: () => 1 }));
62
+ on(http.get("/notes").public(), flow("notes.also", { do: () => 1 }));
63
+ expect(() => oke({ name: "t", autoBoot: false })).toThrow(/OKE1011/);
64
+ });
65
+
66
+ test("nameless HTTP flow fails construction", () => {
67
+ on(http.get("/notes").public(), flow({ do: () => 1 }));
68
+ expect(() => oke({ name: "t", autoBoot: false })).toThrow(/OKE1012/);
69
+ });
70
+
71
+ test("registerFlowUnits drains into $routes; registry ignore does not", () => {
72
+ const get = on(http.get("/notes/:id").public(), flow("notes.get", { do: () => 1 }));
73
+ registerFlowUnits({ notes: { get } });
74
+ const ignored = oke({ name: "t", autoBoot: false, registry: "ignore" });
75
+ expect(ignored.$routes).toEqual({});
76
+ const app = oke({ name: "t", autoBoot: false });
77
+ expect((app.$routes as RuntimeRouteMap).notes?.get?.path).toBe("/notes/:id");
78
+ });
79
+ });