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
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { flow, type AnyFlowDef, type FlowDef, type FlowErrorMap } from "../../kernel/flow.ts";
16
16
  import { applyBoundaryContract, stampBoundaryContract } from "../../kernel/boundary-contract.ts";
17
- import { fail } from "../../kernel/errors.ts";
17
+ import { fail } from "../../kernel/fail-helpers.ts";
18
18
  import type { Fx } from "../../kernel/fx.ts";
19
19
  import {
20
20
  openLiveStream,
@@ -53,7 +53,11 @@ export interface ResourceOptions {
53
53
  * used to extend `update` with the path id (wire sends `{ id, ...patch }`).
54
54
  */
55
55
  readonly idSchema?: unknown;
56
- /** Typed errors for get / update / remove (default `{ NotFound }`). */
56
+ /**
57
+ * Extra typed errors for get / update / remove.
58
+ *
59
+ * Built-in `NotFound` is always on — omit unless tightening the payload.
60
+ */
57
61
  readonly errors?: FlowErrorMap;
58
62
  /** `:id` column; defaults to the table primary key. */
59
63
  readonly id?: unknown;
@@ -191,7 +195,7 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
191
195
  const { mode, limit, maxLimit, cursorColumns: resolvedCursor } = scope;
192
196
  const countMode = scope.count;
193
197
 
194
- const errors = options.errors ?? ({ NotFound: {} as never } as FlowErrorMap);
198
+ const errors = options.errors;
195
199
  const breaking = options.breaking === true;
196
200
 
197
201
  /** Run the list query and shape rows + meta. */
@@ -309,7 +313,7 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
309
313
  findById(t: unknown, id: string): Promise<SqlRow | null>;
310
314
  };
311
315
  const row = await store.findById(table, String(id));
312
- if (!row) return fail("NotFound", {});
316
+ if (!row) return fail.notFound();
313
317
  return row;
314
318
  },
315
319
  });
@@ -341,7 +345,7 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
341
345
  update(t: unknown): { set(row: SqlRow): { where(w: unknown): Promise<number> } };
342
346
  };
343
347
  const existing = await store.findById(table, String(id));
344
- if (!existing) return fail("NotFound", {});
348
+ if (!existing) return fail.notFound();
345
349
  if (Object.keys(patch).length > 0) {
346
350
  await store
347
351
  .update(table)
@@ -349,7 +353,7 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
349
353
  .where(leafOp(idDrizzleCol, "=", String(id)));
350
354
  }
351
355
  const row = await store.findById(table, String(id));
352
- if (!row) return fail("NotFound", {});
356
+ if (!row) return fail.notFound();
353
357
  return row;
354
358
  },
355
359
  });
@@ -371,7 +375,7 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
371
375
  delete(t: unknown, id: string): Promise<boolean>;
372
376
  };
373
377
  const deleted = await store.delete(table, String(id));
374
- if (!deleted) return fail("NotFound", {});
378
+ if (!deleted) return fail.notFound();
375
379
  return fx.json.empty();
376
380
  },
377
381
  });
@@ -44,6 +44,7 @@ import type {
44
44
  StoreDecl,
45
45
  } from "./declare.ts";
46
46
  import { openSqlKvNamespace } from "./kv-sql.ts";
47
+ import { withStoreErrorMap } from "./store-errors.ts";
47
48
  import { createGatedFilesStoreHandle, type GatedFilesFxBridge } from "./files-fx.ts";
48
49
  import {
49
50
  createFilesImagePipeline,
@@ -434,14 +435,15 @@ export function createStoreRuntime(options: CreateStoreRuntimeOptions): StoreRun
434
435
  kvNs.set(decl.name, ns);
435
436
  }
436
437
  const driverId = ns.driverId;
