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
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `fs` / `s3` files errors → Forbidden / ServiceUnavailable.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { isFlowFailure } from "../../kernel/hooks.ts";
7
+ import { createS3FakeClient, s3Driver } from "../../drivers/s3.ts";
8
+ import { fsDriver } from "../../drivers/fs.ts";
9
+ import { memoryDrivers } from "../../drivers/memory.ts";
10
+ import { store } from "./declare.ts";
11
+ import { filesErrorToFailure, isRetryableFilesError } from "./files-errors.ts";
12
+ import { createStoreRuntime, type FilesStoreFxHandle } from "./runtime.ts";
13
+
14
+ describe("filesErrorToFailure", () => {
15
+ test("AccessDenied / EACCES → Forbidden without copying message", () => {
16
+ const denied = filesErrorToFailure(
17
+ Object.assign(new Error("Access Denied"), { code: "AccessDenied", status: 403 }),
18
+ );
19
+ expect(denied?.error.code).toBe("Forbidden");
20
+ expect(JSON.stringify(denied)).not.toContain("Access Denied");
21
+
22
+ expect(
23
+ filesErrorToFailure(Object.assign(new Error("permission denied"), { code: "EACCES" }))?.error
24
+ .code,
25
+ ).toBe("Forbidden");
26
+ expect(isFlowFailure(denied)).toBe(true);
27
+ });
28
+
29
+ test("Invalid object key → Forbidden without copying the key", () => {
30
+ const mapped = filesErrorToFailure(new Error("Invalid object key: ../secret"));
31
+ expect(mapped?.error.code).toBe("Forbidden");
32
+ expect(mapped?.error.data).toMatchObject({ reason: "invalid_key" });
33
+ expect(JSON.stringify(mapped)).not.toContain("../secret");
34
+ });
35
+
36
+ test("NoSuchBucket / ENOSPC / connection → ServiceUnavailable", () => {
37
+ expect(filesErrorToFailure(new Error("The specified bucket does not exist"))?.error.code).toBe(
38
+ "ServiceUnavailable",
39
+ );
40
+ expect(
41
+ filesErrorToFailure(Object.assign(new Error("No space left on device"), { code: "ENOSPC" }))
42
+ ?.error.code,
43
+ ).toBe("ServiceUnavailable");
44
+ expect(
45
+ filesErrorToFailure(
46
+ Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }),
47
+ )?.error.code,
48
+ ).toBe("ServiceUnavailable");
49
+ expect(
50
+ JSON.stringify(filesErrorToFailure(new Error("The specified bucket does not exist"))),
51
+ ).not.toContain("specified bucket");
52
+ });
53
+
54
+ test("SlowDown / EBUSY left thrown until retryable: unavailable", () => {
55
+ const slow = Object.assign(new Error("Please reduce your request rate"), { code: "SlowDown" });
56
+ expect(isRetryableFilesError(slow)).toBe(true);
57
+ expect(filesErrorToFailure(slow, { retryable: "leave" })).toBeUndefined();
58
+ expect(filesErrorToFailure(slow, { retryable: "unavailable" })?.error.code).toBe(
59
+ "ServiceUnavailable",
60
+ );
61
+ expect(
62
+ filesErrorToFailure(Object.assign(new Error("resource busy"), { code: "EBUSY" }), {
63
+ retryable: "leave",
64
+ }),
65
+ ).toBeUndefined();
66
+ });
67
+
68
+ test("NoSuchKey and generic throws stay unmapped", () => {
69
+ expect(
70
+ filesErrorToFailure(
71
+ Object.assign(new Error("The specified key does not exist"), { code: "NoSuchKey" }),
72
+ ),
73
+ ).toBeUndefined();
74
+ expect(filesErrorToFailure(new Error("boom"))).toBeUndefined();
75
+ });
76
+
77
+ test("walks .cause and S3 XML Code", () => {
78
+ const wrapped = {
79
+ message: "put failed",
80
+ cause: new Error("<?xml><Error><Code>AccessDenied</Code></Error>"),
81
+ };
82
+ expect(filesErrorToFailure(wrapped)?.error.code).toBe("Forbidden");
83
+ expect(JSON.stringify(filesErrorToFailure(wrapped))).not.toContain("put failed");
84
+ });
85
+ });
86
+
87
+ describe("openFiles maps S3 throws", () => {
88
+ test("AccessDenied on put becomes Forbidden", async () => {
89
+ const fake = createS3FakeClient();
90
+ const inner = fake.file.bind(fake);
91
+ fake.file = (key: string) => {
92
+ const file = inner(key);
93
+ return {
94
+ ...file,
95
+ write: async () => {
96
+ throw Object.assign(new Error("Access Denied"), { code: "AccessDenied", status: 403 });
97
+ },
98
+ };
99
+ };
100
+ const uploads = store.files("uploads");
101
+ const runtime = createStoreRuntime({
102
+ drivers: {
103
+ files: s3Driver,
104
+ kv: memoryDrivers.kv,
105
+ index: memoryDrivers.index,
106
+ },
107
+ files: { uploads: { client: fake } },
108
+ });
109
+ runtime.register(uploads);
110
+ const handle = (await runtime.open(uploads, { effects: {} })) as FilesStoreFxHandle;
111
+ try {
112
+ await handle.put("a.bin", new Uint8Array([1]));
113
+ throw new Error("expected mapped failure");
114
+ } catch (err) {
115
+ expect(isFlowFailure(err)).toBe(true);
116
+ expect((err as { error: { code: string } }).error.code).toBe("Forbidden");
117
+ expect(JSON.stringify(err)).not.toContain("Access Denied");
118
+ }
119
+ await runtime.close();
120
+ });
121
+
122
+ test("fs path-escape key becomes Forbidden", async () => {
123
+ const uploads = store.files("escapes");
124
+ const runtime = createStoreRuntime({
125
+ drivers: {
126
+ files: fsDriver,
127
+ kv: memoryDrivers.kv,
128
+ index: memoryDrivers.index,
129
+ },
130
+ files: { escapes: { root: `${process.env.TMPDIR ?? "/tmp"}/oke-files-escape` } },
131
+ });
132
+ runtime.register(uploads);
133
+ const handle = (await runtime.open(uploads, { effects: {} })) as FilesStoreFxHandle;
134
+ try {
135
+ await handle.put("../secret", "x");
136
+ throw new Error("expected mapped failure");
137
+ } catch (err) {
138
+ expect(isFlowFailure(err)).toBe(true);
139
+ expect((err as { error: { code: string; data?: { reason?: string } } }).error.code).toBe(
140
+ "Forbidden",
141
+ );
142
+ expect((err as { error: { data?: { reason?: string } } }).error.data?.reason).toBe(
143
+ "invalid_key",
144
+ );
145
+ expect(JSON.stringify(err)).not.toContain("../secret");
146
+ }
147
+ await runtime.close();
148
+ });
149
+ });
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Map `fs` / `s3` files-driver errors to typed {@link FlowFailure} values.
3
+ *
4
+ * Lives on the store path — not the kernel edge graph. Availability and
5
+ * throttle replies become `ServiceUnavailable`; access / path-escape become
6
+ * `Forbidden`. Missing `get` (`null`) is not auto-`NotFound`.
7
+ */
8
+
9
+ import { fail } from "../../kernel/fail-helpers.ts";
10
+ import type { FlowFailure } from "../../kernel/errors.ts";
11
+ import type { BuiltinErrorMap } from "../../kernel/builtin-errors.ts";
12
+
13
+ /** How to treat SlowDown / EBUSY / AWS `InternalError`. */
14
+ export type FilesRetryableMode = "leave" | "unavailable";
15
+
16
+ /** Options for {@link filesErrorToFailure}. */
17
+ export interface FilesErrorToFailureOptions {
18
+ /**
19
+ * `leave` — return `undefined` so {@link flow.retry} can rethrow.
20
+ * `unavailable` — map to `ServiceUnavailable` after retries exhaust.
21
+ */
22
+ readonly retryable?: FilesRetryableMode;
23
+ }
24
+
25
+ type FilesFailure = FlowFailure<
26
+ BuiltinErrorMap["Forbidden"] | BuiltinErrorMap["ServiceUnavailable"]
27
+ >;
28
+
29
+ type FilesErrShape = {
30
+ readonly name?: unknown;
31
+ readonly code?: unknown;
32
+ readonly errno?: unknown;
33
+ readonly status?: unknown;
34
+ readonly statusCode?: unknown;
35
+ readonly message?: unknown;
36
+ readonly cause?: unknown;
37
+ };
38
+
39
+ const RETRY_TOKEN = new Set([
40
+ "SLOWDOWN",
41
+ "REQUESTTIMEOUT",
42
+ "INTERNALERROR",
43
+ "REDUCEYOURREQUESTRATE",
44
+ "PRIORREQUESTNOTCOMPLETE",
45
+ "OPERATIONABORTED",
46
+ "EBUSY",
47
+ "EAGAIN",
48
+ "EWOULDBLOCK",
49
+ "EIO",
50
+ ]);
51
+ const UNAVAILABLE_TOKEN = new Set([
52
+ "NOSUCHBUCKET",
53
+ "SERVICEUNAVAILABLE",
54
+ "ENOSPC",
55
+ "EDQUOT",
56
+ "EMFILE",
57
+ "ENFILE",
58
+ "ECONNREFUSED",
59
+ "ECONNRESET",
60
+ "ETIMEDOUT",
61
+ "ENOTFOUND",
62
+ "EPIPE",
63
+ "ENETUNREACH",
64
+ "EAI_AGAIN",
65
+ "ECONNABORTED",
66
+ ]);
67
+ const FORBIDDEN_TOKEN = new Set([
68
+ "ACCESSDENIED",
69
+ "ACCESSFORBIDDEN",
70
+ "ALLACCESSDISABLED",
71
+ "INVALIDACCESSKEYID",
72
+ "SIGNATUREDOESNOTMATCH",
73
+ "INVALIDSECURITY",
74
+ "EXPIREDTOKEN",
75
+ "TOKENREFRESHREQUIRED",
76
+ "ACCOUNTPROBLEM",
77
+ "EACCES",
78
+ "EPERM",
79
+ "EROFS",
80
+ ]);
81
+
82
+ /**
83
+ * True when `err` is a throttle / busy files failure.
84
+ *
85
+ * Left thrown during retries; mapped to `ServiceUnavailable` after exhaust.
86
+ *
87
+ * @param err - Caught driver error
88
+ */
89
+ export function isRetryableFilesError(err: unknown): boolean {
90
+ return walk(err, (node) => retrySignal(node));
91
+ }
92
+
93
+ /**
94
+ * Map an `fs` / `s3` driver throw to `Forbidden` or `ServiceUnavailable`,
95
+ * or `undefined` when it is not a known files failure.
96
+ *
97
+ * @param err - Caught value from `fx.store` files ops
98
+ * @param options - Retryable handling
99
+ */
100
+ export function filesErrorToFailure(
101
+ err: unknown,
102
+ options: FilesErrorToFailureOptions = {},
103
+ ): FilesFailure | undefined {
104
+ const retryable = options.retryable ?? "leave";
105
+ let current: unknown = err;
106
+ for (let i = 0; i < 4; i++) {
107
+ if (!current || typeof current !== "object") break;
108
+ if (isInvalidObjectKey(current)) return fail.forbidden({ reason: "invalid_key" });
109
+ if (retrySignal(current)) {
110
+ if (retryable === "leave") return undefined;
111
+ return fail.serviceUnavailable();
112
+ }
113
+ if (isForbidden(current)) return fail.forbidden();
114
+ if (isUnavailable(current)) return fail.serviceUnavailable();
115
+ current = causeOf(current);
116
+ }
117
+ return undefined;
118
+ }
119
+
120
+ function retrySignal(err: unknown): boolean {
121
+ if (RETRY_TOKEN.has(filesToken(err))) return true;
122
+ return httpStatus(err) === 429;
123
+ }
124
+
125
+ function isForbidden(err: unknown): boolean {
126
+ if (FORBIDDEN_TOKEN.has(filesToken(err))) return true;
127
+ if (httpStatus(err) === 403) return true;
128
+ const message = errMessage(err);
129
+ return /access denied|not authorized to perform/i.test(message);
130
+ }
131
+
132
+ function isUnavailable(err: unknown): boolean {
133
+ const token = filesToken(err);
134
+ if (UNAVAILABLE_TOKEN.has(token)) return true;
135
+ const status = httpStatus(err);
136
+ if (status === 502 || status === 503 || status === 504) return true;
137
+ const message = errMessage(err);
138
+ if (/specified bucket does not exist|NoSuchBucket/i.test(message)) return true;
139
+ if (/connection (closed|refused|reset|timed out)/i.test(message)) return true;
140
+ if (/socket hang up|connect econn|read econn/i.test(message)) return true;
141
+ if (/ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOSPC|EMFILE/i.test(message)) return true;
142
+ return false;
143
+ }
144
+
145
+ function isInvalidObjectKey(err: unknown): boolean {
146
+ return /^Invalid object key:/i.test(errMessage(err));
147
+ }
148
+
149
+ function filesToken(err: unknown): string {
150
+ if (!err || typeof err !== "object") return "";
151
+ const e = err as FilesErrShape;
152
+ if (typeof e.code === "string" && e.code.length > 0) {
153
+ return e.code.toUpperCase().replace(/[^A-Z0-9]/g, "");
154
+ }
155
+ const xml = /<Code>([A-Za-z][A-Za-z0-9]+)<\/Code>/i.exec(errMessage(err));
156
+ if (xml?.[1]) return xml[1].toUpperCase();
157
+ const message = errMessage(err).trim();
158
+ const first = /^([A-Za-z][A-Za-z0-9_]+)/.exec(message)?.[1];
159
+ return first ? first.toUpperCase().replace(/[^A-Z0-9]/g, "") : "";
160
+ }
161
+
162
+ function httpStatus(err: unknown): number | undefined {
163
+ if (!err || typeof err !== "object") return undefined;
164
+ const e = err as FilesErrShape;
165
+ if (typeof e.status === "number") return e.status;
166
+ if (typeof e.statusCode === "number") return e.statusCode;
167
+ return undefined;
168
+ }
169
+
170
+ function errMessage(err: unknown): string {
171
+ if (!err || typeof err !== "object") return "";
172
+ const message = (err as FilesErrShape).message;
173
+ return typeof message === "string" ? message : "";
174
+ }
175
+
176
+ function causeOf(err: unknown): unknown {
177
+ if (!err || typeof err !== "object") return undefined;
178
+ return (err as FilesErrShape).cause;
179
+ }
180
+
181
+ function walk(err: unknown, pred: (node: unknown) => boolean): boolean {
182
+ let current: unknown = err;
183
+ for (let i = 0; i < 4; i++) {
184
+ if (pred(current)) return true;
185
+ current = causeOf(current);
186
+ if (current === undefined) break;
187
+ }
188
+ return false;
189
+ }
@@ -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
+ }
@@ -2,7 +2,7 @@
2
2
  * Load plugin table contributions from a live `oke()` app entry.
