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
@@ -1,17 +1,21 @@
1
1
  /**
2
- * `oke({...})` auto-populates `stores` / `secrets` / `signals` /
3
- * `channel.templates` from the same kind of module-evaluation registry
4
- * already proven for `on()`'s trigger drain (`registry-isolation.test.ts`).
2
+ * `oke({...})` auto-populates `stores` / `secrets` / `signals` / `clocks` /
3
+ * `gate.policies` / `channel.templates` from the same kind of
4
+ * module-evaluation registry already proven for `on()`'s trigger drain
5
+ * (`registry-isolation.test.ts`).
5
6
  *
6
- * `store.sql` / `store.files`, `vault.secret`, `signal()`, and medium-binder
7
- * `.template()` push into a shared registry (`src/kernel/element-registries.ts`)
8
- * at call time — zero change to how developers call them. `oke()` drains it
9
- * at construction under the same `registry: "consume" | "keep" | "ignore"`
10
- * switch that already governs bindings.
7
+ * `store.*`, `vault.secret`, `signal()`, `clock()`, `gate.policy` / `.scope`
8
+ * / `.rate`, and medium-binder `.template()` push into a shared registry
9
+ * (`src/kernel/element-registries.ts`) at call time — zero change to how
10
+ * developers call them. `oke()` drains it at construction under the same
11
+ * `registry: "consume" | "keep" | "ignore"` switch that already governs
12
+ * bindings.
11
13
  */
12
14
 
13
15
  import { describe, expect, test } from "bun:test";
14
16
  import { channel } from "../elements/channel/declare.ts";
17
+ import { clock } from "../elements/clock/declare.ts";
18
+ import { gate } from "../elements/gate/declare.ts";
15
19
  import { signal } from "../elements/signal/declare.ts";
16
20
  import { store } from "../elements/store/declare.ts";
17
21
  import { vault } from "../elements/vault/declare.ts";
@@ -20,7 +24,7 @@ import { flow } from "./flow.ts";
20
24
  import { on, resetBindings } from "./on.ts";
21
25
  import { http } from "./triggers.ts";
22
26
 