438
+ const handle = ns;
437
439
  return {
438
440
  ref: `kv:${decl.name}`,
439
441
  driverId,
440
- get: (key) => ns!.get(key),
441
- set: (key, value, ttl) => ns!.set(key, value, ttl),
442
- delete: (key) => ns!.delete(key),
443
- list: (prefix) => ns!.list(prefix),
444
- ttlMs: (key) => ns!.ttlMs(key),
442
+ get: (key) => withStoreErrorMap(() => handle.get(key)),
443
+ set: (key, value, ttl) => withStoreErrorMap(() => handle.set(key, value, ttl)),
444
+ delete: (key) => withStoreErrorMap(() => handle.delete(key)),
445
+ list: (prefix) => withStoreErrorMap(() => handle.list(prefix)),
446
+ ttlMs: (key) => withStoreErrorMap(() => handle.ttlMs(key)),
445
447
  };
446
448
  }
447
449
 
@@ -458,19 +460,22 @@ export function createStoreRuntime(options: CreateStoreRuntimeOptions): StoreRun
458
460
  });
459
461
  fileBuckets.set(decl.name, bucket);
460
462
  }
463
+ const handle = bucket;
461
464
  const access = {
462
- get: (key: string) => bucket!.get(key),
463
- put: (key: string, data: Uint8Array | string) => bucket!.put(key, data),
465
+ get: (key: string) => withStoreErrorMap(() => handle.get(key)),
466
+ put: (key: string, data: Uint8Array | string) =>
467
+ withStoreErrorMap(() => handle.put(key, data)),
464
468
  };
465
469
  return {
466
470
  ref: `files:${decl.name}`,
467
- driverId: bucket.driverId,
468
- put: (key, data) => bucket!.put(key, data),
469
- get: (key) => bucket!.get(key),
470
- delete: (key) => bucket!.delete(key),
471
- list: (prefix) => bucket!.list(prefix),
471
+ driverId: handle.driverId,
472
+ put: (key, data) => withStoreErrorMap(() => handle.put(key, data)),
473
+ get: (key) => withStoreErrorMap(() => handle.get(key)),
474
+ delete: (key) => withStoreErrorMap(() => handle.delete(key)),
475
+ list: (prefix) => withStoreErrorMap(() => handle.list(prefix)),
472
476
  image: (source, imageOpts) => createFilesImagePipeline(access, source, imageOpts),
473
- putImage: (key, data, putOpts) => putImageToBucket(access, key, data, putOpts),
477
+ putImage: (key, data, putOpts) =>
478
+ withStoreErrorMap(() => putImageToBucket(access, key, data, putOpts)),
474
479
  };
475
480
  }
476
481
 
@@ -3,6 +3,9 @@
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join, resolve } from "node:path";
6
9
  import { plugin } from "../../kernel/plugin.ts";
7
10
  import { createRecordingApi } from "../../kernel/registry.ts";
8
11
  import {
@@ -18,9 +21,13 @@ import {
18
21
  type SqlStoreHandle,
19
22
  } from "../store.ts";
20
23
  import {
24
+ discoverDeclareRel,
25
+ discoverGeneratedRel,
21
26
  emitDrizzleSource,
22
27
  GENERATED_SCHEMA_HEADER,
28
+ maybeEmitDomainSchema,
23
29
  mergeSchemaTables,
30
+ resolveEmitPaths,
24
31
  tablesFromPluginContributions,
25
32
  } from "./emit-drizzle.ts";
26
33
 
@@ -297,3 +304,174 @@ describe("store.schema RLS extras", () => {
297
304
  expect(emitDrizzleSource([notes], "postgres")).toContain("pgTable.withRLS");
298
305
  });
299
306
  });
