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,294 @@
1
+ /**
2
+ * Map driver SQL errors to typed {@link FlowFailure} values.
3
+ *
4
+ * Lives on the store path — not the kernel edge graph. Public payloads are
5
+ * names only (constraint / table / column / sqlstate). Never copy
6
+ * `message` / `detail` / `hint`.
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 serialization / deadlock SQLSTATE (`40001` / `40P01`). */
14
+ export type SqlRetryableMode = "leave" | "unavailable";
15
+
16
+ /** Options for {@link sqlErrorToFailure}. */
17
+ export interface SqlErrorToFailureOptions {
18
+ /**
19
+ * `leave` — return `undefined` so {@link flow.retry} can rethrow.
20
+ * `unavailable` — map to `ServiceUnavailable` after retries exhaust.
21
+ */
22
+ readonly retryable?: SqlRetryableMode;
23
+ }
24
+
25
+ type SqlFailure = FlowFailure<
26
+ | BuiltinErrorMap["Conflict"]
27
+ | BuiltinErrorMap["DatabaseError"]
28
+ | BuiltinErrorMap["ServiceUnavailable"]
29
+ >;
30
+
31
+ type SqlErrShape = {
32
+ readonly name?: unknown;
33
+ readonly code?: unknown;
34
+ readonly errno?: unknown;
35
+ readonly message?: unknown;
36
+ readonly cause?: unknown;
37
+ readonly constraint?: unknown;
38
+ readonly table?: unknown;
39
+ readonly column?: unknown;
40
+ readonly table_name?: unknown;
41
+ readonly column_name?: unknown;
42
+ readonly constraint_name?: unknown;
43
+ };
44
+
45
+ const CONFLICT_STATE = new Set(["23505", "23P01"]);
46
+ const FOREIGN_KEY_STATE = new Set(["23503", "23001"]);
47
+ const NOT_NULL_STATE = new Set(["23502"]);
48
+ const CHECK_STATE = new Set(["23514"]);
49
+ const RETRY_STATE = new Set(["40001", "40P01", "55P03"]);
50
+ const TOO_LONG_STATE = new Set(["22001"]);
51
+ const OUT_OF_RANGE_STATE = new Set(["22003", "22008"]);
52
+ const INVALID_STATE = new Set(["22P02", "22007", "22023", "42804"]);
53
+ const UNAVAILABLE_STATE = new Set([
54
+ "53300",
55
+ "53100",
56
+ "53200",
57
+ "53400",
58
+ "57P01",
59
+ "57P02",
60
+ "57P03",
61
+ "57P04",
62
+ "57014",
63
+ "25P03",
64
+ ]);
65
+
66
+ /** bun:sqlite / libsql busy / locked (leave thrown for retry). */
67
+ const SQLITE_RETRY_ERRNO = new Set([5, 6, 261, 517]);
68
+ /** bun:sqlite PRIMARYKEY / ROWID unique. */
69
+ const SQLITE_CONFLICT_ERRNO = new Set([1555, 2067, 2579]);
70
+ const SQLITE_UNAVAILABLE_ERRNO = new Set([8, 10, 13, 14, 15]);
71
+
72
+ /**
73
+ * True when `err` is a serialization / deadlock / lock-busy failure.
74
+ *
75
+ * Left thrown during retries; mapped to `ServiceUnavailable` after exhaust.
76
+ *
77
+ * @param err - Caught driver error
78
+ */
79
+ export function isRetryableSqlError(err: unknown): boolean {
80
+ return walk(err, (node) => retrySignal(node));
81
+ }
82
+
83
+ /**
84
+ * Map a driver SQL error to a typed failure, or `undefined` when it is not a
85
+ * SQL constraint / connection failure (or is retryable under `leave`).
86
+ *
87
+ * @param err - Caught value from `query` / `exec`
88
+ * @param options - Retryable handling
89
+ */
90
+ export function sqlErrorToFailure(
91
+ err: unknown,
92
+ options: SqlErrorToFailureOptions = {},
93
+ ): SqlFailure | undefined {
94
+ const retryable = options.retryable ?? "leave";
95
+ let unknownNode: unknown;
96
+ let current: unknown = err;
97
+ for (let i = 0; i < 4; i++) {
98
+ if (!current || typeof current !== "object") break;
99
+ if (retrySignal(current)) {
100
+ if (retryable === "leave") return undefined;
101
+ return fail.serviceUnavailable();
102
+ }
103
+ if (isConnectionUnavailable(current)) return fail.serviceUnavailable();
104
+ const mapped = mapConstraint(current);
105
+ if (mapped) return mapped;
106
+ if (unknownNode === undefined && looksLikeSqlError(current)) unknownNode = current;
107
+ current = causeOf(current);
108
+ }
109
+ if (unknownNode !== undefined) {
110
+ return fail.database({ reason: "unknown", ...publicNames(unknownNode) });
111
+ }
112
+ return undefined;
113
+ }
114
+
115
+ function mapConstraint(err: unknown): SqlFailure | undefined {
116
+ const code = sqlCode(err);
117
+ const names = publicNames(err);
118
+
119
+ if (CONFLICT_STATE.has(code)) return fail.conflict(names);
120
+ if (FOREIGN_KEY_STATE.has(code)) return fail.foreignKey(names);
121
+ if (NOT_NULL_STATE.has(code)) return fail.database({ reason: "not_null", ...names });
122
+ if (CHECK_STATE.has(code)) return fail.database({ reason: "check", ...names });
123
+ if (TOO_LONG_STATE.has(code)) return fail.database({ reason: "too_long", ...names });
124
+ if (OUT_OF_RANGE_STATE.has(code)) return fail.database({ reason: "out_of_range", ...names });
125
+ if (INVALID_STATE.has(code)) return fail.database({ reason: "invalid", ...names });
126
+
127
+ const sqlite = sqliteConstraint(err);
128
+ if (sqlite === "unique") return fail.conflict(names);
129
+ if (sqlite === "fk") return fail.foreignKey(names);
130
+ if (sqlite === "not_null") return fail.database({ reason: "not_null", ...names });
131
+ if (sqlite === "check") return fail.database({ reason: "check", ...names });
132
+
133
+ return undefined;
134
+ }
135
+
136
+ function retrySignal(err: unknown): boolean {
137
+ const code = sqlCode(err);
138
+ if (RETRY_STATE.has(code)) return true;
139
+ const errno = sqlErrno(err);
140
+ if (errno !== undefined && SQLITE_RETRY_ERRNO.has(errno)) return true;
141
+ const named = sqlCode(err);
142
+ if (named === "SQLITE_BUSY" || named === "SQLITE_LOCKED" || named === "SQLITE_BUSY_SNAPSHOT") {
143
+ return true;
144
+ }
145
+ return false;
146
+ }
147
+
148
+ function isConnectionUnavailable(err: unknown): boolean {
149
+ const e = err as SqlErrShape;
150
+ const code = sqlCode(err);
151
+ if (code.startsWith("08")) return true;
152
+ if (code.startsWith("53")) return true;
153
+ if (UNAVAILABLE_STATE.has(code)) return true;
154
+ if (code.startsWith("ERR_POSTGRES_")) return true;
155
+ if (code === "ERR_OKE_POSTGRES_PAUSED") return true;
156
+ if (typeof e.name === "string" && e.name === "SharedPostgresPausedError") return true;
157
+ const errno = sqlErrno(err);
158
+ if (errno !== undefined && SQLITE_UNAVAILABLE_ERRNO.has(errno)) return true;
159
+ if (code === "SQLITE_READONLY" || code === "SQLITE_FULL" || code === "SQLITE_CANTOPEN") {
160
+ return true;
161
+ }
162
+ const message = typeof e.message === "string" ? e.message : "";
163
+ if (/postgres pools are paused/i.test(message)) return true;
164
+ return false;
165
+ }
166
+
167
+ function looksLikeSqlError(err: unknown): boolean {
168
+ const e = err as SqlErrShape;
169
+ const code = sqlCode(err);
170
+ if (/^[0-9A-Z]{5}$/.test(code)) return true;
171
+ if (code.startsWith("SQLITE_")) return true;
172
+ if (typeof e.name === "string" && /postgres|sqlite|pglite|libsql/i.test(e.name)) return true;
173
+ const message = typeof e.message === "string" ? e.message : "";
174
+ return /sqlstate|constraint|syntax error/i.test(message);
175
+ }
176
+
177
+ function sqlCode(err: unknown): string {
178
+ if (!err || typeof err !== "object") return "";
179
+ const code = (err as SqlErrShape).code;
180
+ return typeof code === "string" ? code : "";
181
+ }
182
+
183
+ function sqlErrno(err: unknown): number | undefined {
184
+ if (!err || typeof err !== "object") return undefined;
185
+ const errno = (err as SqlErrShape).errno;
186
+ return typeof errno === "number" ? errno : undefined;
187
+ }
188
+
189
+ function publicNames(err: unknown): {
190
+ readonly sqlstate?: string;
191
+ readonly constraint?: string;
192
+ readonly table?: string;
193
+ readonly column?: string;
194
+ } {
195
+ const e = err as SqlErrShape;
196
+ const sqlstate = sqlCode(err);
197
+ const constraint = firstString(e.constraint, e.constraint_name);
198
+ const table = firstString(e.table, e.table_name);
199
+ const column = firstString(e.column, e.column_name);
200
+ const fromMessage = parseSqliteNames(typeof e.message === "string" ? e.message : "");
201
+ const out: {
202
+ sqlstate?: string;
203
+ constraint?: string;
204
+ table?: string;
205
+ column?: string;
206
+ } = {};
207
+ if (/^[0-9A-Z]{5}$/.test(sqlstate)) out.sqlstate = sqlstate;
208
+ const constraintName = constraint ?? fromMessage.constraint;
209
+ if (constraintName) out.constraint = constraintName;
210
+ const tableName = table ?? fromMessage.table;
211
+ const columnName = column ?? fromMessage.column;
212
+ if (tableName) out.table = tableName;
213
+ if (columnName) out.column = columnName;
214
+ return out;
215
+ }
216
+
217
+ function firstString(...values: readonly unknown[]): string | undefined {
218
+ for (const v of values) {
219
+ if (typeof v === "string" && v.length > 0) return v;
220
+ }
221
+ return undefined;
222
+ }
223
+
224
+ function parseSqliteNames(message: string): {
225
+ table?: string;
226
+ column?: string;
227
+ constraint?: string;
228
+ } {
229
+ const dotted =
230
+ /(?:UNIQUE|NOT NULL|PRIMARY KEY) constraint failed:\s*([A-Za-z_][\w]*)\.([A-Za-z_][\w]*)/i.exec(
231
+ message,
232
+ );
233
+ if (dotted?.[1] && dotted[2]) {
234
+ return { table: dotted[1], column: dotted[2] };
235
+ }
236
+ const check = /CHECK constraint failed:\s*([A-Za-z_][\w]*)/i.exec(message);
237
+ if (check?.[1]) return { constraint: check[1] };
238
+ return {};
239
+ }
240
+
241
+ type SqliteKind = "unique" | "fk" | "not_null" | "check";
242
+
243
+ function sqliteConstraint(err: unknown): SqliteKind | undefined {
244
+ const e = err as SqlErrShape;
245
+ const message = typeof e.message === "string" ? e.message : "";
246
+ const errno = sqlErrno(err);
247
+ const code = sqlCode(err);
248
+ if (
249
+ (errno !== undefined && SQLITE_CONFLICT_ERRNO.has(errno)) ||
250
+ code === "SQLITE_CONSTRAINT_UNIQUE" ||
251
+ code === "SQLITE_CONSTRAINT_PRIMARYKEY" ||
252
+ /UNIQUE constraint failed/i.test(message) ||
253
+ /PRIMARY KEY constraint failed/i.test(message)
254
+ ) {
255
+ return "unique";
256
+ }
257
+ if (
258
+ errno === 787 ||
259
+ code === "SQLITE_CONSTRAINT_FOREIGNKEY" ||
260
+ /FOREIGN KEY constraint failed/i.test(message)
261
+ ) {
262
+ return "fk";
263
+ }
264
+ if (
265
+ errno === 1299 ||
266
+ code === "SQLITE_CONSTRAINT_NOTNULL" ||
267
+ /NOT NULL constraint failed/i.test(message)
268
+ ) {
269
+ return "not_null";
270
+ }
271
+ if (
272
+ errno === 275 ||
273
+ code === "SQLITE_CONSTRAINT_CHECK" ||
274
+ /CHECK constraint failed/i.test(message)
275
+ ) {
276
+ return "check";
277
+ }
278
+ return undefined;
279
+ }
280
+
281
+ function causeOf(err: unknown): unknown {
282
+ if (!err || typeof err !== "object") return undefined;
283
+ return (err as SqlErrShape).cause;
284
+ }
285
+
286
+ function walk(err: unknown, pred: (node: unknown) => boolean): boolean {
287
+ let current: unknown = err;
288
+ for (let i = 0; i < 4; i++) {
289
+ if (pred(current)) return true;
290
+ current = causeOf(current);
291
+ if (current === undefined) break;
292
+ }
293
+ return false;
294
+ }
@@ -15,6 +15,7 @@ import { and, desc, eq, like, lt, or } from "drizzle-orm";
15
15
  import { integer, pgTable, text } from "drizzle-orm/pg-core";
