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.
- package/package.json +2 -1
- package/site/content/docs/client/calling.mdx +117 -29
- package/site/content/docs/client/index.mdx +3 -3
- package/site/content/docs/elements/flow/http.mdx +18 -15
- package/site/content/docs/elements/flow/index.mdx +26 -18
- package/site/content/docs/elements/gate/tenancy.mdx +1 -1
- package/site/content/docs/elements/store/files.mdx +11 -5
- package/site/content/docs/elements/store/index.mdx +2 -3
- package/site/content/docs/elements/store/kv.mdx +8 -2
- package/site/content/docs/elements/store/sql.mdx +9 -6
- package/site/content/docs/elements/vault/index.mdx +11 -8
- package/site/content/docs/elements/vault/secrets.mdx +6 -3
- package/site/content/docs/index.mdx +1 -1
- package/site/content/docs/recipes/rustfs.mdx +1 -1
- package/site/content/docs/reference/configuration.mdx +1 -1
- package/site/content/docs/reference/errors.mdx +199 -25
- package/site/content/docs/reference/fx.mdx +6 -1
- package/site/content/docs/understand/the-architecture.mdx +2 -2
- package/site/content/docs/understand/try-it.mdx +758 -25
- package/src/cli/dev-app-runner.ts +2 -1
- package/src/cli/start.ts +2 -1
- package/src/client/create.ts +3 -3
- package/src/client/explain.test.ts +252 -0
- package/src/client/explain.ts +272 -0
- package/src/client/live.test.ts +44 -0
- package/src/client/notes-contract.test.ts +10 -0
- package/src/client/sse.ts +6 -2
- package/src/client/transport.test.ts +67 -0
- package/src/client/transport.ts +24 -40
- package/src/client/types.ts +17 -6
- package/src/client-react/live-resource.ts +6 -2
- package/src/compiler/response.ts +17 -27
- package/src/console/server/invoke-user-flow.test.ts +8 -2
- package/src/console/server/invoke-user-flow.ts +12 -18
- package/src/console/server/security.gate.test.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DFLu0wTA.js → access-page-tIbsiphz.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-DGscxaF5.js → agent-disclosure-CSKumwS2.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-BGmRZk7d.js → cache-glyph-BCC-DxKT.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button--feUYxvG.js → call-pii-button-jOmYISdc.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-JWvpaiGY.js → collapsible-DC2xNaAb.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-D9yCJG4n.js → duration-tone-CwoV56jn.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-Bs6MD9GB.js → flows-page-gT1lsWQK.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-xH8MrEnv.js → highlighted-json-C2GZEJNI.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-C4vB6ZIw.js → http-method-BdYjcIrD.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-yTCY4AcS.js → index-Cul17AcV.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-BxJ3R6dU.js → observability-page-oY9vdYBk.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-QRKB_IE8.js → replica-lag-C4QdAF7J.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DqZ-fMu5.js → request-meta-C43DHyld.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-Dixb6L7a.js → store-page-CL0D9dOq.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CazhjtiU.js → trace-detail-sheet-Dcpo4Us_.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-DlnqYKfr.js → tree-expand-toggle-CKJTuv43.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-BXTLjU2-.js → units-page-1PlT19ft.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-39KR__bc.js → vault-page-BwZ9YjTW.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/docker/docker.test.ts +3 -3
- package/src/docker/images-config.test.ts +4 -4
- package/src/docker/stack-id.test.ts +1 -1
- package/src/elements/store/files-errors.test.ts +149 -0
- package/src/elements/store/files-errors.ts +189 -0
- package/src/elements/store/kv-errors.test.ts +98 -0
- package/src/elements/store/kv-errors.ts +139 -0
- package/src/elements/store/resource.ts +11 -7
- package/src/elements/store/runtime.ts +18 -13
- package/src/elements/store/sql-errors.test.ts +197 -0
- package/src/elements/store/sql-errors.ts +294 -0
- package/src/elements/store/sql-session.test.ts +52 -0
- package/src/elements/store/sql-session.ts +6 -0
- package/src/elements/store/store-errors.ts +47 -0
- package/src/http.ts +9 -1
- package/src/i18n/catalogs/ar.ts +18 -0
- package/src/i18n/catalogs/en.ts +18 -0
- package/src/index.ts +9 -1
- package/src/kernel/app.ts +14 -2
- package/src/kernel/builtin-errors.test.ts +117 -0
- package/src/kernel/builtin-errors.ts +129 -0
- package/src/kernel/call.test.ts +182 -0
- package/src/kernel/errors-vault.ts +16 -0
- package/src/kernel/errors.registry.test.ts +7 -0
- package/src/kernel/errors.ts +94 -21
- package/src/kernel/fail-helpers.ts +34 -0
- package/src/kernel/fx.test.ts +8 -0
- package/src/kernel/fx.ts +12 -6
- package/src/kernel/index.ts +12 -1
- package/src/runtime/dev-request-log.ts +29 -11
- package/src/term.test.ts +76 -0
- 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";
|
|
@@ -549,9 +551,13 @@ export function createSqlStoreHandle(
|
|
|
549
551
|
try {
|
|
550
552
|
return await fn();
|
|
551
553
|
} catch (err) {
|
|
554
|
+
if (isFlowFailure(err)) throw err;
|
|
552
555
|
if (domainDdl === "off" && isMissingDomainRelationError(err)) {
|
|
553
556
|
throwOke("DOMAIN_SCHEMA_MISSING");
|
|
554
557
|
}
|
|
558
|
+
if (isRetryableSqlError(err)) throw err;
|
|
559
|
+
const failure = sqlErrorToFailure(err, { retryable: "leave" });
|
|
560
|
+
if (failure) throw failure;
|
|
555
561
|
throw err;
|
|
556
562
|
}
|
|
557
563
|
}
|
|
@@ -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
|
+
}
|
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
|
|
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";
|
package/src/i18n/catalogs/ar.ts
CHANGED
|
@@ -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": "رفضت السياسة هذا الطلب.",
|
package/src/i18n/catalogs/en.ts
CHANGED
|
@@ -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
|
|
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";
|
package/src/kernel/app.ts
CHANGED
|
@@ -61,7 +61,7 @@ import type {
|
|
|
61
61
|
} from "./fx.ts";
|
|
62
62
|
import {
|
|
63
63
|
currentDevSurface,
|
|
64
|
-
|
|
64
|
+
failureEnvelopeFromResponse,
|
|
65
65
|
logDevRequest,
|
|
66
66
|
shouldLogDevRequests,
|
|
67
67
|
} from "../runtime/dev-request-log.ts";
|
|
@@ -351,6 +351,11 @@ export interface ExecuteResult {
|
|
|
351
351
|
readonly failure: PipelineResult["failure"];
|
|
352
352
|
readonly response: Response | undefined;
|
|
353
353
|
readonly ctx: InvocationContext;
|
|
354
|
+
/**
|
|
355
|
+
* Derived projection of {@link InvocationContext.error} — never a separate
|
|
356
|
+
* store. Always `=== ctx.error` by reference.
|
|
357
|
+
*/
|
|
358
|
+
readonly error: unknown;
|
|
354
359
|
readonly fx: Fx;
|
|
355
360
|
/** Wide-event cache dimension from this invocation's telemetry. */
|
|
356
361
|
readonly cache: "hit" | "miss" | "none";
|
|
@@ -1915,6 +1920,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1915
1920
|
},
|
|
1916
1921
|
);
|
|
1917
1922
|
if (inner.failure) return inner.failure;
|
|
1923
|
+
if (inner.ctx.error !== undefined) throw inner.ctx.error;
|
|
1918
1924
|
return inner.output;
|
|
1919
1925
|
},
|
|
1920
1926
|
});
|
|
@@ -2144,6 +2150,9 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2144
2150
|
failure: result.failure,
|
|
2145
2151
|
response: result.response,
|
|
2146
2152
|
ctx: result.ctx,
|
|
2153
|
+
get error() {
|
|
2154
|
+
return result.ctx.error;
|
|
2155
|
+
},
|
|
2147
2156
|
fx,
|
|
2148
2157
|
cache: cacheDimensionOf(telemetry),
|
|
2149
2158
|
durationMs,
|
|
@@ -2328,6 +2337,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2328
2337
|
response.headers.set("accept-query", '"application/json"');
|
|
2329
2338
|
}
|
|
2330
2339
|
if (shouldLogDevRequests()) {
|
|
2340
|
+
const envelope = await failureEnvelopeFromResponse(response);
|
|
2331
2341
|
logDevRequest({
|
|
2332
2342
|
surface: currentDevSurface(),
|
|
2333
2343
|
method,
|
|
@@ -2336,7 +2346,8 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2336
2346
|
runId: runLabel,
|
|
2337
2347
|
status: response.status,
|
|
2338
2348
|
ms: Math.round(performance.now() - started),
|
|
2339
|
-
detail:
|
|
2349
|
+
detail: envelope.detail,
|
|
2350
|
+
errorCode: envelope.code,
|
|
2340
2351
|
});
|
|
2341
2352
|
}
|
|
2342
2353
|
const jcb = loadJsonCodeBlock();
|
|
@@ -2552,6 +2563,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2552
2563
|
flowDef.triggers[0] ?? ({ kind: "internal" } satisfies InternalTrigger);
|
|
2553
2564
|
const result = await execute(flowDef, input, trigger);
|
|
2554
2565
|
if (result.failure) return result.failure;
|
|
2566
|
+
if (result.ctx.error !== undefined) throw result.ctx.error;
|
|
2555
2567
|
return result.output;
|
|
2556
2568
|
},
|
|
2557
2569
|
resolveMcpTool(name) {
|