307
+
308
+ describe("declare path discovery", () => {
309
+ test("schema.ts wins over schema/index.ts", async () => {
310
+ const dir = await mkdtemp(join(tmpdir(), "oke-decl-priority-"));
311
+ await mkdir(join(dir, "src", "db", "schema"), { recursive: true });
312
+ await writeFile(join(dir, "src", "db", "schema.ts"), "export {}\n");
313
+ await writeFile(join(dir, "src", "db", "schema", "index.ts"), "export {}\n");
314
+ expect(discoverDeclareRel(dir)).toBe("src/db/schema.ts");
315
+ expect(resolveEmitPaths(dir).declarePath).toBe(join(dir, "src/db/schema.ts"));
316
+ await rm(dir, { recursive: true, force: true });
317
+ });
318
+
319
+ test("schema.decl.ts when schema.ts is absent", async () => {
320
+ const dir = await mkdtemp(join(tmpdir(), "oke-decl-prev-"));
321
+ await mkdir(join(dir, "src", "db", "schema"), { recursive: true });
322
+ await writeFile(join(dir, "src", "db", "schema.decl.ts"), "export {}\n");
323
+ await writeFile(join(dir, "src", "db", "schema", "index.ts"), "export {}\n");
324
+ expect(discoverDeclareRel(dir)).toBe("src/db/schema.decl.ts");
325
+ await rm(dir, { recursive: true, force: true });
326
+ });
327
+
328
+ test("schema/index.ts when single-file declare is absent", async () => {
329
+ const dir = await mkdtemp(join(tmpdir(), "oke-decl-folder-"));
330
+ await mkdir(join(dir, "src", "db", "schema"), { recursive: true });
331
+ await writeFile(join(dir, "src", "db", "schema", "index.ts"), "export {}\n");
332
+ expect(discoverDeclareRel(dir)).toBe("src/db/schema/index.ts");
333
+ await rm(dir, { recursive: true, force: true });
334
+ });
335
+
336
+ test("db.declare overrides discovery", async () => {
337
+ const dir = await mkdtemp(join(tmpdir(), "oke-decl-override-"));
338
+ await mkdir(join(dir, "src", "db", "schema"), { recursive: true });
339
+ await writeFile(join(dir, "src", "db", "schema.ts"), "export {}\n");
340
+ const resolved = resolveEmitPaths(dir, { declare: "src/db/schema/index.ts" });
341
+ expect(resolved.declarePath).toBe(join(dir, "src/db/schema/index.ts"));
342
+ await rm(dir, { recursive: true, force: true });
343
+ });
344
+
345
+ test("drizzle/index.ts wins over drizzle.ts", async () => {
346
+ const dir = await mkdtemp(join(tmpdir(), "oke-gen-folder-"));
347
+ await mkdir(join(dir, "src", "db", "drizzle"), { recursive: true });
348
+ await writeFile(join(dir, "src", "db", "drizzle", "index.ts"), "export {}\n");
349
+ await writeFile(join(dir, "src", "db", "drizzle.ts"), "export {}\n");
350
+ expect(discoverGeneratedRel(dir, "src/db/schema.ts")).toBe("src/db/drizzle/index.ts");
351
+ await rm(dir, { recursive: true, force: true });
352
+ });
353
+
354
+ test("omitted generated is drizzle/index.ts even if older files exist", async () => {
355
+ const dir = await mkdtemp(join(tmpdir(), "oke-gen-default-"));
356
+ await mkdir(join(dir, "src", "db"), { recursive: true });
357
+ await writeFile(join(dir, "src", "db", "drizzle.ts"), "export {}\n");
358
+ await writeFile(join(dir, "src", "db", "schema.drizzle.ts"), "export {}\n");
359
+ expect(discoverGeneratedRel(dir, "src/db/schema.ts")).toBe("src/db/drizzle/index.ts");
360
+ await rm(dir, { recursive: true, force: true });
361
+ });
362
+ });
363
+
364
+ describe("maybeEmitDomainSchema — barrel declare", () => {
365
+ test("emits from schema/index.ts without schema.ts", async () => {
366
+ const dir = await mkdtemp(join(tmpdir(), "oke-schema-folder-"));
367
+ const schemaDir = join(dir, "src", "db", "schema");
368
+ await mkdir(schemaDir, { recursive: true });
369
+ const oke = resolve(import.meta.dir, "../../index.ts");
370
+ await writeFile(
371
+ join(schemaDir, "posts.ts"),
372
+ `import { store, field } from ${JSON.stringify(oke)};
373
+ export const posts = store.schema.table("posts", {
374
+ id: field.id().primaryKey(),
375
+ title: field.text().notNull(),
376
+ });
377
+ `,
378
+ );
379
+ await writeFile(join(schemaDir, "index.ts"), `export * from "./posts.ts";\n`);
380
+ const result = await maybeEmitDomainSchema({
381
+ cwd: dir,
382
+ dialect: "postgres",
383
+ });
384
+ expect(result.emitted).toBe(true);
385
+ expect(result.tableCount).toBe(1);
386
+ const generated = await readFile(join(dir, "src", "db", "drizzle", "posts.ts"), "utf8");
387
+ expect(generated).toContain('pgTable("posts"');
388
+ const barrel = await readFile(join(dir, "src", "db", "drizzle", "index.ts"), "utf8");
389
+ expect(barrel).toContain('export * from "./posts.ts"');
390
+ await rm(dir, { recursive: true, force: true });
391
+ });
392
+
393
+ test("writes one file per table with FK import + relations", async () => {
394
+ const dir = await mkdtemp(join(tmpdir(), "oke-drizzle-split-"));
395
+ const schemaDir = join(dir, "src", "db", "schema");
396
+ await mkdir(schemaDir, { recursive: true });
397
+ const oke = resolve(import.meta.dir, "../../index.ts");
398
+ await writeFile(
399
+ join(schemaDir, "links.ts"),
400
+ `import { store, field } from ${JSON.stringify(oke)};
401
+ export const links = store.schema.table("links", {
402
+ id: field.id().primaryKey(),
403
+ code: field.text().notNull().unique(),
404
+ });
405
+ `,
406
+ );
407
+ await writeFile(
408
+ join(schemaDir, "daily.ts"),
409
+ `import { store, field } from ${JSON.stringify(oke)};
410
+ import { links } from "./links.ts";
411
+ export const daily = store.schema.table("daily", {
412
+ id: field.id().primaryKey(),
413
+ code: field.text().notNull().references(() => links.code, { onDelete: "cascade" }),
414
+ });
415
+ `,
416
+ );
417
+ await writeFile(
418
+ join(schemaDir, "relations.ts"),
419
+ `import { store } from ${JSON.stringify(oke)};
420
+ import { daily } from "./daily.ts";
421
+ import { links } from "./links.ts";
422
+ export const relations = store.schema.relations({ links, daily }, (r) => ({
423
+ links: { daily: r.many.daily({ from: r.links.code, to: r.daily.code }) },
424
+ daily: { link: r.one.links({ from: r.daily.code, to: r.links.code, optional: false }) },
425
+ }));
426
+ `,
427
+ );
428
+ await writeFile(
429
+ join(schemaDir, "index.ts"),
430
+ `export * from "./links.ts";
431
+ export * from "./daily.ts";
432
+ export * from "./relations.ts";
433
+ `,
434
+ );
435
+ const result = await maybeEmitDomainSchema({ cwd: dir, dialect: "postgres" });
436
+ expect(result.emitted).toBe(true);
437
+ expect(result.tableCount).toBe(2);
438
+ const daily = await readFile(join(dir, "src", "db", "drizzle", "daily.ts"), "utf8");
439
+ expect(daily).toContain('import { links } from "./links.ts"');
440
+ expect(daily).toContain(".references(() => links.code");
441
+ const rel = await readFile(join(dir, "src", "db", "drizzle", "relations.ts"), "utf8");
442
+ expect(rel).toContain("defineRelations({ links, daily }");
443
+ const barrel = await readFile(join(dir, "src", "db", "drizzle", "index.ts"), "utf8");
444
+ expect(barrel).toContain('export * from "./relations.ts"');
445
+ await rm(dir, { recursive: true, force: true });
446
+ });
447
+
448
+ test("prunes stale generated files and drops sibling drizzle.ts", async () => {
449
+ const dir = await mkdtemp(join(tmpdir(), "oke-drizzle-prune-"));
450
+ const schemaDir = join(dir, "src", "db", "schema");
451
+ await mkdir(schemaDir, { recursive: true });
452
+ await mkdir(join(dir, "src", "db", "drizzle"), { recursive: true });
453
+ const oke = resolve(import.meta.dir, "../../index.ts");
454
+ await writeFile(
455
+ join(schemaDir, "posts.ts"),
456
+ `import { store, field } from ${JSON.stringify(oke)};
457
+ export const posts = store.schema.table("posts", {
458
+ id: field.id().primaryKey(),
459
+ });
460
+ `,
461
+ );
462
+ await writeFile(join(schemaDir, "index.ts"), `export * from "./posts.ts";\n`);
463
+ await writeFile(
464
+ join(dir, "src", "db", "drizzle.ts"),
465
+ `// generated by oke — do not edit\nexport const stale = true;\n`,
466
+ );
467
+ await writeFile(
468
+ join(dir, "src", "db", "drizzle", "gone.ts"),
469
+ `// generated by oke — do not edit\nexport const gone = true;\n`,
470
+ );
471
+ await maybeEmitDomainSchema({ cwd: dir, dialect: "postgres" });
472
+ expect(await Bun.file(join(dir, "src", "db", "drizzle.ts")).exists()).toBe(false);
473
+ expect(await Bun.file(join(dir, "src", "db", "drizzle", "gone.ts")).exists()).toBe(false);
474
+ expect(await Bun.file(join(dir, "src", "db", "drizzle", "posts.ts")).exists()).toBe(true);
475
+ await rm(dir, { recursive: true, force: true });
476
+ });
477
+ });
@@ -0,0 +1,197 @@
1
+ /**
2
+ * SQLSTATE / SQLite constraint → typed FlowFailure.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { isFlowFailure } from "../../kernel/hooks.ts";
7
+ import { isRetryableSqlError, sqlErrorToFailure } from "./sql-errors.ts";
8
+
9
+ describe("sqlErrorToFailure", () => {
10
+ test("unique / exclusion → Conflict without copying message", () => {
11
+ const unique = sqlErrorToFailure({
12
+ code: "23505",
13
+ message: 'duplicate key value violates unique constraint "users_email_key"',
14
+ detail: "Key (email)=(a@b.c) already exists.",
15
+ constraint: "users_email_key",
16
+ table: "users",
17
+ column: "email",
18
+ });
19
+ expect(unique?.error.code).toBe("Conflict");
20
+ expect(unique?.error.data).toEqual({
21
+ sqlstate: "23505",
22
+ constraint: "users_email_key",
23
+ table: "users",
24
+ column: "email",
25
+ });
26
+ expect(JSON.stringify(unique)).not.toContain("already exists");
27
+ expect(JSON.stringify(unique)).not.toContain("a@b.c");
28
+
29
+ const exclusion = sqlErrorToFailure({ code: "23P01", constraint: "no_overlap" });
30
+ expect(exclusion?.error.code).toBe("Conflict");
31
+ });
32
+
33
+ test("restrict / restrict_violation → ForeignKey", () => {
34
+ expect(sqlErrorToFailure({ code: "23001", table: "notes" })?.error.code).toBe("ForeignKey");
35
+ });
36
+
37
+ test("invalid / too_long / out_of_range → DatabaseError 422 reasons", () => {
38
+ expect(sqlErrorToFailure({ code: "22P02" })?.error.data).toMatchObject({
39
+ reason: "invalid",
40
+ sqlstate: "22P02",
41
+ });
42
+ expect(sqlErrorToFailure({ code: "22007" })?.error.data).toMatchObject({ reason: "invalid" });
43
+ expect(sqlErrorToFailure({ code: "22023" })?.error.data).toMatchObject({ reason: "invalid" });
44
+ expect(sqlErrorToFailure({ code: "42804" })?.error.data).toMatchObject({ reason: "invalid" });
45
+ expect(sqlErrorToFailure({ code: "22001", column: "title" })?.error.data).toMatchObject({
46
+ reason: "too_long",
47
+ column: "title",
48
+ });
49
+ expect(sqlErrorToFailure({ code: "22003" })?.error.data).toMatchObject({
50
+ reason: "out_of_range",
51
+ });
52
+ expect(sqlErrorToFailure({ code: "22008" })?.error.data).toMatchObject({
53
+ reason: "out_of_range",
54
+ });
55
+ });
56
+
57
+ test("walks Drizzle .cause for unique SQLSTATE", () => {
58
+ const wrapped = {
59
+ message: "Failed query: insert into users constraint users_email_key",
60
+ cause: {
61
+ code: "23505",
62
+ constraint: "users_email_key",
63
+ message: 'duplicate key value violates unique constraint "users_email_key"',
64
+ },
65
+ };
66
+ const mapped = sqlErrorToFailure(wrapped);
67
+ expect(mapped?.error.code).toBe("Conflict");
68
+ expect(mapped?.error.data).toMatchObject({ constraint: "users_email_key", sqlstate: "23505" });
69
+ expect(JSON.stringify(mapped)).not.toContain("duplicate key");
70
+ });
71
+
72
+ test("foreign key → ForeignKey", () => {
73
+ const fk = sqlErrorToFailure({
74
+ code: "23503",
75
+ table: "notes",
76
+ constraint: "notes_user_id_fkey",
77
+ message: 'insert or update on table "notes" violates foreign key constraint',
78
+ });
79
+ expect(fk?.error.code).toBe("ForeignKey");
80
+ expect(fk?.error.data).toMatchObject({
81
+ sqlstate: "23503",
82
+ table: "notes",
83
+ constraint: "notes_user_id_fkey",
84
+ });
85
+ expect(JSON.stringify(fk)).not.toContain("violates");
86
+ });
87
+
88
+ test("not_null / check → DatabaseError reasons", () => {
89
+ const notNull = sqlErrorToFailure({ code: "23502", column: "title", table: "posts" });
90
+ expect(notNull?.error.code).toBe("DatabaseError");
91
+ expect(notNull?.error.data).toMatchObject({ reason: "not_null", column: "title" });
92
+
93
+ const check = sqlErrorToFailure({ code: "23514", constraint: "positive_clicks" });
94
+ expect(check?.error.code).toBe("DatabaseError");
95
+ expect(check?.error.data).toMatchObject({ reason: "check", constraint: "positive_clicks" });
96
+ });
97
+
98
+ test("40001 / 40P01 left thrown until retryable: unavailable", () => {
99
+ const ser = { code: "40001", message: "could not serialize access" };
100
+ expect(isRetryableSqlError(ser)).toBe(true);
101
+ expect(sqlErrorToFailure(ser, { retryable: "leave" })).toBeUndefined();
102
+ const exhausted = sqlErrorToFailure(ser, { retryable: "unavailable" });
103
+ expect(exhausted?.error.code).toBe("ServiceUnavailable");
104
+ expect(JSON.stringify(exhausted)).not.toContain("serialize");
105
+
106
+ expect(sqlErrorToFailure({ code: "40P01" }, { retryable: "leave" })).toBeUndefined();
107
+ expect(sqlErrorToFailure({ code: "40P01" }, { retryable: "unavailable" })?.error.code).toBe(
108
+ "ServiceUnavailable",
109
+ );
110
+ expect(isRetryableSqlError({ code: "55P03" })).toBe(true);
111
+ expect(sqlErrorToFailure({ code: "55P03" }, { retryable: "leave" })).toBeUndefined();
112
+ expect(sqlErrorToFailure({ code: "55P03" }, { retryable: "unavailable" })?.error.code).toBe(
113
+ "ServiceUnavailable",
114
+ );
115
+ });
116
+
117
+ test("connection / paused → ServiceUnavailable", () => {
118
+ expect(sqlErrorToFailure({ code: "08006" })?.error.code).toBe("ServiceUnavailable");
119
+ expect(sqlErrorToFailure({ code: "53300" })?.error.code).toBe("ServiceUnavailable");
120
+ expect(sqlErrorToFailure({ code: "53100" })?.error.code).toBe("ServiceUnavailable");
121
+ expect(sqlErrorToFailure({ code: "57P01" })?.error.code).toBe("ServiceUnavailable");
122
+ expect(sqlErrorToFailure({ code: "57014" })?.error.code).toBe("ServiceUnavailable");
123
+ expect(sqlErrorToFailure({ code: "25P03" })?.error.code).toBe("ServiceUnavailable");
124
+ expect(sqlErrorToFailure({ code: "ERR_POSTGRES_CONNECTION_CLOSED" })?.error.code).toBe(
125
+ "ServiceUnavailable",
126
+ );
127
+ expect(
128
+ sqlErrorToFailure({ name: "SharedPostgresPausedError", code: "ERR_OKE_POSTGRES_PAUSED" })
129
+ ?.error.code,
130
+ ).toBe("ServiceUnavailable");
131
+ });
132
+
133
+ test("sqlite UNIQUE / FK / NOT NULL / CHECK", () => {
134
+ const unique = sqlErrorToFailure({
135
+ name: "SQLiteError",
136
+ message: "UNIQUE constraint failed: users.email",
137
+ errno: 2067,
138
+ });
139
+ expect(unique?.error.code).toBe("Conflict");
140
+ expect(unique?.error.data).toMatchObject({ table: "users", column: "email" });
141
+
142
+ const fk = sqlErrorToFailure({
143
+ name: "SQLiteError",
144
+ message: "FOREIGN KEY constraint failed",
145
+ errno: 787,
146
+ });
147
+ expect(fk?.error.code).toBe("ForeignKey");
148
+
149
+ const notNull = sqlErrorToFailure({
150
+ name: "SQLiteError",
151
+ message: "NOT NULL constraint failed: posts.title",
152
+ });
153
+ expect(notNull?.error.data).toMatchObject({
154
+ reason: "not_null",
155
+ table: "posts",
156
+ column: "title",
157
+ });
158
+
159
+ const check = sqlErrorToFailure({
160
+ name: "SQLiteError",
161
+ message: "CHECK constraint failed: positive_clicks",
162
+ });
163
+ expect(check?.error.data).toMatchObject({ reason: "check", constraint: "positive_clicks" });
164
+
165
+ const pk = sqlErrorToFailure({
166
+ name: "SQLiteError",
167
+ message: "PRIMARY KEY constraint failed: users.id",
168
+ errno: 1555,
169
+ });
170
+ expect(pk?.error.code).toBe("Conflict");
171
+ expect(pk?.error.data).toMatchObject({ table: "users", column: "id" });
172
+ });
173
+
174
+ test("sqlite busy left thrown until retryable: unavailable", () => {
175
+ const busy = {
176
+ name: "SQLiteError",
177
+ code: "SQLITE_BUSY",
178
+ errno: 5,
179
+ message: "database is locked",
180
+ };
181
+ expect(isRetryableSqlError(busy)).toBe(true);
182
+ expect(sqlErrorToFailure(busy, { retryable: "leave" })).toBeUndefined();
183
+ expect(sqlErrorToFailure(busy, { retryable: "unavailable" })?.error.code).toBe(
184
+ "ServiceUnavailable",
185
+ );
186
+ });
187
+
188
+ test("unknown SQLSTATE → DatabaseError unknown; non-SQL stays undefined", () => {
189
+ const other = sqlErrorToFailure({ code: "22012", message: "division by zero" });
190
+ expect(other?.error.code).toBe("DatabaseError");
191
+ expect(other?.error.data).toMatchObject({ reason: "unknown", sqlstate: "22012" });
192
+ expect(JSON.stringify(other)).not.toContain("division");
193
+
194
+ expect(sqlErrorToFailure(new Error("boom"))).toBeUndefined();
195
+ expect(isFlowFailure(sqlErrorToFailure({ code: "23505" }))).toBe(true);
196
+ });
197
+ });