okengine 0.19.9 → 0.21.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 (156) hide show
  1. package/AGENTS.md +1 -1
  2. package/package.json +2 -1
  3. package/site/content/docs/client/auth.mdx +1 -2
  4. package/site/content/docs/client/calling.mdx +125 -37
  5. package/site/content/docs/client/index.mdx +7 -7
  6. package/site/content/docs/client/react.mdx +5 -0
  7. package/site/content/docs/elements/channel/email.mdx +25 -36
  8. package/site/content/docs/elements/channel/index.mdx +88 -46
  9. package/site/content/docs/elements/channel/push.mdx +7 -9
  10. package/site/content/docs/elements/channel/sms.mdx +6 -4
  11. package/site/content/docs/elements/channel/whatsapp.mdx +11 -9
  12. package/site/content/docs/elements/clock/index.mdx +14 -25
  13. package/site/content/docs/elements/flow/http.mdx +42 -23
  14. package/site/content/docs/elements/flow/index.mdx +41 -30
  15. package/site/content/docs/elements/flow/routing.mdx +166 -122
  16. package/site/content/docs/elements/gate/rls.mdx +2 -2
  17. package/site/content/docs/elements/gate/tenancy.mdx +1 -1
  18. package/site/content/docs/elements/store/files.mdx +11 -5
  19. package/site/content/docs/elements/store/index.mdx +13 -6
  20. package/site/content/docs/elements/store/kv.mdx +8 -2
  21. package/site/content/docs/elements/store/search.mdx +5 -5
  22. package/site/content/docs/elements/store/sql.mdx +100 -39
  23. package/site/content/docs/elements/vault/index.mdx +14 -13
  24. package/site/content/docs/elements/vault/secrets.mdx +6 -3
  25. package/site/content/docs/index.mdx +1 -1
  26. package/site/content/docs/plugins/magic-link.mdx +4 -3
  27. package/site/content/docs/plugins/otp.mdx +4 -3
  28. package/site/content/docs/plugins/two-factor.mdx +4 -0
  29. package/site/content/docs/providers/index.mdx +1 -1
  30. package/site/content/docs/recipes/index.mdx +1 -1
  31. package/site/content/docs/recipes/rustfs.mdx +1 -1
  32. package/site/content/docs/reference/cli.mdx +7 -4
  33. package/site/content/docs/reference/configuration.mdx +8 -8
  34. package/site/content/docs/reference/errors.mdx +229 -55
  35. package/site/content/docs/reference/fx.mdx +22 -9
  36. package/site/content/docs/reference/i18n.mdx +4 -4
  37. package/site/content/docs/reference/plugins.mdx +4 -4
  38. package/site/content/docs/understand/the-architecture.mdx +2 -2
  39. package/site/content/docs/understand/try-it.mdx +759 -24
  40. package/src/cli/ai-setup/ai-setup.test.ts +40 -0
  41. package/src/cli/ai-setup/apply.ts +28 -46
  42. package/src/cli/build.test.ts +3 -3
  43. package/src/cli/build.ts +5 -5
  44. package/src/cli/db-auto-push.test.ts +11 -0
  45. package/src/cli/db-auto-push.ts +6 -2
  46. package/src/cli/db.test.ts +1 -1
  47. package/src/cli/db.ts +6 -6
  48. package/src/cli/dev-app-runner.ts +2 -1
  49. package/src/cli/dev-db-push.test.ts +6 -2
  50. package/src/cli/dev-schema-sync.ts +1 -1
  51. package/src/cli/dev.test.ts +10 -7
  52. package/src/cli/dev.ts +13 -11
  53. package/src/cli/ensure-drizzle-config.ts +4 -3
  54. package/src/cli/start.ts +2 -1
  55. package/src/client/create.ts +3 -3
  56. package/src/client/explain.test.ts +252 -0
  57. package/src/client/explain.ts +272 -0
  58. package/src/client/live.test.ts +44 -0
  59. package/src/client/notes-contract.test.ts +10 -0
  60. package/src/client/sse.ts +6 -2
  61. package/src/client/transport.test.ts +67 -0
  62. package/src/client/transport.ts +24 -40
  63. package/src/client/types.ts +17 -6
  64. package/src/client-react/browser.test.ts +23 -0
  65. package/src/client-react/live-resource.ts +6 -2
  66. package/src/client-react/use-live-query.ts +1 -1
  67. package/src/compiler/flow-path.test.ts +1 -0
  68. package/src/compiler/flow-path.ts +1 -1
  69. package/src/compiler/generate-adopt.test.ts +55 -1
  70. package/src/compiler/generate-adopt.ts +111 -21
  71. package/src/compiler/response.ts +17 -27
  72. package/src/config/index.ts +6 -4
  73. package/src/console/server/invoke-user-flow.test.ts +8 -2
  74. package/src/console/server/invoke-user-flow.ts +12 -18
  75. package/src/console/server/security.gate.test.ts +1 -1
  76. package/src/console/ui-next/dist/assets/{access-page-BoC83Ubl.js → access-page-tIbsiphz.js} +1 -1
  77. package/src/console/ui-next/dist/assets/{agent-disclosure-CKjAEOqA.js → agent-disclosure-CSKumwS2.js} +1 -1
  78. package/src/console/ui-next/dist/assets/{cache-glyph-Ceaq9pYh.js → cache-glyph-BCC-DxKT.js} +1 -1
  79. package/src/console/ui-next/dist/assets/{call-pii-button-C4lmY7ck.js → call-pii-button-jOmYISdc.js} +1 -1
  80. package/src/console/ui-next/dist/assets/{collapsible-82y257sL.js → collapsible-DC2xNaAb.js} +1 -1
  81. package/src/console/ui-next/dist/assets/{duration-tone-W63jaMZ8.js → duration-tone-CwoV56jn.js} +1 -1
  82. package/src/console/ui-next/dist/assets/{flows-page-KFTFj2rK.js → flows-page-gT1lsWQK.js} +1 -1
  83. package/src/console/ui-next/dist/assets/{highlighted-json-yE9zNTqC.js → highlighted-json-C2GZEJNI.js} +1 -1
  84. package/src/console/ui-next/dist/assets/{http-method-CwFeFroN.js → http-method-BdYjcIrD.js} +1 -1
  85. package/src/console/ui-next/dist/assets/{index-r7xXt_VV.js → index-Cul17AcV.js} +3 -3
  86. package/src/console/ui-next/dist/assets/{observability-page-BDiliMiC.js → observability-page-oY9vdYBk.js} +1 -1
  87. package/src/console/ui-next/dist/assets/{replica-lag-DYDzWUFT.js → replica-lag-C4QdAF7J.js} +1 -1
  88. package/src/console/ui-next/dist/assets/{request-meta-CzrOfgiz.js → request-meta-C43DHyld.js} +1 -1
  89. package/src/console/ui-next/dist/assets/{store-page-CZC2cwaw.js → store-page-CL0D9dOq.js} +1 -1
  90. package/src/console/ui-next/dist/assets/{trace-detail-sheet-DgeeejW7.js → trace-detail-sheet-Dcpo4Us_.js} +1 -1
  91. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CzGIyOPY.js → tree-expand-toggle-CKJTuv43.js} +1 -1
  92. package/src/console/ui-next/dist/assets/{units-page-DBiDCLIB.js → units-page-1PlT19ft.js} +1 -1
  93. package/src/console/ui-next/dist/assets/{vault-page-CcHsthPe.js → vault-page-BwZ9YjTW.js} +1 -1
  94. package/src/console/ui-next/dist/index.html +1 -1
  95. package/src/docker/docker.test.ts +3 -3
  96. package/src/docker/images-config.test.ts +4 -4
  97. package/src/docker/stack-id.test.ts +1 -1
  98. package/src/drivers/clock-postgres.test.ts +10 -2
  99. package/src/drivers/clock-postgres.ts +18 -2
  100. package/src/drivers/vault-driver-removal.test.ts +2 -2
  101. package/src/elements/channel/declare.ts +66 -3
  102. package/src/elements/channel/runtime.ts +9 -11
  103. package/src/elements/channel.test.ts +42 -0
  104. package/src/elements/channel.ts +4 -2
  105. package/src/elements/clock/reconcile.ts +45 -24
  106. package/src/elements/clock.test.ts +33 -0
  107. package/src/elements/store/emit-drizzle.ts +285 -65
  108. package/src/elements/store/files-errors.test.ts +149 -0
  109. package/src/elements/store/files-errors.ts +189 -0
  110. package/src/elements/store/kv-errors.test.ts +98 -0
  111. package/src/elements/store/kv-errors.ts +139 -0
  112. package/src/elements/store/load-plugin-tables.ts +1 -1
  113. package/src/elements/store/prepare-row.test.ts +57 -4
  114. package/src/elements/store/resource.ts +11 -7
  115. package/src/elements/store/runtime.ts +18 -13
  116. package/src/elements/store/schema-decl.test.ts +178 -0
  117. package/src/elements/store/sql-errors.test.ts +197 -0
  118. package/src/elements/store/sql-errors.ts +294 -0
  119. package/src/elements/store/sql-session.test.ts +52 -0
  120. package/src/elements/store/sql-session.ts +50 -2
  121. package/src/elements/store/store-errors.ts +47 -0
  122. package/src/elements/store/table.ts +8 -6
  123. package/src/http.ts +9 -1
  124. package/src/i18n/catalogs/ar.ts +18 -0
  125. package/src/i18n/catalogs/en.ts +18 -0
  126. package/src/index.ts +9 -1
  127. package/src/kernel/adopt-barrel-fresh.test.ts +1 -1
  128. package/src/kernel/app.ts +40 -34
  129. package/src/kernel/auto-registry.test.ts +26 -1
  130. package/src/kernel/boot.ts +2 -2
  131. package/src/kernel/boundary-contract.ts +6 -1
  132. package/src/kernel/builtin-errors.test.ts +117 -0
  133. package/src/kernel/builtin-errors.ts +129 -0
  134. package/src/kernel/call.test.ts +182 -0
  135. package/src/kernel/errors-vault.ts +16 -0
  136. package/src/kernel/errors.registry.test.ts +7 -0
  137. package/src/kernel/errors.ts +97 -24
  138. package/src/kernel/fail-helpers.ts +34 -0
  139. package/src/kernel/flow-units.ts +3 -3
  140. package/src/kernel/fx.test.ts +8 -0
  141. package/src/kernel/fx.ts +24 -8
  142. package/src/kernel/index.ts +12 -1
  143. package/src/kernel/mutation-id.ts +8 -0
  144. package/src/kernel/plugin.ts +4 -3
  145. package/src/kernel/project-out.test.ts +176 -0
  146. package/src/kernel/project-out.ts +91 -0
  147. package/src/kernel/realtime-bind.ts +2 -3
  148. package/src/kernel/router/linear.ts +12 -6
  149. package/src/kernel/router.test.ts +13 -0
  150. package/src/plugins/magic-link.ts +25 -24
  151. package/src/plugins/otp.ts +35 -24
  152. package/src/plugins/two-factor.ts +15 -0
  153. package/src/runs/duckdb.test.ts +2 -2
  154. package/src/runtime/dev-request-log.ts +29 -11
  155. package/src/term.test.ts +76 -0
  156. package/src/term.ts +166 -3