23
- describe("oke() auto-registry — stores/secrets/signals/channel.templates/ai", () => {
27
+ describe("oke() auto-registry — stores/secrets/signals/clocks/gates/channel.templates/ai", () => {
24
28
  test("before/after: zero explicit arrays boots identically to the explicit-array form", async () => {
25
29
  resetBindings();
26
30
 
@@ -161,6 +165,42 @@ describe("oke() auto-registry — stores/secrets/signals/channel.templates/ai",
161
165
  expect(app.bootResult?.vault?.contracts.has("IGNORED_SECRET")).toBeFalsy();
162
166
  });
163
167
 
168
+ test("store.kv / store.index / clock() / gate.policy auto-drain — no explicit oke() arrays", async () => {
169
+ resetBindings();
170
+ store.kv("drafts", { description: "Compose drafts" });
171
+ store.index("tasks", { description: "Task search" });
172
+ clock("daily-digest", { cron: "0 8 * * *", every: "1d", timezone: "UTC" });
173
+ const member = gate.policy("member", ({ auth }) => !!auth.verified);
174
+ gate.scope("task:write");
175
+ gate.rate({ max: 60, per: "1m", keyBy: "user" });
176
+
177
+ const ping = flow("auto.ping", {
178
+ do: () => ({ ok: true as const }),
179
+ });
180
+ on(http.get("/auto-ping").gate(member), ping);
181
+
182
+ const app = oke({
183
+ name: "auto-kv-clock-gate",
184
+ autoBoot: false,
185
+ startScheduler: false,
186
+ });
187
+
188
+ expect(app.$options.stores?.some((s) => s.ref === "kv:drafts")).toBe(true);
189
+ expect(app.$options.stores?.some((s) => s.ref === "index:tasks")).toBe(true);
190
+ expect(app.$options.clocks?.some((c) => c.name === "daily-digest")).toBe(true);
191
+ expect(app.$options.gate?.policies?.some((g) => "name" in g && g.name === "member")).toBe(true);
192
+ expect(app.$options.gate?.policies?.some((g) => "name" in g && g.name === "task:write")).toBe(
193
+ true,
194
+ );
195
+
196
+ await app.boot({ env: "test" });
197
+ expect(app.bootResult?.store?.declarations.has("kv:drafts")).toBe(true);
198
+ expect(app.bootResult?.store?.declarations.has("index:tasks")).toBe(true);
199
+ expect(app.bootResult?.clock?.declarations.has("daily-digest")).toBe(true);
200
+ expect(app.bootResult?.gate?.gates.has("member")).toBe(true);
201
+ expect(app.bootResult?.gate?.gates.has("task:write")).toBe(true);
202
+ });
203
+
164
204
  test('registry: "consume" (default) drains stores/secrets/signals/channel.templates — a later app does not inherit them', async () => {
165
205
  store.sql("leak-app", { schema: {} });
166
206
  vault.secret("LEAK_SECRET", { dev: "dev-leak" });
@@ -205,6 +245,10 @@ describe("oke() auto-registry — stores/secrets/signals/channel.templates/ai",
205
245
  const summarizeNote = smart.prompt("summarize-note", {
206
246
  version: 1,
207
247
  });
248
+ ai.mcpServer("github", {
249
+ url: "https://mcp.example/github",
250
+ tools: ["create_issue"],
251
+ });
208
252
 
209
253
  const ping = flow("ai.ping", {
210
254
  effects: { asks: ["summarize-note"] },
@@ -221,6 +265,7 @@ describe("oke() auto-registry — stores/secrets/signals/channel.templates/ai",
221
265
 
222
266
  expect(app.$options.ai?.models?.some((m) => m.name === "smart")).toBe(true);
223
267
  expect(app.$options.ai?.prompts?.some((p) => p.name === "summarize-note")).toBe(true);
268
+ expect(app.$options.ai?.mcpServers?.some((s) => s.name === "github")).toBe(true);
224
269
 
225
270
  const runtime = createAiRuntime({
226
271
  models: [smart],
@@ -18,6 +18,7 @@ import { openaiCompatibleAiDriver } from "../../drivers/ai-openai-compatible.ts"
18
18
  import type { AiDriver, AiOpenOptions } from "../../drivers/ai-types.ts";
19
19
  import { createAiRuntime, type AiRuntime } from "../../elements/ai.ts";
20
20
  import type { GateRuntime } from "../../elements/gate.ts";
21
+ import type { VaultRuntime } from "../../elements/vault.ts";
21
22
  import type { BootOptions } from "../boot.ts";
22
23
 
23
24
  /**
@@ -28,6 +29,7 @@ import type { BootOptions } from "../boot.ts";
28
29
  * @param now - Clock
29
30
  * @param env - Active environment
30
31
  * @param docker - Prefer compose AI URL when active
32
+ * @param vault - Optional vault for MCP bearer secrets
31
33
  */
32
34
  export function bindAi(
33
35
  options: BootOptions,
@@ -35,13 +37,21 @@ export function bindAi(
35
37
  now: () => number,
36
38
  env: ConfigEnv = "test",
37
39
  docker = false,
40
+ vault?: VaultRuntime,
38
41
  ): AiRuntime {
39
42
  const id = resolveAiDriverId(options, env, docker);
40
43
  const driver =
41
44
  options.ai?.defaultDriver ?? withOpenDefaults(aiDriverFor(id), openDefaultsFor(id, docker));
42
45
  return createAiRuntime({
43
46
  ...(options.ai ?? {}),
47
+ ...(vault ? { resolveSecret: (name: string) => vault.read(name) } : undefined),
44
48
  defaultDriver: driver,
49
+ drivers: {
50
+ mock: lazyDriver("mock", docker),
51
+ anthropic: lazyDriver("anthropic", docker),
52
+ "openai-compatible": lazyDriver("openai-compatible", docker),
53
+ ollama: lazyDriver("ollama", docker),
54
+ },
45
55
  gates: options.ai?.gates ?? gate,
46
56
  now,
47
57
  });
@@ -156,6 +166,20 @@ export function aiUrlFor(docker = false): string {
156
166
  * @param driver - Base driver
157
167
  * @param defaults - Env-derived open options
158
168
  */
169
+ /**
170
+ * Open a protocol driver with env defaults only when a model actually uses it.
171
+ *
172
+ * @param id - Driver id
173
+ * @param docker - Docker mode
174
+ */
175
+ function lazyDriver(id: string, docker: boolean): AiDriver {
176
+ const base = aiDriverFor(id);
177
+ return {
178
+ id: base.id,
179
+ open: (opts = {}) => base.open({ ...openDefaultsFor(id, docker), ...opts }),
180
+ };
181
+ }
182
+
159
183
  function withOpenDefaults(driver: AiDriver, defaults: AiOpenOptions): AiDriver {
160
184
  if (Object.keys(defaults).length === 0) return driver;
161
185
  return {
@@ -124,5 +124,7 @@ export async function bindClock(
124
124
  }
125
125
  }
126
126
 
127
+ clock.startWakes();
128
+
127
129
  return { clock, clockDecls };
128
130
  }
@@ -116,9 +116,9 @@ describe("boot — lazy element needs", () => {
116
116
  if (raw.byteLength === 0) continue;
117
117
  total += Bun.gzipSync(new Uint8Array(raw)).byteLength;
118
118
  }
119
- // Rebased after 0.12.0 store/http graph growth
120
- // (~47.6 kB gzip with export externals).
121
- expect(total).toBeLessThan(48_000);
119
+ // Rebased after clock / gate / kv / index auto-drain on `oke()`.
120
+ // (~48.3 kB gzip with export externals).
121
+ expect(total).toBeLessThan(49_000);
122
122
  } finally {
123
123
  await rm(dir, { recursive: true, force: true });
124
124
  }
@@ -227,7 +227,7 @@ describe("boot — HTTP gate wiring on the default path", () => {
227
227
  resetBindings();
228
228
  resetFlowSeq();
229
229
  on(
230
- http.get("/ping").gate.public,
230
+ http.get("/ping").public(),
231
231
  flow("ping-public", {
232
232
  do: () => ({ ok: true }),
233
233
  }),
@@ -101,10 +101,10 @@ export interface BootOptions {
101
101
  readonly gates?: readonly GateDecl[];
102
102
  /**
103
103
  * HTTP auth-posture enforcement at boot.
104
- * - `"deny"` (default) — every HTTP trigger must carry a gate or `.gate.public`
104
+ * - `"deny"` (default) — every HTTP trigger must carry a gate or `.public()`
105
105
  * - `"allow"` — skips the audit **only** when {@link env} is `"test"`;
106
106
  * ignored in local/prod/docker (never a production-wide bypass).
107
- * Migrate real apps with per-trigger `.gate.public`.
107
+ * Migrate real apps with per-trigger `.public()`.
108
108
  */
109
109
  readonly unguardedHttp?: "deny" | "allow";
110
110
  /** Signal declarations. */
@@ -550,7 +550,7 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
550
550
  // 6. AI
551
551
  let ai = pre.ai;
552
552
  if (needs.ai && !ai) {
553
- ai = aiBind!.bindAi(options, gate, now, env, docker);
553
+ ai = aiBind!.bindAi(options, gate, now, env, docker, vault);
554
554
  }
555
555
 
556
556
  // 7. Runs
@@ -607,12 +607,14 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
607
607
  clearInterval(schedulerTimer);
608
608
  schedulerTimer = undefined;
609
609
  }
610
+ clock?.stopWakes();
610
611
  },
611
612
  async close() {
612
613
  if (schedulerTimer !== undefined) {
613
614
  clearInterval(schedulerTimer);
614
615
  schedulerTimer = undefined;
615
616
  }
617
+ clock?.stopWakes();
616
618
  await signal?.close();
617
619
  await vault?.close();
618
620
  await runs?.flush();
@@ -47,7 +47,7 @@ const CreateOut = z.object({ ok: z.boolean() });
47
47
 
48
48
  function buildUnannotatedCreateFlow(db: ReturnType<typeof store.sql>) {
49
49
  return on(
50
- http.post("/notes").gate.public,
50
+ http.post("/notes").public(),
51
51
  flow("notes.create", {
52
52
  in: CreateIn,
53
53
  out: CreateOut,
@@ -140,7 +140,7 @@ describe("boot-level: table-ref resolution stays backward compatible", () => {
140
140
  resetFlowSeq();
141
141
  const db = store.sql("app", { schema: { notes } });
142
142
  const create = on(
143
- http.post("/notes").gate.public,
143
+ http.post("/notes").public(),
144
144
  flow("notes.create", {
145
145
  in: CreateIn,
146
146
  out: CreateOut,
@@ -193,7 +193,7 @@ import { on, flow, http, gate } from "okengine";
193
193
  const db = {} as any;
194
194
  const notes = {} as any;
195
195
  export const create = on(
196
- http.post("/notes").gate.public,
196
+ http.post("/notes").public(),
197
197
  flow("notes.create", {
198
198
  do: async (input, fx) => {
199
199
  await fx.store(db).insert(notes).values({ id: input.id, title: input.title, createdAt: 1 });
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Module-evaluation registries for `store.sql` / `store.files`, `vault.secret`,
3
- * `vault.env.required`, `signal()`, `channel.<medium>().template()`, and
4
- * `ai.model` / `.prompt` / `ai.embed` / `ai.agent` — the plain arrays behind
5
- * each declare module's `listX()` / `resetX()` pair (mirrors `on.ts`'s
2
+ * Module-evaluation registries for `store.*`, `vault.secret`,
3
+ * `vault.env.required`, `signal()`, `clock()`, `gate.policy` / `.scope` /
4
+ * `.rate`, `channel.<medium>().template()`, and `ai.model` / `.prompt` /
5
+ * `ai.embed` / `ai.agent` / `ai.mcpServer` the plain arrays behind each
6
+ * declare module's `listX()` / `resetX()` pair (mirrors `on.ts`'s
6
7
  * trigger-drain `bindings` array).
7
8
  *
8
9
  * Lives here, not in the declare modules themselves, so {@link oke} can read
@@ -16,6 +17,7 @@
16
17
  import type {
17
18
  AiAgentDecl,
18
19
  AiEmbedDecl,
20
+ AiMcpServerDecl,
19
21
  AiModelDecl,
20
22
  AiPromptDecl,
21
23
  } from "../elements/ai/declare.ts";
@@ -23,8 +25,10 @@ import type { StoreDecl } from "../elements/store/declare.ts";
23
25
  import type { VaultSecretDecl } from "../elements/vault/declare.ts";
24
26
  import type { SignalDecl } from "../elements/signal/declare.ts";
25
27
  import type { ChannelTemplateDecl } from "../elements/channel/declare.ts";
28
+ import type { ClockDecl } from "../elements/clock/declare.ts";
29
+ import type { GateDecl } from "../elements/gate/declare.ts";
26
30
 
27
- /** `store.sql` / `store.files` declarations since the last reset. */
31
+ /** `store.sql` / `store.kv` / `store.files` / `store.index` declarations since the last reset. */
28
32
  export const storeRegistry: StoreDecl[] = [];
29
33
  /** `vault.secret` declarations since the last reset. */
30
34
  export const secretRegistry: VaultSecretDecl[] = [];
@@ -32,6 +36,10 @@ export const secretRegistry: VaultSecretDecl[] = [];
32
36
  export const requiredEnvRegistry: string[] = [];
33
37
  /** `signal()` declarations since the last reset. */
34
38
  export const signalRegistry: SignalDecl[] = [];
39
+ /** `clock()` declarations since the last reset. */
40
+ export const clockRegistry: ClockDecl[] = [];
41
+ /** `gate.policy` / `gate.scope` / `gate.rate` declarations since the last reset. */
42
+ export const gateRegistry: GateDecl[] = [];
35
43
  /** Medium-binder `.template()` declarations since the last reset. */
36
44
  export const channelTemplateRegistry: ChannelTemplateDecl[] = [];
37
45
  /** `ai.model` declarations since the last reset. */
@@ -42,3 +50,5 @@ export const aiPromptRegistry: AiPromptDecl[] = [];
42
50
  export const aiEmbedRegistry: AiEmbedDecl[] = [];
43
51
  /** `ai.agent` declarations since the last reset. */
44
52
  export const aiAgentRegistry: AiAgentDecl[] = [];
53
+ /** `ai.mcpServer` declarations since the last reset. */
54
+ export const aiMcpServerRegistry: AiMcpServerDecl[] = [];
@@ -183,6 +183,30 @@ export const OKE_ERRORS = {
183
183
  cause: "src/flows/{unit} exists on disk but adopted no flows — the .adopt() barrel is stale.",
184
184
  fix: "Run `oke dev` or `oke build` to regenerate `src/flows/generated.ts`.",
185
185
  },
186
+ /**
187
+ * `http.get()` was never stamped from the file tree — refuse a silent `/`.
188
+ */
189
+ HTTP_PATH_UNRESOLVED: {
190
+ code: 1010,
191
+ cause: 'Flow "{flow}" bound {method} with no path — the file-tree stamp never ran.',
192
+ fix: 'Put the file under `src/flows/<unit>/` and import `@/flows/generated`, or pass an explicit path to `http.{method}("/…")`.',
193
+ },
194
+ /**
195
+ * Two HTTP bindings share method + path — last-add-wins is the opposite of this DX.
196
+ */
197
+ HTTP_ROUTE_DUPLICATE: {
198
+ code: 1011,
199
+ cause: '{method} {path} is bound twice (flow "{flow}").',
200
+ fix: "Give each HTTP flow a unique method + path.",
201
+ },
202
+ /**
203
+ * Adopted HTTP flow still has no name (`flow({ do })` outside a unit).
204
+ */
205
+ HTTP_FLOW_UNNAMED: {
206
+ code: 1012,
207
+ cause: "An HTTP flow on {method} {path} has no name.",
208
+ fix: 'Use `flow("unit.export", {…})` or export it from a `src/flows/<unit>/` file so the tree can stamp `unit.export`.',
209
+ },
186
210
  /**
187
211
  * Emit target has no subscriber (unified-theory §21).
188
212
  * Thrown at emit when `optional` is false and nobody is subscribed.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Module-evaluation registry for `src/flows/generated.ts`.
3
+ *
4
+ * Same consume/ignore posture as `on()` / `listBindings()`: generated.ts
5
+ * calls {@link registerFlowUnits}; {@link oke} drains the bag into `$routes`
6
+ * unless `registry: "ignore"`.
7
+ */
8
+
9
+ import type { AnyFlowDef } from "./flow.ts";
10
+ import { isFlow } from "./flow.ts";
11
+
12
+ /** Unit name → export name → Flow. */
13
+ export type FlowUnitBag = {
14
+ readonly [exportName: string]: unknown;
15
+ };
16
+
17
+ const pending: Record<string, FlowUnitBag> = {};
18
+
19
+ /**
20
+ * Record generated flow units for the next {@link oke} construction.
21
+ *
22
+ * @param units - `{ notes, main, … }` from `generated.ts`
23
+ */
24
+ export function registerFlowUnits(units: Record<string, FlowUnitBag>): void {
25
+ Object.assign(pending, units);
26
+ }
27
+
28
+ /**
29
+ * Drain registered units (consume). Clears the registry.
30
+ */
31
+ export function consumeRegisteredFlowUnits(): Record<string, FlowUnitBag> {
32
+ const snap: Record<string, FlowUnitBag> = { ...pending };
33
+ resetRegisteredFlowUnits();
34
+ return snap;
35
+ }
36
+
37
+ /**
38
+ * Snapshot without clearing (keep / tests).
39
+ */
40
+ export function peekRegisteredFlowUnits(): Record<string, FlowUnitBag> {
41
+ return { ...pending };
42
+ }
43
+
44
+ /**
45
+ * Clear the unit registry (tests / `registry: "consume"` after drain).
46
+ */
47
+ export function resetRegisteredFlowUnits(): void {
48
+ for (const key of Object.keys(pending)) {
49
+ delete pending[key];
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Collect FlowDefs from a unit bag (skip non-flow exports).
55
+ *
56
+ * @param bag - Namespace object
57
+ */
58
+ export function flowsInUnitBag(bag: FlowUnitBag): AnyFlowDef[] {
59
+ const found: AnyFlowDef[] = [];
60
+ for (const value of Object.values(bag)) {
61
+ if (isFlow(value)) found.push(value);
62
+ }
63
+ return found;
64
+ }
@@ -239,13 +239,34 @@ export const flowBrand: unique symbol = Symbol("oke.flow");
239
239
  * Input / output types are inferred from Standard Schema `in` / `out` when
240
240
  * present; otherwise from the `do` handler signature.
241
241
  *
242
+ * A nameless `flow({ do })` is stamped `unit.export` by the file-tree
243
+ * generator, {@link unit}, or `.adopt()`. Explicit `flow("notes.get", {…})`
244
+ * still wins.
245
+ *
246
+ * @param options - Contract and handler (name stamped later)
247
+ */
248
+ export function flow<Opts extends FlowOptions<any, any, any>>(
249
+ options: Opts,
250
+ ): FlowDef<InferFlowIn<Opts>, InferFlowOut<Opts>, InferFlowErrors<Opts>>;
251
+ /**
242
252
  * @param name - Stable name (used by `fx.call` and the Manifest)
243
253
  * @param options - Contract and handler
244
254
  */
245
255
  export function flow<Opts extends FlowOptions<any, any, any>>(
246
256
  name: string,
247
257
  options: Opts & { readonly name?: never },
248
- ): FlowDef<InferFlowIn<Opts>, InferFlowOut<Opts>, InferFlowErrors<Opts>> {
258
+ ): FlowDef<InferFlowIn<Opts>, InferFlowOut<Opts>, InferFlowErrors<Opts>>;
259
+ export function flow(
260
+ nameOrOptions: string | FlowOptions<any, any, any>,
261
+ maybeOptions?: FlowOptions<any, any, any> & { readonly name?: never },
262
+ ): FlowDef {
263
+ const named = typeof nameOrOptions === "string";
264
+ const name = named ? nameOrOptions : "";
265
+ const options = (named ? maybeOptions : nameOrOptions) as FlowOptions<any, any, any> | undefined;
266
+ if (!options || typeof options.do !== "function") {
267
+ throw new TypeError("flow() expected an options bag with a do handler");
268
+ }
269
+
249
270
  const triggers: Trigger[] = [];
250
271
  const hooks: Partial<Record<HookStage, HookFn[]>> = {};
251
272
  const pendingPlugins: PluginDef[] = [];
@@ -253,13 +274,13 @@ export function flow<Opts extends FlowOptions<any, any, any>>(
253
274
  const dot = name.indexOf(".");
254
275
  const unit = dot > 0 ? name.slice(0, dot) : undefined;
255
276
 
256
- const def: FlowDef<InferFlowIn<Opts>, InferFlowOut<Opts>, InferFlowErrors<Opts>> = {
277
+ const def: FlowDef = {
257
278
  [flowBrand]: true,
258
279
  name,
259
280
  unit,
260
281
  in: options.in,
261
282
  out: options.out,
262
- errors: (options.errors ?? undefined) as InferFlowErrors<Opts> | undefined,
283
+ errors: (options.errors ?? undefined) as FlowErrorMap | undefined,
263
284
  effects: options.effects,
264
285
  durable: options.durable ?? false,
265
286
  retry: options.retry,
@@ -268,17 +289,8 @@ export function flow<Opts extends FlowOptions<any, any, any>>(
268
289
  slo: options.slo,
269
290
  plane: options.plane,
270
291
  breaking: options.breaking ?? false,
271
- compensate: options.compensate as
272
- | ((
273
- ctx: {
274
- readonly input: InferFlowIn<Opts>;
275
- readonly error: unknown;
276
- readonly completedSteps: readonly string[];
277
- },
278
- fx: import("./fx.ts").Fx,
279
- ) => unknown | Promise<unknown>)
280
- | undefined,
281
- do: options.do as FlowHandler<InferFlowIn<Opts>, InferFlowOut<Opts>>,
292
+ compensate: options.compensate as FlowDef["compensate"],
293
+ do: options.do as FlowHandler,
282
294
  triggers,
283
295
  $trigger: undefined,
284
296
  hooks,
@@ -320,9 +332,9 @@ export function isFlow(value: unknown): value is AnyFlowDef {
320
332
  }
321
333
 
322
334
  /**
323
- * No-op. `name` is a required positional argument now, so there is no
324
- * auto-name sequence left to reset — kept so existing test `beforeEach`
325
- * blocks don't need touching.
335
+ * No-op. Nameless flows start as `""` and are stamped by unit / adopt /
336
+ * the file-tree generator — kept so existing test `beforeEach` blocks
337
+ * don't need touching.
326
338
  *
327
339
  * @internal
328
340
  */
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { memorySignalDriver } from "../drivers/index.ts";
3
+ import { createSignalRuntime, signal, type SignalDecl } from "../elements/signal.ts";
4
+ import { OkeError } from "./errors.ts";
5
+ import { createFx, createFxContext, signalReadRef } from "./fx.ts";
6
+
7
+ describe("fx.deadLetters", () => {
8
+ test("returns dead-lettered messages for a declared signal read", async () => {
9
+ const notify = signal("notify", { delivery: "once", retries: 0, deadLetter: true });
10
+ const runtime = openRuntime(notify);
11
+ const bus = await runtime.start();
12
+ await bus.subscribe("notify", "c1", async () => {
13
+ throw new Error("smtp-down");
14
+ });
15
+ await runtime.emit("notify", { orderId: "ord_1" });
16
+ await bus.drain();
17
+
18
+ const { fx, ledger } = createFxContext({
19
+ flow: "notifications.failed",
20
+ effects: { reads: [signalReadRef("notify")] },
21
+ signalRuntime: runtime,
22
+ });
23
+
24
+ const dead = await fx.deadLetters(notify);
25
+ expect(dead).toHaveLength(1);
26
+ expect(dead[0]!.payload).toEqual({ orderId: "ord_1" });
27
+ expect(dead[0]!.status).toBe("dead");
28
+ expect(dead[0]!.attempts).toBe(1);
29
+ expect(dead[0]!.failures[0]!.message).toBe("smtp-down");
30
+ expect(ledger.entries.map((e) => `${e.kind}:${e.resource}`)).toEqual(["read:signal:notify"]);
31
+
32
+ await runtime.close();
33
+ });
34
+
35
+ test("undeclared and cross-signal reads throw OKE1001", async () => {
36
+ const notify = signal("notify", { delivery: "once", retries: 0, deadLetter: true });
37
+ const other = signal("other", { delivery: "once", retries: 0, deadLetter: true });
38
+ const runtime = openRuntime(notify);
39
+ const fx = createFx({
40
+ flow: "notifications.failed",
41
+ effects: { reads: [signalReadRef("notify")] },
42
+ signalRuntime: runtime,
43
+ });
44
+
45
+ let err: unknown;
46
+ try {
47
+ await fx.deadLetters(other);
48
+ } catch (e) {
49
+ err = e;
50
+ }
51
+ expect(err).toBeInstanceOf(OkeError);
52
+ const oke = err as OkeError;
53
+ expect(oke.code).toBe(1001);
54
+ expect(oke.causeText).toBe(
55
+ 'Flow "notifications.failed" reads "signal:other" without declaring it.',
56
+ );
57
+ expect(oke.fix).toBe('Add "signal:other" to this flow\'s effects.reads.');
58
+
59
+ await runtime.close();
60
+ });
61
+
62
+ test("throws when no signal runtime is bound", async () => {
63
+ const fx = createFx({
64
+ flow: "notifications.failed",
65
+ effects: { reads: [signalReadRef("notify")] },
66
+ });
67
+ await expect(fx.deadLetters("notify")).rejects.toThrow(
68
+ "fx.deadLetters requires a bound signal runtime",
69
+ );
70
+ });
71
+ });
72
+
73
+ function openRuntime(decl: SignalDecl) {
74
+ const runtime = createSignalRuntime({ driver: memorySignalDriver });
75
+ runtime.register(decl);
76
+ return runtime;
77
+ }
@@ -361,11 +361,17 @@ describe("fx — wholesale swap", () => {
361
361
  async emit(signal) {
362
362
  calls.push(`emit:${typeof signal === "string" ? signal : signal.name}`);
363
363
  },
364
+ async deadLetters() {
365
+ return [];
366
+ },
364
367
  async call() {
365
368
  return null;
366
369
  },
367
370
  clock: {
368
371
  now: () => 0,
372
+ ago: () => 0,
373
+ fromNow: () => 0,
374
+ duration: () => 0,
369
375
  sleep: async () => undefined,
370
376
  },
371
377
  vault: {
@@ -451,6 +457,9 @@ describe("fx — wholesale swap", () => {
451
457
  withQuery(rows) {
452
458
  return { [jsonResultBrand]: true, status: 200, value: [...rows], meta: {} };
453
459
  },
460
+ stream(chunks) {
461
+ return { [jsonResultBrand]: true, kind: "stream" as const, status: 200 as const, chunks };
462
+ },
454
463
  },
455
464
  async step(_name, fn) {
456
465
  return fn();
@@ -514,6 +523,19 @@ describe("fx.t — catalogs", () => {
514
523
  });
515
524
  });
516
525
 
526
+ describe("fx.clock — relative instants", () => {
527
+ test("ago / fromNow / duration use the injected now and duration strings", () => {
528
+ const fx = createFx({ flow: "sessions.sweepExpired", effects: {}, now: () => 1_000_000 });
529
+ expect(fx.clock.now()).toBe(1_000_000);
530
+ expect(fx.clock.duration("30d")).toBe(30 * 86_400_000);
531
+ expect(fx.clock.ago("30d")).toBe(1_000_000 - 30 * 86_400_000);
532
+ expect(fx.clock.fromNow("14d")).toBe(1_000_000 + 14 * 86_400_000);
533
+ expect(fx.clock.duration("nope")).toBe(0);
534
+ expect(fx.clock.ago("nope")).toBe(1_000_000);
535
+ expect(fx.clock.fromNow("200ms")).toBe(1_000_200);
536
+ });
537
+ });
538
+
517
539
  describe("fx.json", () => {
518
540
  test("with accepts a page or (data, meta)", () => {
519
541
  const fx = createFx({ flow: "x", effects: {} });