okengine 0.20.0 → 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 (86) hide show
  1. package/package.json +2 -1
  2. package/site/content/docs/client/calling.mdx +117 -29
  3. package/site/content/docs/client/index.mdx +3 -3
  4. package/site/content/docs/elements/flow/http.mdx +18 -15
  5. package/site/content/docs/elements/flow/index.mdx +26 -18
  6. package/site/content/docs/elements/gate/tenancy.mdx +1 -1
  7. package/site/content/docs/elements/store/files.mdx +11 -5
  8. package/site/content/docs/elements/store/index.mdx +2 -3
  9. package/site/content/docs/elements/store/kv.mdx +8 -2
  10. package/site/content/docs/elements/store/sql.mdx +9 -6
  11. package/site/content/docs/elements/vault/index.mdx +11 -8
  12. package/site/content/docs/elements/vault/secrets.mdx +6 -3
  13. package/site/content/docs/index.mdx +1 -1
  14. package/site/content/docs/recipes/rustfs.mdx +1 -1
  15. package/site/content/docs/reference/configuration.mdx +1 -1
  16. package/site/content/docs/reference/errors.mdx +199 -25
  17. package/site/content/docs/reference/fx.mdx +6 -1
  18. package/site/content/docs/understand/the-architecture.mdx +2 -2
  19. package/site/content/docs/understand/try-it.mdx +758 -25
  20. package/src/cli/dev-app-runner.ts +2 -1
  21. package/src/cli/start.ts +2 -1
  22. package/src/client/create.ts +3 -3
  23. package/src/client/explain.test.ts +252 -0
  24. package/src/client/explain.ts +272 -0
  25. package/src/client/live.test.ts +44 -0
  26. package/src/client/notes-contract.test.ts +10 -0
  27. package/src/client/sse.ts +6 -2
  28. package/src/client/transport.test.ts +67 -0
  29. package/src/client/transport.ts +24 -40
  30. package/src/client/types.ts +17 -6
  31. package/src/client-react/live-resource.ts +6 -2
  32. package/src/compiler/response.ts +17 -27
  33. package/src/console/server/invoke-user-flow.test.ts +8 -2
  34. package/src/console/server/invoke-user-flow.ts +12 -18
  35. package/src/console/server/security.gate.test.ts +1 -1
  36. package/src/console/ui-next/dist/assets/{access-page-DFLu0wTA.js → access-page-tIbsiphz.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{agent-disclosure-DGscxaF5.js → agent-disclosure-CSKumwS2.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{cache-glyph-BGmRZk7d.js → cache-glyph-BCC-DxKT.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{call-pii-button--feUYxvG.js → call-pii-button-jOmYISdc.js} +1 -1
  40. package/src/console/ui-next/dist/assets/{collapsible-JWvpaiGY.js → collapsible-DC2xNaAb.js} +1 -1
  41. package/src/console/ui-next/dist/assets/{duration-tone-D9yCJG4n.js → duration-tone-CwoV56jn.js} +1 -1
  42. package/src/console/ui-next/dist/assets/{flows-page-Bs6MD9GB.js → flows-page-gT1lsWQK.js} +1 -1
  43. package/src/console/ui-next/dist/assets/{highlighted-json-xH8MrEnv.js → highlighted-json-C2GZEJNI.js} +1 -1
  44. package/src/console/ui-next/dist/assets/{http-method-C4vB6ZIw.js → http-method-BdYjcIrD.js} +1 -1
  45. package/src/console/ui-next/dist/assets/{index-yTCY4AcS.js → index-Cul17AcV.js} +3 -3
  46. package/src/console/ui-next/dist/assets/{observability-page-BxJ3R6dU.js → observability-page-oY9vdYBk.js} +1 -1
  47. package/src/console/ui-next/dist/assets/{replica-lag-QRKB_IE8.js → replica-lag-C4QdAF7J.js} +1 -1
  48. package/src/console/ui-next/dist/assets/{request-meta-DqZ-fMu5.js → request-meta-C43DHyld.js} +1 -1
  49. package/src/console/ui-next/dist/assets/{store-page-Dixb6L7a.js → store-page-CL0D9dOq.js} +1 -1
  50. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CazhjtiU.js → trace-detail-sheet-Dcpo4Us_.js} +1 -1
  51. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DlnqYKfr.js → tree-expand-toggle-CKJTuv43.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{units-page-BXTLjU2-.js → units-page-1PlT19ft.js} +1 -1
  53. package/src/console/ui-next/dist/assets/{vault-page-39KR__bc.js → vault-page-BwZ9YjTW.js} +1 -1
  54. package/src/console/ui-next/dist/index.html +1 -1
  55. package/src/docker/docker.test.ts +3 -3
  56. package/src/docker/images-config.test.ts +4 -4
  57. package/src/docker/stack-id.test.ts +1 -1
  58. package/src/elements/store/files-errors.test.ts +149 -0
  59. package/src/elements/store/files-errors.ts +189 -0
  60. package/src/elements/store/kv-errors.test.ts +98 -0
  61. package/src/elements/store/kv-errors.ts +139 -0
  62. package/src/elements/store/resource.ts +11 -7
  63. package/src/elements/store/runtime.ts +18 -13
  64. package/src/elements/store/sql-errors.test.ts +197 -0
  65. package/src/elements/store/sql-errors.ts +294 -0
  66. package/src/elements/store/sql-session.test.ts +52 -0
  67. package/src/elements/store/sql-session.ts +6 -0
  68. package/src/elements/store/store-errors.ts +47 -0
  69. package/src/http.ts +9 -1
  70. package/src/i18n/catalogs/ar.ts +18 -0
  71. package/src/i18n/catalogs/en.ts +18 -0
  72. package/src/index.ts +9 -1
  73. package/src/kernel/app.ts +14 -2
  74. package/src/kernel/builtin-errors.test.ts +117 -0
  75. package/src/kernel/builtin-errors.ts +129 -0
  76. package/src/kernel/call.test.ts +182 -0
  77. package/src/kernel/errors-vault.ts +16 -0
  78. package/src/kernel/errors.registry.test.ts +7 -0
  79. package/src/kernel/errors.ts +94 -21
  80. package/src/kernel/fail-helpers.ts +34 -0
  81. package/src/kernel/fx.test.ts +8 -0
  82. package/src/kernel/fx.ts +12 -6
  83. package/src/kernel/index.ts +12 -1
  84. package/src/runtime/dev-request-log.ts +29 -11
  85. package/src/term.test.ts +76 -0
  86. package/src/term.ts +166 -3
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Redis-wire KV errors → ServiceUnavailable (availability / retry exhaust).
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { isFlowFailure } from "../../kernel/hooks.ts";
7
+ import { createRedisFakeClient, redisDriver } from "../../drivers/redis.ts";
8
+ import { memoryDrivers } from "../../drivers/memory.ts";
9
+ import { store } from "./declare.ts";
10
+ import { isRetryableKvError, kvErrorToFailure } from "./kv-errors.ts";
11
+ import { createStoreRuntime, type KvStoreFxHandle } from "./runtime.ts";
12
+
13
+ describe("kvErrorToFailure", () => {
14
+ test("connection / cluster → ServiceUnavailable without copying message", () => {
15
+ const closed = kvErrorToFailure(
16
+ Object.assign(new Error("Connection closed"), { code: "ECONNREFUSED" }),
17
+ );
18
+ expect(closed?.error.code).toBe("ServiceUnavailable");
19
+ expect(JSON.stringify(closed)).not.toContain("Connection closed");
20
+
21
+ expect(kvErrorToFailure(new Error("CLUSTERDOWN The cluster is down"))?.error.code).toBe(
22
+ "ServiceUnavailable",
23
+ );
24
+ expect(
25
+ kvErrorToFailure(new Error("READONLY You can't write against a read only replica."))?.error
26
+ .code,
27
+ ).toBe("ServiceUnavailable");
28
+ expect(
29
+ kvErrorToFailure(new Error("OOM command not allowed when used memory > 'maxmemory'."))?.error
30
+ .code,
31
+ ).toBe("ServiceUnavailable");
32
+ expect(isFlowFailure(closed)).toBe(true);
33
+ });
34
+
35
+ test("BUSY / TRYAGAIN left thrown until retryable: unavailable", () => {
36
+ const busy = new Error("BUSY Redis is busy running a script");
37
+ expect(isRetryableKvError(busy)).toBe(true);
38
+ expect(kvErrorToFailure(busy, { retryable: "leave" })).toBeUndefined();
39
+ expect(kvErrorToFailure(busy, { retryable: "unavailable" })?.error.code).toBe(
40
+ "ServiceUnavailable",
41
+ );
42
+ expect(
43
+ kvErrorToFailure(new Error("TRYAGAIN Multiple keys request during rehashing"), {
44
+ retryable: "leave",
45
+ }),
46
+ ).toBeUndefined();
47
+ });
48
+
49
+ test("WRONGTYPE / NOSCRIPT stay unmapped", () => {
50
+ expect(
51
+ kvErrorToFailure(
52
+ new Error("WRONGTYPE Operation against a key holding the wrong kind of value"),
53
+ ),
54
+ ).toBeUndefined();
55
+ expect(
56
+ kvErrorToFailure(new Error("NOSCRIPT No matching script. Please use EVAL.")),
57
+ ).toBeUndefined();
58
+ expect(kvErrorToFailure(new Error("boom"))).toBeUndefined();
59
+ });
60
+
61
+ test("walks .cause for connection failures", () => {
62
+ const wrapped = {
63
+ message: "kv get failed",
64
+ cause: Object.assign(new Error("Connection closed"), { code: "ECONNRESET" }),
65
+ };
66
+ expect(kvErrorToFailure(wrapped)?.error.code).toBe("ServiceUnavailable");
67
+ expect(JSON.stringify(kvErrorToFailure(wrapped))).not.toContain("kv get failed");
68
+ });
69
+ });
70
+
71
+ describe("openKv maps Redis throws", () => {
72
+ test("connection failure on get becomes ServiceUnavailable", async () => {
73
+ const fake = createRedisFakeClient();
74
+ fake.get = async () => {
75
+ throw Object.assign(new Error("Connection closed"), { code: "ECONNREFUSED" });
76
+ };
77
+ const sessions = store.kv("sessions");
78
+ const runtime = createStoreRuntime({
79
+ drivers: {
80
+ kv: redisDriver,
81
+ files: memoryDrivers.files,
82
+ index: memoryDrivers.index,
83
+ },
84
+ kv: { sessions: { client: fake } },
85
+ });
86
+ runtime.register(sessions);
87
+ const handle = (await runtime.open(sessions, { effects: {} })) as KvStoreFxHandle;
88
+ try {
89
+ await handle.get("sid");
90
+ throw new Error("expected mapped failure");
91
+ } catch (err) {
92
+ expect(isFlowFailure(err)).toBe(true);
93
+ expect((err as { error: { code: string } }).error.code).toBe("ServiceUnavailable");
94
+ expect(JSON.stringify(err)).not.toContain("Connection closed");
95
+ }
96
+ await runtime.close();
97
+ });
98
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Map Redis-wire KV errors to typed {@link FlowFailure} values.
3
+ *
4
+ * Lives on the store path — not the kernel edge graph. Availability and
5
+ * retryable script/cluster replies become `ServiceUnavailable`. Programmer
6
+ * mistakes (`WRONGTYPE`, `NOSCRIPT`) stay unmapped so the HTTP encoder
7
+ * sanitizes them as `InternalError`.
8
+ */
9
+
10
+ import { fail } from "../../kernel/fail-helpers.ts";
11
+ import type { FlowFailure } from "../../kernel/errors.ts";
12
+ import type { BuiltinErrorMap } from "../../kernel/builtin-errors.ts";
13
+
14
+ /** How to treat Redis `BUSY` / `TRYAGAIN` / `LOADING`. */
15
+ export type KvRetryableMode = "leave" | "unavailable";
16
+
17
+ /** Options for {@link kvErrorToFailure}. */
18
+ export interface KvErrorToFailureOptions {
19
+ /**
20
+ * `leave` — return `undefined` so {@link flow.retry} can rethrow.
21
+ * `unavailable` — map to `ServiceUnavailable` after retries exhaust.
22
+ */
23
+ readonly retryable?: KvRetryableMode;
24
+ }
25
+
26
+ type KvFailure = FlowFailure<BuiltinErrorMap["ServiceUnavailable"]>;
27
+
28
+ type KvErrShape = {
29
+ readonly name?: unknown;
30
+ readonly code?: unknown;
31
+ readonly errno?: unknown;
32
+ readonly message?: unknown;
33
+ readonly cause?: unknown;
34
+ };
35
+
36
+ const RETRY_TOKEN = new Set(["BUSY", "BUSYKEY", "TRYAGAIN", "LOADING"]);
37
+ const UNAVAILABLE_TOKEN = new Set([
38
+ "CLUSTERDOWN",
39
+ "MASTERDOWN",
40
+ "READONLY",
41
+ "MISCONF",
42
+ "OOM",
43
+ "MOVED",
44
+ "ASK",
45
+ "UNBLOCKED",
46
+ ]);
47
+ const CONN_CODE = new Set([
48
+ "ECONNREFUSED",
49
+ "ECONNRESET",
50
+ "ETIMEDOUT",
51
+ "ENOTFOUND",
52
+ "EPIPE",
53
+ "ENETUNREACH",
54
+ "EAI_AGAIN",
55
+ "ECONNABORTED",
56
+ ]);
57
+
58
+ /**
59
+ * True when `err` is a Redis `BUSY` / `TRYAGAIN` / `LOADING` reply.
60
+ *
61
+ * Left thrown during retries; mapped to `ServiceUnavailable` after exhaust.
62
+ *
63
+ * @param err - Caught driver error
64
+ */
65
+ export function isRetryableKvError(err: unknown): boolean {
66
+ return walk(err, (node) => retrySignal(node));
67
+ }
68
+
69
+ /**
70
+ * Map a Redis-wire KV error to `ServiceUnavailable`, or `undefined` when it
71
+ * is not an availability / retryable Redis failure.
72
+ *
73
+ * @param err - Caught value from `fx.store` KV ops
74
+ * @param options - Retryable handling
75
+ */
76
+ export function kvErrorToFailure(
77
+ err: unknown,
78
+ options: KvErrorToFailureOptions = {},
79
+ ): KvFailure | undefined {
80
+ const retryable = options.retryable ?? "leave";
81
+ let current: unknown = err;
82
+ for (let i = 0; i < 4; i++) {
83
+ if (!current || typeof current !== "object") break;
84
+ if (retrySignal(current)) {
85
+ if (retryable === "leave") return undefined;
86
+ return fail.serviceUnavailable();
87
+ }
88
+ if (isUnavailable(current)) return fail.serviceUnavailable();
89
+ current = causeOf(current);
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ function retrySignal(err: unknown): boolean {
95
+ return RETRY_TOKEN.has(redisToken(err));
96
+ }
97
+
98
+ function isUnavailable(err: unknown): boolean {
99
+ const token = redisToken(err);
100
+ if (UNAVAILABLE_TOKEN.has(token)) return true;
101
+ if (CONN_CODE.has(token)) return true;
102
+ if (/^ERR_REDIS_(CONNECTION|CONNECT|CLOSED|TIMEOUT|NETWORK)/.test(token)) return true;
103
+ const message = errMessage(err);
104
+ if (/max number of clients/i.test(message)) return true;
105
+ if (/connection (closed|refused|reset|timed out)/i.test(message)) return true;
106
+ if (/socket hang up|connect econn|read econn/i.test(message)) return true;
107
+ if (/ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EPIPE/i.test(message)) return true;
108
+ return false;
109
+ }
110
+
111
+ function redisToken(err: unknown): string {
112
+ if (!err || typeof err !== "object") return "";
113
+ const e = err as KvErrShape;
114
+ if (typeof e.code === "string" && e.code.length > 0) return e.code.toUpperCase();
115
+ const message = typeof e.message === "string" ? e.message.trim() : "";
116
+ const first = /^([A-Za-z][A-Za-z0-9_]+)/.exec(message)?.[1];
117
+ return first ? first.toUpperCase() : "";
118
+ }
119
+
120
+ function errMessage(err: unknown): string {
121
+ if (!err || typeof err !== "object") return "";
122
+ const message = (err as KvErrShape).message;
123
+ return typeof message === "string" ? message : "";
124
+ }
125
+
126
+ function causeOf(err: unknown): unknown {
127
+ if (!err || typeof err !== "object") return undefined;
128
+ return (err as KvErrShape).cause;
129
+ }
130
+
131
+ function walk(err: unknown, pred: (node: unknown) => boolean): boolean {
132
+ let current: unknown = err;
133
+ for (let i = 0; i < 4; i++) {
134
+ if (pred(current)) return true;
135
+ current = causeOf(current);
136
+ if (current === undefined) break;
137
+ }
138
+ return false;
139
+ }
@@ -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
 
@@ -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
+ });