3
3
  *
4
4
  * Used by `oke db` emit so `.plug()`-registered `field.*` tables merge into
5
- * `schema.drizzle.ts` — not only at unit-test merge call sites.
5
+ * `drizzle.ts` — not only at unit-test merge call sites.
6
6
  */
7
7
 
8
8
  import { resolve } from "node:path";
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
6
+ import { lte } from "drizzle-orm";
6
7
  import { timestamp, text, pgTable, integer } from "drizzle-orm/pg-core";
7
8
  import { pgliteDriver } from "../../drivers/pglite.ts";
8
9
  import type { SqlConnection } from "../../drivers/types.ts";
@@ -21,11 +22,19 @@ describe("coerceTemporalBindValue", () => {
21
22
  expect((day as Date).getTime()).toBe(0);
22
23
  });
23
24
 
24
- test("leaves non-temporal and non-number values alone", () => {
25
+ test("ISO datetime string → Date for TIMESTAMP and DATE", () => {
26
+ const at = coerceTemporalBindValue("TIMESTAMP", "2024-01-01T00:00:00.000Z");
27
+ expect(at).toBeInstanceOf(Date);
28
+ expect((at as Date).toISOString()).toBe("2024-01-01T00:00:00.000Z");
29
+
30
+ const day = coerceTemporalBindValue("DATE", "2024-01-01T00:00:00.000Z");
31
+ expect(day).toBeInstanceOf(Date);
32
+ expect((day as Date).toISOString()).toBe("2024-01-01T00:00:00.000Z");
33
+ });
34
+
35
+ test("leaves non-temporal and unparseable values alone", () => {
25
36
  expect(coerceTemporalBindValue("INTEGER", 42)).toBe(42);
26
- expect(coerceTemporalBindValue("TIMESTAMP", "2024-01-01T00:00:00.000Z")).toBe(
27
- "2024-01-01T00:00:00.000Z",
28
- );
37
+ expect(coerceTemporalBindValue("TIMESTAMP", "not-an-instant")).toBe("not-an-instant");
29
38
  const d = new Date(5);
30
39
  expect(coerceTemporalBindValue("TIMESTAMP", d)).toBe(d);
31
40
  expect(coerceTemporalBindValue("TIMESTAMP", null)).toBe(null);
@@ -73,6 +82,17 @@ describe("prepareInsertRow / prepareUpdateRow — timestamp columns", () => {
73
82
  expect((prepared.created_at as Date).getTime()).toBe(42);
74
83
  expect(prepared.hits).toBe(7);
75
84
  });
85
+
86
+ test("abstract schema: insert coerces ISO datetime on timestamp columns", () => {
87
+ const prepared = prepareInsertRow(notes, {
88
+ id: "welcome",
89
+ createdAt: "2026-09-14T12:00:00.000Z",
90
+ archivedAt: null,
91
+ });
92
+ expect(prepared.created_at).toBeInstanceOf(Date);
93
+ expect((prepared.created_at as Date).toISOString()).toBe("2026-09-14T12:00:00.000Z");
94
+ expect(prepared.archived_at).toBe(null);
95
+ });
76
96
  });
77
97
 
78
98
  describe("SqlStoreHandle upsert — epoch-ms into timestamp (Postgres)", () => {
@@ -120,4 +140,37 @@ describe("SqlStoreHandle upsert — epoch-ms into timestamp (Postgres)", () => {
120
140
  expect(rows).toHaveLength(1);
121
141
  expect(Number(rows[0]!.createdAt)).toBe(1);
122
142
  });
143
+
144
+ test("select/update WHERE coerces epoch-ms on timestamp columns", async () => {
145
+ await handle.insert(notesTs).values({ id: "old", title: "old", createdAt: 1 });
146
+ await handle.insert(notesTs).values({ id: "new", title: "new", createdAt: 100 });
147
+
148
+ // Drizzle types timestamp `{ mode: "date" }` as Date; the store still
149
+ // coerces epoch-ms binds (`fx.clock.now()`) at WHERE compile time.
150
+ const before = 50 as unknown as Date;
151
+ const rows = await handle.select().from(notesTs).where(lte(notesTs.createdAt, before));
152
+ expect(rows).toHaveLength(1);
153
+ expect(rows[0]!.id).toBe("old");
154
+
155
+ const n = await handle
156
+ .update(notesTs)
157
+ .set({ title: "archived" })
158
+ .where(lte(notesTs.createdAt, before));
159
+ expect(n).toBe(1);
160
+ const archived = await handle.findById(notesTs, "old");
161
+ expect(archived?.title).toBe("archived");
162
+ });
163
+
164
+ test("upsert accepts ISO datetime strings without Postgres type error", async () => {
165
+ const first = await handle.upsert(
166
+ notesTs,
167
+ { id: "welcome" },
168
+ { id: "welcome", title: "Welcome", createdAt: "2026-09-14T12:00:00.000Z" },
169
+ );
170
+ expect(first.status).toBe("upserted");
171
+
172
+ const rows = await handle.select().from(notesTs);
173
+ expect(rows).toHaveLength(1);
174
+ expect(new Date(rows[0]!.createdAt as Date).toISOString()).toBe("2026-09-14T12:00:00.000Z");
175
+ });
123
176
  });