@@ -83,15 +83,7 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
83
83
  } catch (err) {
84
84
  const transient = isTransient(err);
85
85
  if (!transient || attempt >= retries) {
86
- return {
87
- data: null,
88
- error: {
89
- code: "TransportError",
90
- data: {
91
- message: err instanceof Error ? err.message : String(err),
92
- },
93
- },
94
- };
86
+ return transportEnvelope(err instanceof Error ? err.message : String(err));
95
87
  }
96
88
  await sleep(delay);
97
89
  delay *= backoff;
@@ -102,6 +94,25 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
102
94
  };
103
95
  }
104
96
 
97
+ /**
98
+ * Build a {@link ClientEnvelope} for a transport / protocol failure.
99
+ *
100
+ * Guarantees `error.message === error.data.message`.
101
+ *
102
+ * @param message - Human-readable failure text
103
+ * @param status - Optional HTTP status
104
+ */
105
+ function transportEnvelope(message: string, status?: number): ClientEnvelope {
106
+ return {
107
+ data: null,
108
+ error: {
109
+ code: "TransportError",
110
+ message,
111
+ data: status !== undefined ? { message, status } : { message },
112
+ },
113
+ };
114
+ }
115
+
105
116
  function normalizeCallOpts(
106
117
  headersOrOpts: ClientHeaders | TransportCallOptions | undefined,
107
118
  ): TransportCallOptions {
@@ -277,29 +288,14 @@ async function decode(res: Response): Promise<ClientEnvelope> {
277
288
  const text = await res.text();
278
289
  if (!text) {
279
290
  if (res.ok) return { data: undefined, error: null };
280
- return {
281
- data: null,
282
- error: {
283
- code: "TransportError",
284
- data: { message: `HTTP ${res.status}`, status: res.status },
285
- },
286
- };
291
+ return transportEnvelope(`HTTP ${res.status}`, res.status);
287
292
  }
288
293
 
289
294
  let json: unknown;
290
295
  try {
291
296
  json = JSON.parse(text);
292
297
  } catch {
293
- return {
294
- data: null,
295
- error: {
296
- code: "TransportError",
297
- data: {
298
- message: `Invalid JSON (${res.status})`,
299
- status: res.status,
300
- },
301
- },
302
- };
298
+ return transportEnvelope(`Invalid JSON (${res.status})`, res.status);
303
299
  }
304
300
 
305
301
  if (json !== null && typeof json === "object" && "data" in json && "error" in json) {
@@ -310,26 +306,14 @@ async function decode(res: Response): Promise<ClientEnvelope> {
310
306
  return { data: json, error: null };
311
307
  }
312
308
 
313
- return {
314
- data: null,
315
- error: {
316
- code: "TransportError",
317
- data: { message: `HTTP ${res.status}`, status: res.status },
318
- },
319
- };
309
+ return transportEnvelope(`HTTP ${res.status}`, res.status);
320
310
  }
321
311
 
322
312
  async function decodeBinary(res: Response, mode: "blob" | "arrayBuffer"): Promise<ClientEnvelope> {
323
313
  if (!res.ok) {
324
314
  const structured = await decodeIfEnvelopeClone(res);
325
315
  if (structured) return structured;
326
- return {
327
- data: null,
328
- error: {
329
- code: "TransportError",
330
- data: { message: `HTTP ${res.status}`, status: res.status },
331
- },
332
- };
316
+ return transportEnvelope(`HTTP ${res.status}`, res.status);
333
317
  }
334
318
  const data = mode === "blob" ? await res.blob() : await res.arrayBuffer();
335
319
  return { data, error: null };
@@ -5,6 +5,8 @@
5
5
  * Local / separate-repo: augment {@link Register} so `createClient(url)` needs no import.
6
6
  */
7
7
 
8
+ import type { BuiltinErrorMap } from "../kernel/builtin-errors.ts";
9
+
8
10
  /**
9
11
  * One flow's client contract. `in` / `out` / `errors` are phantom (type-only).
10
12
  *
@@ -125,7 +127,8 @@ export interface TransportError {
125
127
  readonly message: string;
126
128
  readonly status?: number;
127
129
  };
128
- readonly message?: string;
130
+ /** Same string as {@link TransportError.data.message}. */
131
+ readonly message: string;
129
132
  }
130
133
 
131
134
  /** Next / previous list request — TanStack `pageParam` / URL bag. */
@@ -431,12 +434,20 @@ type ContractIn<C> = "in" extends keyof C
431
434
  /** Pull output from a contract shape. */
432
435
  type ContractOut<C> = "out" extends keyof C ? NonNullable<C["out"]> : unknown;
433
436
 
434
- /** Pull error map from a contract shape. */
437
+ /**
438
+ * Pull error map from a contract shape. Built-in codes are always on; declared
439
+ * keys win. A missing `errors` bag (phantom `Record<string, unknown>`) is
440
+ * treated as unspecified — not as an index signature that wipes the builtins.
441
+ */
435
442
  type ContractErrors<C> = "errors" extends keyof C
436
- ? NonNullable<C["errors"]> extends Record<string, unknown>
437
- ? NonNullable<C["errors"]>
438
- : Record<string, never>
439
- : Record<string, never>;
443
+ ? NonNullable<C["errors"]> extends infer E
444
+ ? E extends Record<string, unknown>
445
+ ? string extends keyof E
446
+ ? BuiltinErrorMap
447
+ : Omit<BuiltinErrorMap, keyof E> & E
448
+ : BuiltinErrorMap
449
+ : BuiltinErrorMap
450
+ : BuiltinErrorMap;
440
451
 
441
452
  /**
442
453
  * Typed client proxy derived from a route map.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Browser graph for `okengine/client-react` — Vite SPAs import `Can` from the
3
+ * barrel, which also loads `useLiveQuery`.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { join } from "node:path";
8
+
9
+ describe("client-react browser graph", () => {
10
+ test("browser bundle does not pull node:async_hooks", async () => {
11
+ const result = await Bun.build({
12
+ entrypoints: [join(import.meta.dir, "index.ts")],
13
+ target: "browser",
14
+ format: "esm",
15
+ minify: false,
16
+ external: ["react", "react/jsx-runtime", "react-dom"],
17
+ });
18
+ expect(result.success).toBe(true);
19
+ const text = (await Promise.all(result.outputs.map((output) => output.text()))).join("\n");
20
+ expect(text).not.toContain("node:async_hooks");
21
+ expect(text).not.toContain("realtime-bind");
22
+ });
23
+ });
@@ -219,8 +219,12 @@ function sseError(status: number, body: string): Error {
219
219
  try {
220
220
  const json: unknown = JSON.parse(body);
221
221
  if (json !== null && typeof json === "object" && "error" in json) {
222
- const err = (json as { error?: { code?: string; data?: { message?: string } } }).error;
223
- message = err?.data?.message ?? err?.code ?? message;
222
+ const err = (
223
+ json as {
224
+ error?: { code?: string; message?: string; data?: { message?: string } };
225
+ }
226
+ ).error;
227
+ message = err?.message ?? err?.data?.message ?? err?.code ?? message;
224
228
  }
225
229
  } catch {
226
230
  message = body.slice(0, 200);
@@ -23,7 +23,7 @@
23
23
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
24
24
  import { transportOf } from "../client/create.ts";
25
25
  import type { ClientCall, ClientResult } from "../client/types.ts";
26
- import { MUTATION_ID_HEADER } from "../kernel/realtime-bind.ts";
26
+ import { MUTATION_ID_HEADER } from "../kernel/mutation-id.ts";
27
27
  import {
28
28
  applyOptimisticPatch,
29
29
  clearOptimisticPatch,
@@ -67,6 +67,7 @@ describe("pathFromFlowFile", () => {
67
67
  expect(pathFromFlowFile("notes/list.test.ts")).toBeUndefined();
68
68
  expect(pathFromFlowFile("notes/_lib/util.ts")).toBeUndefined();
69
69
  expect(pathFromFlowFile("src/flows/generated.ts")).toBeUndefined();
70
+ expect(pathFromFlowFile("src/flows/index.ts")).toBeUndefined();
70
71
  });
71
72
 
72
73
  test("accepts a source path under src/flows/", () => {
@@ -17,7 +17,7 @@ export const RESERVED_LEAVES: ReadonlySet<string> = new Set([
17
17
  "route",
18
18
  ]);
19
19
 
20
- /** Filenames that are never routes. */
20
+ /** Filenames that are never HTTP routes (`signals.ts` may still export `on()` consumers). */
21
21
  export const SKIP_FLOW_FILENAMES: ReadonlySet<string> = new Set([
22
22
  "generated.ts",
23
23
  "shapes.ts",
@@ -69,7 +69,7 @@ describe("generateAdoptBarrel", () => {
69
69
  'export const create = "notes-flow";\n',
70
70
  );
71
71
  const result = await generateAdoptBarrel({ rootDir: root });
72
- const generatedPath = join(root, "src/flows/generated.ts");
72
+ const generatedPath = join(root, "src/flows/index.ts");
73
73
  await mkdir(join(root, "node_modules/okengine"), { recursive: true });
74
74
  await writeFile(
75
75
  join(root, "node_modules/okengine/package.json"),
@@ -173,12 +173,66 @@ describe("generateAdoptBarrel", () => {
173
173
  }
174
174
  });
175
175
 
176
+ test("writeAdoptBarrel unlinks leftover generated.ts", async () => {
177
+ const root = await mkdtemp(join(tmpdir(), "oke-adopt-prev-"));
178
+ try {
179
+ await mkdir(join(root, "src/flows"), { recursive: true });
180
+ const prev = join(root, "src/flows/generated.ts");
181
+ await writeFile(prev, "// old\n");
182
+ expect((await writeAdoptBarrel(root, "// new\n")).written).toBe(true);
183
+ expect(await Bun.file(join(root, "src/flows/index.ts")).text()).toBe("// new\n");
184
+ expect(await Bun.file(prev).exists()).toBe(false);
185
+ await writeFile(prev, "// leftover\n");
186
+ expect((await writeAdoptBarrel(root, "// new\n")).written).toBe(false);
187
+ expect(await Bun.file(prev).exists()).toBe(false);
188
+ } finally {
189
+ await rm(root, { recursive: true, force: true });
190
+ }
191
+ });
192
+
176
193
  test("import specifiers stay POSIX when the walked path used backslash", () => {
177
194
  const spec = importSpecifierFromWalked("notes\\[id]\\get.ts");
178
195
  expect(spec).toBe("./notes/[id]/get.ts");
179
196
  expect(spec.includes("\\")).toBe(false);
180
197
  });
181
198
 
199
+ test("signals.ts on() consumers join the unit; signal.once decls do not", async () => {
200
+ const root = await mkdtemp(join(tmpdir(), "oke-adopt-signals-"));
201
+ try {
202
+ await mkdir(join(root, "src/flows/links"), { recursive: true });
203
+ await writeFile(
204
+ join(root, "src/flows/links/create.ts"),
205
+ "export const create = on(http.post(), flow({ do: () => 1 }));\n",
206
+ );
207
+ await writeFile(
208
+ join(root, "src/flows/links/signals.ts"),
209
+ [
210
+ 'export const linkCreated = signal.once("link-created");',
211
+ "export const onCreated = on(",
212
+ " linkCreated,",
213
+ ' flow("links.onCreated", { do: () => 1 }),',
214
+ ");",
215
+ "export const onClicked = on(",
216
+ " linkClicked,",
217
+ ' flow("links.onClicked", { do: () => 1 }),',
218
+ ");",
219
+ ].join("\n"),
220
+ );
221
+ await writeFile(join(root, "src/flows/links/shapes.ts"), "export const LinkOut = 1;\n");
222
+ const result = await generateAdoptBarrel({ rootDir: root });
223
+ expect(result.source).toContain('from "./links/signals.ts"');
224
+ expect(result.source).toContain("links_signals.onCreated");
225
+ expect(result.source).toContain("links_signals.onClicked");
226
+ expect(result.source).toContain('"/links/on-created"');
227
+ expect(result.source).toContain('"/links/on-clicked"');
228
+ expect(result.source).not.toContain("linkCreated");
229
+ expect(result.source).not.toContain("LinkOut");
230
+ expect(result.source).not.toContain("shapes.ts");
231
+ } finally {
232
+ await rm(root, { recursive: true, force: true });
233
+ }
234
+ });
235
+
182
236
  test("[...slug] unit adds the * client-key comment", async () => {
183
237
  const root = await mkdtemp(join(tmpdir(), "oke-adopt-gen-"));
184
238
  try {
@@ -51,15 +51,24 @@ const CATCHALL_COMMENT =
51
51
  '// Catch-all param is "*" — call api.docs.get({ "*": "a/b/c" }), not { slug }.\n';
52
52
 
53
53
  /** Filename of the on-disk `.adopt()` barrel under the flows directory. */
54
- export const ADOPT_BARREL_FILE = "generated.ts";
54
+ export const ADOPT_BARREL_FILE = "index.ts";
55
+
56
+ /** Previous barrel filename — unlinked after writing {@link ADOPT_BARREL_FILE}. */
57
+ export const PREV_ADOPT_BARREL_FILE = "generated.ts";
55
58
 
56
59
  /** A folder name usable as an `export * as <name>` binding. */
57
60
  const VALID_UNIT_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
58
61
 
59
62
  const EXPORT_CONST = /^export const ([A-Za-z_$][A-Za-z0-9_$]*)\b/gm;
60
63
 
64
+ /** `export const onCreated = on(` — consumers colocated with `signal.once` in `signals.ts`. */
65
+ const EXPORT_ON = /^export const ([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*on\s*\(/gm;
66
+
61
67
  const EXPLICIT_FLOW_UNIT = /flow\(\s*["']([A-Za-z_$][A-Za-z0-9_$]*)\./g;
62
68
 
69
+ /** Skip-list file that may still export `on()` consumers (not `signal.once` decls). */
70
+ const SIGNALS_FILE = "signals.ts";
71
+
63
72
  /**
64
73
  * Generate-time failure (mixed barrel + tree, export collision, unit drift).
65
74
  */
@@ -86,9 +95,28 @@ interface WalkedFlowFile {
86
95
  }
87
96
 
88
97
  /**
89
- * Write the adopt barrel atomically: `generated.ts.tmp` → rename to
90
- * `generated.ts`. Skips the write when bytes are unchanged so the `src/`
91
- * watcher + bun `--hot` do not loop.
98
+ * True when `rel` is the adopt barrel (or its `.tmp`) at the flows root —
99
+ * not a unit `index.ts`.
100
+ *
101
+ * @param rel - Watcher or source path (POSIX or Windows)
102
+ */
103
+ export function isAdoptBarrelRelPath(rel: string): boolean {
104
+ const posix = toPosixPath(rel);
105
+ const marker = "flows/";
106
+ const idx = posix.lastIndexOf(marker);
107
+ const after = idx >= 0 ? posix.slice(idx + marker.length) : posix;
108
+ return (
109
+ after === ADOPT_BARREL_FILE ||
110
+ after === `${ADOPT_BARREL_FILE}.tmp` ||
111
+ after === PREV_ADOPT_BARREL_FILE ||
112
+ after === `${PREV_ADOPT_BARREL_FILE}.tmp`
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Write the adopt barrel atomically: `index.ts.tmp` → rename to
118
+ * `index.ts`. Skips the write when bytes are unchanged so the `src/`
119
+ * watcher + bun `--hot` do not loop. Unlinks leftover `generated.ts`.
92
120
  *
93
121
  * @param rootDir - Project root
94
122
  * @param source - Full barrel source from {@link generateAdoptBarrel}
@@ -103,25 +131,44 @@ export async function writeAdoptBarrel(
103
131
  const target = join(dir, ADOPT_BARREL_FILE);
104
132
  const tmp = `${target}.tmp`;
105
133
  await mkdir(dir, { recursive: true });
134
+ let written = true;
106
135
  try {
107
136
  const existing = await readFile(target, "utf8");
108
- if (existing === source) return { written: false };
137
+ if (existing === source) written = false;
109
138
  } catch {
110
139
  // missing — write
111
140
  }
112
- await writeFile(tmp, source);
113
- try {
114
- await rename(tmp, target);
115
- } catch {
116
- // Windows cannot rename onto an existing path (EEXIST / EPERM).
117
- await writeFile(target, source);
141
+ if (written) {
142
+ await writeFile(tmp, source);
118
143
  try {
119
- await unlink(tmp);
144
+ await rename(tmp, target);
120
145
  } catch {
121
- // tmp already replaced or removed
146
+ // Windows cannot rename onto an existing path (EEXIST / EPERM).
147
+ await writeFile(target, source);
148
+ try {
149
+ await unlink(tmp);
150
+ } catch {
151
+ // tmp already replaced or removed
152
+ }
153
+ }
154
+ }
155
+ await unlinkPrevAdoptBarrel(dir);
156
+ return { written };
157
+ }
158
+
159
+ /**
160
+ * Remove leftover `generated.ts` after the barrel moved to `index.ts`.
161
+ *
162
+ * @param dir - Absolute flows directory
163
+ */
164
+ async function unlinkPrevAdoptBarrel(dir: string): Promise<void> {
165
+ for (const name of [PREV_ADOPT_BARREL_FILE, `${PREV_ADOPT_BARREL_FILE}.tmp`]) {
166
+ try {
167
+ await unlink(join(dir, name));
168
+ } catch {
169
+ // absent
122
170
  }
123
171
  }
124
- return { written: true };
125
172
  }
126
173
 
127
174
  /**
@@ -225,6 +272,7 @@ export async function generateAdoptBarrel(
225
272
 
226
273
  /**
227
274
  * Walk a unit folder for route files (skip-list applied).
275
+ * `signals.ts` is included only for `export const … = on(` consumers.
228
276
  *
229
277
  * @param unitAbs - Absolute unit directory
230
278
  */
@@ -246,10 +294,11 @@ async function listFlowFiles(unitAbs: string): Promise<WalkedFlowFile[]> {
246
294
  continue;
247
295
  }
248
296
  if (!posixName.endsWith(".ts") && !posixName.endsWith(".tsx")) continue;
249
- if (isSkipFlowFile(posixName)) continue;
297
+ if (isSkipFlowFile(posixName) && posixName !== SIGNALS_FILE) continue;
250
298
  const relFile = rel ? `${rel}/${posixName}` : posixName;
251
299
  const source = await readFile(join(dir, d.name), "utf8");
252
- const exports = listExportedConsts(source);
300
+ const exports =
301
+ posixName === SIGNALS_FILE ? listExportedOnConsts(source) : listExportedConsts(source);
253
302
  const relPosix = toPosixPath(relFile);
254
303
  // Barrel `index.ts` counts even with no `export const` (`export {}` / re-exports).
255
304
  if (exports.length === 0 && relPosix !== "index.ts") continue;
@@ -282,6 +331,31 @@ export function listExportedConsts(source: string): string[] {
282
331
  return names;
283
332
  }
284
333
 
334
+ /**
335
+ * Collect `export const name = on(` bindings (skip `signal.once` decls).
336
+ *
337
+ * @param source - TypeScript source
338
+ */
339
+ export function listExportedOnConsts(source: string): string[] {
340
+ const names: string[] = [];
341
+ EXPORT_ON.lastIndex = 0;
342
+ let match: RegExpExecArray | null;
343
+ while ((match = EXPORT_ON.exec(source))) {
344
+ const name = match[1];
345
+ if (name) names.push(name);
346
+ }
347
+ return names;
348
+ }
349
+
350
+ /**
351
+ * camelCase export → kebab filename (`onCreated` → `on-created`).
352
+ *
353
+ * @param name - `export const` binding
354
+ */
355
+ function camelToKebab(name: string): string {
356
+ return name.replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`).replace(/^-/, "");
357
+ }
358
+
285
359
  /**
286
360
  * Emit import + stamped namespace for a tree unit.
287
361
  *
@@ -309,10 +383,6 @@ function emitTreeUnit(
309
383
  const alias = importAlias(unit, file.relFromUnit);
310
384
  importLines.push(`import * as ${alias} from ${JSON.stringify(spec)};`);
311
385
 
312
- const inferred =
313
- pathFromFlowFile(`${unit}/${file.relFromUnit}`) ??
314
- pathFromFlowFile(`src/flows/${unit}/${file.relFromUnit}`);
315
-
316
386
  for (const exp of file.exports) {
317
387
  const prev = used.get(exp);
318
388
  if (prev !== undefined) {
@@ -322,7 +392,7 @@ function emitTreeUnit(
322
392
  }
323
393
  used.set(exp, file.relFromUnit);
324
394
  const flowName = nameFromFlowFile(`${unit}/${file.relFromUnit}`, exp) ?? `${unit}.${exp}`;
325
- const pathLit = JSON.stringify(inferred ?? "/");
395
+ const pathLit = JSON.stringify(httpPathForExport(unit, file.relFromUnit, exp));
326
396
  fieldLines.push(
327
397
  ` ${exp}: stampHttpPath(stampFlowName(${alias}.${exp}, ${JSON.stringify(flowName)}), ${pathLit}),`,
328
398
  );
@@ -340,6 +410,26 @@ function emitTreeUnit(
340
410
  return { lines, catchAll };
341
411
  }
342
412
 
413
+ /**
414
+ * HTTP path stamp for one export.
415
+ *
416
+ * Skip-list files (`signals.ts`) have no file-tree path — fall back to
417
+ * `unit` + kebab export so `onCreated` in `links/signals.ts` stamps
418
+ * `/links/on-created` like a dedicated `on-created.ts` would.
419
+ *
420
+ * @param unit - Folder name
421
+ * @param relFromUnit - POSIX relative path
422
+ * @param exp - `export const` binding
423
+ */
424
+ function httpPathForExport(unit: string, relFromUnit: string, exp: string): string {
425
+ return (
426
+ pathFromFlowFile(`${unit}/${relFromUnit}`) ??
427
+ pathFromFlowFile(`src/flows/${unit}/${relFromUnit}`) ??
428
+ pathFromFlowFile(`${unit}/${camelToKebab(exp)}.ts`) ??
429
+ "/"
430
+ );
431
+ }
432
+
343
433
  /**
344
434
  * Fail when `flow("other.x")` lives under a different unit folder.
345
435
  *
@@ -4,11 +4,10 @@
4
4
  * Shared by AoT and dynamic so responses stay byte-identical.
5
5
  */
6
6
 
7
- import type { FlowFailure } from "../kernel/errors.ts";
7
+ import { fail, type FlowFailure } from "../kernel/errors.ts";
8
8
  import type { JsonResult, JsonStreamResult, SseFrame } from "../kernel/fx.ts";
9
9
  import { isFlowFailure } from "../kernel/hooks.ts";
10
10
  import { lazyRequire } from "../kernel/lazy-require.ts";
11
- import { VALIDATION_ERROR_CODE } from "../validation/standard-schema.ts";
12
11
 
13
12
  /** Successful envelope. */
14
13
  export interface SuccessEnvelope {
@@ -26,24 +25,12 @@ export interface FailureEnvelope {
26
25
  /**
27
26
  * HTTP status for a flow-boundary failure.
28
27
  *
29
- * Gate denials use the status the Gates simulator promises:
30
- * `Unauthorized` → 401 · `Forbidden` → 403 · `RateLimited` → 429.
28
+ * Built-in codes use {@link httpStatusForFailure}. Domain codes default to 400.
31
29
  *
32
30
  * @param failure - Typed failure
33
31
  */
34
32
  export function statusForFailure(failure: FlowFailure): number {
35
- switch (failure.error.code) {
36
- case VALIDATION_ERROR_CODE:
37
- return 422;
38
- case "Unauthorized":
39
- return 401;
40
- case "Forbidden":
41
- return 403;
42
- case "RateLimited":
43
- return 429;
44
- default:
45
- return 400;
46
- }
33
+ return loadBuiltinErrors().httpStatusForFailure(failure.error.code, failure.error.data);
47
34
  }
48
35
 
49
36
  /**
@@ -87,6 +74,14 @@ function loadFxLiveStream(): typeof import("../kernel/fx-live-stream.ts") {
87
74
  return lazyRequire(`${import.meta.dir}/../kernel`, ["fx", "live", "stream"].join("-"));
88
75
  }
89
76
 
77
+ function loadBuiltinErrors(): typeof import("../kernel/builtin-errors.ts") {
78
+ return lazyRequire(`${import.meta.dir}/../kernel`, ["builtin", "errors"].join("-"));
79
+ }
80
+
81
+ function loadStoreErrors(): typeof import("../elements/store/store-errors.ts") {
82
+ return lazyRequire(`${import.meta.dir}/../elements/store`, ["store", "errors"].join("-"));
83
+ }
84
+
90
85
  function loadFxJson(): {
91
86
  isJsonResult: (value: unknown) => value is JsonResult;
92
87
  isJsonStreamResult: (value: unknown) => value is JsonStreamResult;
@@ -123,18 +118,13 @@ export async function encodeExecuteResult(result: {
123
118
  ? loadFxLiveStream().encodeGap(result.error)
124
119
  : undefined;
125
120
  if (gap) return gap;
121
+ const storeMapped = loadStoreErrors().storeErrorToFailure(result.error, {
122
+ retryable: "unavailable",
123
+ });
124
+ if (storeMapped) return encodeFailure(storeMapped);
126
125
  // Unhandled throws must never look like success (`undefined` → 204).
127
- return Response.json(
128
- {
129
- data: null,
130
- error: {
131
- code: "InternalError",
132
- data: {},
133
- message: result.error instanceof Error ? result.error.message : "internal error",
134
- },
135
- } satisfies FailureEnvelope,
136
- { status: 500 },
137
- );
126
+ // Catalog message only — never copy the thrown `Error.message`.
127
+ return encodeFailure(fail("InternalError", {}));
138
128
  }
139
129
  if (loadFxJson().isJsonStreamResult(result.output) && result.output.ready) {
140
130
  const early = await loadFxLiveStream().awaitLiveReady(result.output);
@@ -214,14 +214,16 @@ export interface DbConfig {
214
214
  /**
215
215
  * Abstract schema declare module (`store.schema.table` exports).
216
216
  * When present, `oke db` emits Drizzle into {@link generated} before sync.
217
- * Default `"src/db/schema.decl.ts"` (legacy `"src/schema.decl.ts"` still resolved).
217
+ * Default: `src/db/schema.ts` if that file exists, else
218
+ * `src/db/schema.decl.ts`, else `src/db/schema/index.ts`, else legacy
219
+ * `src/schema.decl.ts`. Set this to override discovery.
218
220
  */
219
221
  readonly declare?: string;
220
222
  /**
221
223
  * Generated Drizzle schema path written from abstract decls.
222
- * Default `"src/db/schema.drizzle.ts"` (prior `"src/db/schema.generated.ts"`;
223
- * legacy `"src/schema.generated.ts"`).
224
- * Point `drizzle.config.ts` `schema` at this file when using the abstract path.
224
+ * Default `"src/db/drizzle/index.ts"`. Older single files still resolve
225
+ * when `generated` is set. Point `drizzle.config.ts` `schema` at this
226
+ * barrel when using the abstract path.
225
227
  */
226
228
  readonly generated?: string;
227
229
  /**
@@ -34,17 +34,23 @@ const MANIFEST = {
34
34
  } as Manifest;
35
35
 
36
36
  function execute(over: Partial<ExecuteResult>): ExecuteResult {
37
- return {
37
+ const result = {
38
38
  output: undefined,
39
39
  failure: undefined,
40
40
  response: undefined,
41
41
  ctx: {} as ExecuteResult["ctx"],
42
42
  fx: {} as ExecuteResult["fx"],
43
- cache: "none",
43
+ cache: "none" as const,
44
44
  durationMs: 0,
45
45
  runId: "run_test",
46
46
  ...over,
47
47
  };
48
+ return {
49
+ ...result,
50
+ get error() {
51
+ return result.ctx.error;
52
+ },
53
+ };
48
54
  }
49
55
 
50
56
  describe("invokeResultFromExecute", () => {