16
16
  import { pgliteDriver } from "../../drivers/pglite.ts";
17
17
  import type { SqlConnection } from "../../drivers/types.ts";
18
+ import { isFlowFailure } from "../../kernel/hooks.ts";
18
19
  import { createSqlStoreHandle, type SqlStoreHandle } from "./sql-session.ts";
19
20
 
20
21
  const posts = pgTable("posts", {
@@ -168,3 +169,54 @@ describe("SqlStoreHandle — upsert", () => {
168
169
  expect(row).toEqual({ id: "n1", title: "two", createdAt: 20 });
169
170
  });
170
171
  });
172
+
173
+ describe("SqlStoreHandle — SQL constraints become typed failures", () => {
174
+ test("unique insert is Conflict; missing FK is ForeignKey", async () => {
175
+ await sharedConn.exec(`
176
+ CREATE TABLE IF NOT EXISTS "acct" (
177
+ "id" TEXT PRIMARY KEY,
178
+ "email" TEXT NOT NULL UNIQUE
179
+ )
180
+ `);
181
+ await sharedConn.exec(`
182
+ CREATE TABLE IF NOT EXISTS "note" (
183
+ "id" TEXT PRIMARY KEY,
184
+ "acct_id" TEXT NOT NULL REFERENCES "acct"("id")
185
+ )
186
+ `);
187
+ await sharedConn.exec(`TRUNCATE "note", "acct" RESTART IDENTITY CASCADE`);
188
+
189
+ const acct = pgTable("acct", {
190
+ id: text("id").primaryKey(),
191
+ email: text("email").notNull(),
192
+ });
193
+ const note = pgTable("note", {
194
+ id: text("id").primaryKey(),
195
+ acctId: text("acct_id").notNull(),
196
+ });
197
+
198
+ await sharedHandle.insert(acct).values({ id: "a1", email: "a@b.c" });
199
+ let unique: unknown;
200
+ try {
201
+ await sharedHandle.insert(acct).values({ id: "a2", email: "a@b.c" });
202
+ } catch (err) {
203
+ unique = err;
204
+ }
205
+ expect(isFlowFailure(unique)).toBe(true);
206
+ if (isFlowFailure(unique)) {
207
+ expect(unique.error.code).toBe("Conflict");
208
+ expect(JSON.stringify(unique)).not.toContain("a@b.c");
209
+ }
210
+
211
+ let fk: unknown;
212
+ try {
213
+ await sharedHandle.insert(note).values({ id: "n1", acctId: "missing" });
214
+ } catch (err) {
215
+ fk = err;
216
+ }
217
+ expect(isFlowFailure(fk)).toBe(true);
218
+ if (isFlowFailure(fk)) {
219
+ expect(fk.error.code).toBe("ForeignKey");
220
+ }
221
+ });
222
+ });
@@ -14,6 +14,8 @@ import {
14
14
  type RlsIdentity,
15
15
  } from "../../drivers/pg-rls.ts";
16
16
  import { throwOke } from "../../kernel/errors.ts";
17
+ import { isFlowFailure } from "../../kernel/hooks.ts";
18
+ import { isRetryableSqlError, sqlErrorToFailure } from "./sql-errors.ts";
17
19
  import { AsyncLocalStorage } from "node:async_hooks";
18
20
  import { maskRows, tableFromSql } from "./classify.ts";
19
21
  import { isMissingDomainRelationError } from "./missing-relation.ts";
@@ -22,9 +24,11 @@ import {
22
24
  compileWhere,
23
25
  compileOrderBy,
24
26
  resolveSelectColumns,
27
+ type CompiledWhere,
25
28
  type WhereMap,
26
29
  } from "./sql-condition.ts";
27
30
  import {
31
+ coerceTemporalBindValue,
28
32
  mapRowToJs,
29
33
  prepareInsertRow,
30
34
  prepareUpdateRow,
@@ -547,9 +551,13 @@ export function createSqlStoreHandle(
547
551
  try {
548
552
  return await fn();
549
553
  } catch (err) {
554
+ if (isFlowFailure(err)) throw err;
550
555
  if (domainDdl === "off" && isMissingDomainRelationError(err)) {
551
556
  throwOke("DOMAIN_SCHEMA_MISSING");
552
557
  }
558
+ if (isRetryableSqlError(err)) throw err;
559
+ const failure = sqlErrorToFailure(err, { retryable: "leave" });
560
+ if (failure) throw failure;
553
561
  throw err;
554
562
  }
555
563
  }
@@ -622,6 +630,46 @@ export function createSqlStoreHandle(
622
630
  return out;
623
631
  }
624
632
 
633
+ /**
634
+ * Coerce epoch-ms / ISO strings on temporal WHERE binds the same way
635
+ * insert/update SET does, so `lte(col, fx.clock.now())` is a timestamp.
636
+ *
637
+ * @param table - Table whose columns supply SQL types
638
+ * @param compiled - Predicate list from {@link compileWhere}
639
+ */
640
+ function coerceCompiledWhere(
641
+ table: TableHandle | unknown,
642
+ compiled: CompiledWhere,
643
+ ): CompiledWhere {
644
+ const cols = resolveColumns(table);
645
+ if (cols.length === 0 || compiled.predicates.length === 0) return compiled;
646
+ const bySql = new Map(cols.map((c) => [c.sqlName, c]));
647
+
648
+ let changed = false;
649
+ const predicates = compiled.predicates.map((p) => {
650
+ const col = bySql.get(p.column);
651
+ if (!col || p.op === "is null" || p.op === "is not null") return p;
652
+ if (p.op === "in" && Array.isArray(p.value)) {
653
+ const incoming = p.value;
654
+ const value = incoming.map((v) => coerceTemporalBindValue(col.sqlType, v));
655
+ if (value.some((v, i) => v !== incoming[i])) changed = true;
656
+ return { ...p, value };
657
+ }
658
+ const value = coerceTemporalBindValue(col.sqlType, p.value);
659
+ if (value !== p.value) changed = true;
660
+ return { ...p, value };
661
+ });
662
+ if (!changed) return compiled;
663
+
664
+ const params: unknown[] = [];
665
+ for (const p of predicates) {
666
+ if (p.op === "is null" || p.op === "is not null") continue;
667
+ if (p.op === "in" && Array.isArray(p.value)) params.push(...p.value);
668
+ else params.push(p.value);
669
+ }
670
+ return { clause: compiled.clause, params, predicates };
671
+ }
672
+
625
673
  function compileTableWhere(
626
674
  table: TableHandle | unknown,
627
675
  where: unknown,
@@ -632,9 +680,9 @@ export function createSqlStoreHandle(
632
680
  !Array.isArray(where) &&
633
681
  !("queryChunks" in (where as object))
634
682
  ) {
635
- return compileWhere(normalizeWhereMap(table, where as WhereMap));
683
+ return coerceCompiledWhere(table, compileWhere(normalizeWhereMap(table, where as WhereMap)));
636
684
  }
637
- return compileWhere(where);
685
+ return coerceCompiledWhere(table, compileWhere(where));
638
686
  }
639
687
 
640
688
  /**
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Store-path error map — SQL, Redis KV, then files. Off the kernel edge graph.
3
+ */
4
+
5
+ import { isFlowFailure } from "../../kernel/hooks.ts";
6
+ import type { FlowFailure } from "../../kernel/errors.ts";
7
+ import { filesErrorToFailure, type FilesErrorToFailureOptions } from "./files-errors.ts";
8
+ import { kvErrorToFailure, type KvErrorToFailureOptions } from "./kv-errors.ts";
9
+ import { sqlErrorToFailure, type SqlErrorToFailureOptions } from "./sql-errors.ts";
10
+
11
+ /** Shared retryable handling for SQL, Redis KV, and files mappers. */
12
+ export type StoreErrorToFailureOptions = SqlErrorToFailureOptions &
13
+ KvErrorToFailureOptions &
14
+ FilesErrorToFailureOptions;
15
+
16
+ /**
17
+ * Map a store driver throw to a typed failure (SQL, then Redis KV, then files).
18
+ *
19
+ * @param err - Caught value from `fx.store`
20
+ * @param options - Retryable handling
21
+ */
22
+ export function storeErrorToFailure(
23
+ err: unknown,
24
+ options: StoreErrorToFailureOptions = {},
25
+ ): FlowFailure | undefined {
26
+ return (
27
+ sqlErrorToFailure(err, options) ??
28
+ kvErrorToFailure(err, options) ??
29
+ filesErrorToFailure(err, options)
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Run a store op; remap known SQL / Redis / files failures, leave retryable throws.
35
+ *
36
+ * @param fn - Store operation
37
+ */
38
+ export async function withStoreErrorMap<T>(fn: () => Promise<T>): Promise<T> {
39
+ try {
40
+ return await fn();
41
+ } catch (err) {
42
+ if (isFlowFailure(err)) throw err;
43
+ const mapped = storeErrorToFailure(err, { retryable: "leave" });
44
+ if (mapped) throw mapped;
45
+ throw err;
46
+ }
47
+ }
@@ -295,7 +295,8 @@ function drizzleSqlType(
295
295
 
296
296
  /**
297
297
  * Bind value for a temporal SQL column. Epoch-ms numbers (e.g. `fx.clock.now()`)
298
- * become `Date` so Postgres `timestamp` / `date` accept them; other values pass through.
298
+ * and parseable ISO datetime strings (HTTP `z.iso.datetime()`) become `Date`
299
+ * so Postgres `timestamp` / `date` accept them; other values pass through.
299
300
  *
300
301
  * @param sqlType - Resolved DDL type
301
302
  * @param value - Incoming JS value
@@ -304,13 +305,14 @@ export function coerceTemporalBindValue(
304
305
  sqlType: ResolvedColumn["sqlType"],
305
306
  value: unknown,
306
307
  ): unknown {
307
- if (
308
- (sqlType === "TIMESTAMP" || sqlType === "DATE") &&
309
- typeof value === "number" &&
310
- Number.isFinite(value)
311
- ) {
308
+ if (sqlType !== "TIMESTAMP" && sqlType !== "DATE") return value;
309
+ if (typeof value === "number" && Number.isFinite(value)) {
312
310
  return new Date(value);
313
311
  }
312
+ if (typeof value === "string") {
313
+ const parsed = Date.parse(value);
314
+ if (Number.isFinite(parsed)) return new Date(parsed);
315
+ }
314
316
  return value;
315
317
  }
316
318
 
package/src/http.ts CHANGED
@@ -23,7 +23,15 @@ export { http, internal } from "./kernel/triggers.ts";
23
23
  export type { BoundaryContract } from "./kernel/boundary-contract.ts";
24
24
  export { clock } from "./elements/clock/declare.ts";
25
25
  export { gate, GATE_PUBLIC_NAME } from "./elements/gate/declare.ts";
26
- export { fail, type FlowFailure } from "./kernel/errors.ts";
26
+ export { fail, type FailFn } from "./kernel/fail-helpers.ts";
27
+ export { type FlowFailure, type FlowErrorValue } from "./kernel/errors.ts";
28
+ export type {
29
+ BuiltinErrorBag,
30
+ BuiltinErrorCode,
31
+ BuiltinErrorMap,
32
+ BuiltinValidationIssue,
33
+ DatabaseErrorReason,
34
+ } from "./kernel/builtin-errors.ts";
27
35
  export { isFlowFailure } from "./kernel/hooks.ts";
28
36
  export { plugin, isPlugin, type PluginDef } from "./kernel/plugin.ts";
29
37
  export { createBunRuntime, APP_PORT, type Runtime, type ServeOptions } from "./runtime/index.ts";
@@ -64,6 +64,10 @@ export const builtinAr = {
64
64
  cause: "جدول النطاق غير موجود — لم تُطبَّق الترحيلات.",
65
65
  fix: "شغّل `oke db migrate` على هذه البيئة.",
66
66
  },
67
+ "1510": {
68
+ cause: "{count} أسرار بلا قيمة في أي طبقة حل.",
69
+ fix: "عيّن كل اسم (`oke vault set <name>` أو `.env.local`).",
70
+ },
67
71
  },
68
72
  errors: {
69
73
  Unauthorized: "المصادقة مطلوبة.",
@@ -71,6 +75,19 @@ export const builtinAr = {
71
75
  RateLimited: "طلبات كثيرة جداً. حاول لاحقاً.",
72
76
  ValidationError: "فشل التحقق من الطلب.",
73
77
  NotFound: "المورد المطلوب غير موجود.",
78
+ Conflict: "هذه القيمة مستخدمة بالفعل.",
79
+ ForeignKey: "السجل المرتبط غير موجود.",
80
+ DatabaseError: "رفضت قاعدة البيانات هذا التغيير.",
81
+ "DatabaseError.not_null": "حقل مطلوب مفقود.",
82
+ "DatabaseError.check": "قيمة لم تستوفِ قيداً في قاعدة البيانات.",
83
+ "DatabaseError.invalid": "قيمة غير صالحة لهذا العمود.",
84
+ "DatabaseError.too_long": "القيمة أطول من المسموح لهذا العمود.",
85
+ "DatabaseError.out_of_range": "القيمة خارج النطاق المسموح.",
86
+ "DatabaseError.retryable": "قاعدة البيانات مشغولة. حاول بعد قليل.",
87
+ ServiceUnavailable: "الخدمة غير متاحة مؤقتاً.",
88
+ InternalError: "حدث خطأ. حاول مرة أخرى.",
89
+ InvalidQuery: "الاستعلام غير صالح.",
90
+ UnsupportedMediaType: "نوع المحتوى هذا غير مدعوم.",
74
91
  AuthFailed: "فشلت المصادقة.",
75
92
  AuthRateLimited: "محاولات مصادقة كثيرة جداً. حاول لاحقاً.",
76
93
  "AuthFailed.invalid_credentials": "بيانات الاعتماد غير صحيحة.",
@@ -83,6 +100,7 @@ export const builtinAr = {
83
100
  "AuthFailed.password_policy": "كلمة المرور لا تستوفي متطلبات السياسة.",
84
101
  "AuthFailed.invalid_origin": "تم رفض أصل مفتاح المرور.",
85
102
  "Forbidden.csrf": "تم حظر الطلب عبر المواقع.",
103
+ "Forbidden.invalid_key": "مفتاح الكائن هذا غير مسموح.",
86
104
  "Forbidden.ip_denied": "عنوان IP الخاص بك محظور.",
87
105
  "Forbidden.ip_not_allowed": "عنوان IP الخاص بك غير مسموح.",
88
106
  "Forbidden.policy_denied": "رفضت السياسة هذا الطلب.",
@@ -64,6 +64,10 @@ export const builtinEn = {
64
64
  cause: "domain table not found — migrations have not been applied.",
65
65
  fix: "run `oke db migrate` against this environment.",
66
66
  },
67
+ "1510": {
68
+ cause: "{count} secrets have no value in any resolution layer.",
69
+ fix: "Set each name (`oke vault set <name>`, or `.env.local`).",
70
+ },
67
71
  },
68
72
  errors: {
69
73
  Unauthorized: "Authentication required.",
@@ -71,6 +75,19 @@ export const builtinEn = {
71
75
  RateLimited: "Too many requests. Try again later.",
72
76
  ValidationError: "The request failed validation.",
73
77
  NotFound: "The requested resource was not found.",
78
+ Conflict: "That value is already in use.",
79
+ ForeignKey: "That related record does not exist.",
80
+ DatabaseError: "The database rejected this change.",
81
+ "DatabaseError.not_null": "A required field is missing.",
82
+ "DatabaseError.check": "A value did not meet a database check.",
83
+ "DatabaseError.invalid": "A value is not valid for that column.",
84
+ "DatabaseError.too_long": "A value is too long for that column.",
85
+ "DatabaseError.out_of_range": "A value is outside the allowed range.",
86
+ "DatabaseError.retryable": "The database is busy. Try again shortly.",
87
+ ServiceUnavailable: "The service is temporarily unavailable.",
88
+ InternalError: "Something went wrong. Try again.",
89
+ InvalidQuery: "The query is not valid.",
90
+ UnsupportedMediaType: "That content type is not supported.",
74
91
  AuthFailed: "Authentication failed.",
75
92
  AuthRateLimited: "Too many authentication attempts. Try again later.",
76
93
  "AuthFailed.invalid_credentials": "Invalid credentials.",
@@ -84,6 +101,7 @@ export const builtinEn = {
84
101
  "AuthFailed.password_policy": "That password does not meet the policy requirements.",
85
102
  "AuthFailed.invalid_origin": "Passkey origin was rejected.",
86
103
  "Forbidden.csrf": "Cross-site request blocked.",
104
+ "Forbidden.invalid_key": "That object key is not allowed.",
87
105
  "Forbidden.ip_denied": "Your IP address is blocked.",
88
106
  "Forbidden.ip_not_allowed": "Your IP address is not allowed.",
89
107
  "Forbidden.policy_denied": "Policy denied this request.",
package/src/index.ts CHANGED
@@ -32,7 +32,15 @@ export { http, internal, mcp } from "./kernel/triggers.ts";
32
32
  export type { BoundaryContract } from "./kernel/boundary-contract.ts";
33
33
  export { registerFlowUnits } from "./kernel/flow-units.ts";
34
34
  export { stampFlowName, stampHttpPath } from "./kernel/stamp-http.ts";
35
- export { fail, type FlowFailure, type FlowErrorValue } from "./kernel/errors.ts";
35
+ export { fail, type FailFn } from "./kernel/fail-helpers.ts";
36
+ export { type FlowFailure, type FlowErrorValue } from "./kernel/errors.ts";
37
+ export type {
38
+ BuiltinErrorBag,
39
+ BuiltinErrorCode,
40
+ BuiltinErrorMap,
41
+ BuiltinValidationIssue,
42
+ DatabaseErrorReason,
43
+ } from "./kernel/builtin-errors.ts";
36
44
  export { isFlowFailure } from "./kernel/hooks.ts";
37
45
  export { plugin, isPlugin, type PluginDef, type PluginCapabilities } from "./kernel/plugin.ts";
38
46
  export type { Fx, FxPrincipal, StepOptions } from "./kernel/fx.ts";
@@ -82,7 +82,7 @@ describe("boot-level: stale barrel through a real oke() boot", () => {
82
82
  const root = await mkdtemp(join(tmpdir(), "oke-adopt-fresh-"));
83
83
  try {
84
84
  // "notes" exists on disk but this app only ever adopted "main" —
85
- // exactly what a stale `src/flows/generated.ts` after adding a unit
85
+ // exactly what a stale `src/flows/index.ts` after adding a unit
86
86
  // (and forgetting to rerun `oke dev` / `oke build`) looks like.
87
87
  await makeUnitDir(root, "notes");
88
88
  const mainFlow = flow("main.health", {