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,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis-wire KV errors → ServiceUnavailable (availability / retry exhaust).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { isFlowFailure } from "../../kernel/hooks.ts";
|
|
7
|
+
import { createRedisFakeClient, redisDriver } from "../../drivers/redis.ts";
|
|
8
|
+
import { memoryDrivers } from "../../drivers/memory.ts";
|
|
9
|
+
import { store } from "./declare.ts";
|
|
10
|
+
import { isRetryableKvError, kvErrorToFailure } from "./kv-errors.ts";
|
|
11
|
+
import { createStoreRuntime, type KvStoreFxHandle } from "./runtime.ts";
|
|
12
|
+
|
|
13
|
+
describe("kvErrorToFailure", () => {
|
|
14
|
+
test("connection / cluster → ServiceUnavailable without copying message", () => {
|
|
15
|
+
const closed = kvErrorToFailure(
|
|
16
|
+
Object.assign(new Error("Connection closed"), { code: "ECONNREFUSED" }),
|
|
17
|
+
);
|
|
18
|
+
expect(closed?.error.code).toBe("ServiceUnavailable");
|
|
19
|
+
expect(JSON.stringify(closed)).not.toContain("Connection closed");
|
|
20
|
+
|
|
21
|
+
expect(kvErrorToFailure(new Error("CLUSTERDOWN The cluster is down"))?.error.code).toBe(
|
|
22
|
+
"ServiceUnavailable",
|
|
23
|
+
);
|
|
24
|
+
expect(
|
|
25
|
+
kvErrorToFailure(new Error("READONLY You can't write against a read only replica."))?.error
|
|
26
|
+
.code,
|
|
27
|
+
).toBe("ServiceUnavailable");
|
|
28
|
+
expect(
|
|
29
|
+
kvErrorToFailure(new Error("OOM command not allowed when used memory > 'maxmemory'."))?.error
|
|
30
|
+
.code,
|
|
31
|
+
).toBe("ServiceUnavailable");
|
|
32
|
+
expect(isFlowFailure(closed)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("BUSY / TRYAGAIN left thrown until retryable: unavailable", () => {
|
|
36
|
+
const busy = new Error("BUSY Redis is busy running a script");
|
|
37
|
+
expect(isRetryableKvError(busy)).toBe(true);
|
|
38
|
+
expect(kvErrorToFailure(busy, { retryable: "leave" })).toBeUndefined();
|
|
39
|
+
expect(kvErrorToFailure(busy, { retryable: "unavailable" })?.error.code).toBe(
|
|
40
|
+
"ServiceUnavailable",
|
|
41
|
+
);
|
|
42
|
+
expect(
|
|
43
|
+
kvErrorToFailure(new Error("TRYAGAIN Multiple keys request during rehashing"), {
|
|
44
|
+
retryable: "leave",
|
|
45
|
+
}),
|
|
46
|
+
).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("WRONGTYPE / NOSCRIPT stay unmapped", () => {
|
|
50
|
+
expect(
|
|
51
|
+
kvErrorToFailure(
|
|
52
|
+
new Error("WRONGTYPE Operation against a key holding the wrong kind of value"),
|
|
53
|
+
),
|
|
54
|
+
).toBeUndefined();
|
|
55
|
+
expect(
|
|
56
|
+
kvErrorToFailure(new Error("NOSCRIPT No matching script. Please use EVAL.")),
|
|
57
|
+
).toBeUndefined();
|
|
58
|
+
expect(kvErrorToFailure(new Error("boom"))).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("walks .cause for connection failures", () => {
|
|
62
|
+
const wrapped = {
|
|
63
|
+
message: "kv get failed",
|
|
64
|
+
cause: Object.assign(new Error("Connection closed"), { code: "ECONNRESET" }),
|
|
65
|
+
};
|
|
66
|
+
expect(kvErrorToFailure(wrapped)?.error.code).toBe("ServiceUnavailable");
|
|
67
|
+
expect(JSON.stringify(kvErrorToFailure(wrapped))).not.toContain("kv get failed");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("openKv maps Redis throws", () => {
|
|
72
|
+
test("connection failure on get becomes ServiceUnavailable", async () => {
|
|
73
|
+
const fake = createRedisFakeClient();
|
|
74
|
+
fake.get = async () => {
|
|
75
|
+
throw Object.assign(new Error("Connection closed"), { code: "ECONNREFUSED" });
|
|
76
|
+
};
|
|
77
|
+
const sessions = store.kv("sessions");
|
|
78
|
+
const runtime = createStoreRuntime({
|
|
79
|
+
drivers: {
|
|
80
|
+
kv: redisDriver,
|
|
81
|
+
files: memoryDrivers.files,
|
|
82
|
+
index: memoryDrivers.index,
|
|
83
|
+
},
|
|
84
|
+
kv: { sessions: { client: fake } },
|
|
85
|
+
});
|
|
86
|
+
runtime.register(sessions);
|
|
87
|
+
const handle = (await runtime.open(sessions, { effects: {} })) as KvStoreFxHandle;
|
|
88
|
+
try {
|
|
89
|
+
await handle.get("sid");
|
|
90
|
+
throw new Error("expected mapped failure");
|
|
91
|
+
} catch (err) {
|
|
92
|
+
expect(isFlowFailure(err)).toBe(true);
|
|
93
|
+
expect((err as { error: { code: string } }).error.code).toBe("ServiceUnavailable");
|
|
94
|
+
expect(JSON.stringify(err)).not.toContain("Connection closed");
|
|
95
|
+
}
|
|
96
|
+
await runtime.close();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map Redis-wire KV errors to typed {@link FlowFailure} values.
|
|
3
|
+
*
|
|
4
|
+
* Lives on the store path — not the kernel edge graph. Availability and
|
|
5
|
+
* retryable script/cluster replies become `ServiceUnavailable`. Programmer
|
|
6
|
+
* mistakes (`WRONGTYPE`, `NOSCRIPT`) stay unmapped so the HTTP encoder
|
|
7
|
+
* sanitizes them as `InternalError`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { fail } from "../../kernel/fail-helpers.ts";
|
|
11
|
+
import type { FlowFailure } from "../../kernel/errors.ts";
|
|
12
|
+
import type { BuiltinErrorMap } from "../../kernel/builtin-errors.ts";
|
|
13
|
+
|
|
14
|
+
/** How to treat Redis `BUSY` / `TRYAGAIN` / `LOADING`. */
|
|
15
|
+
export type KvRetryableMode = "leave" | "unavailable";
|
|
16
|
+
|
|
17
|
+
/** Options for {@link kvErrorToFailure}. */
|
|
18
|
+
export interface KvErrorToFailureOptions {
|
|
19
|
+
/**
|
|
20
|
+
* `leave` — return `undefined` so {@link flow.retry} can rethrow.
|
|
21
|
+
* `unavailable` — map to `ServiceUnavailable` after retries exhaust.
|
|
22
|
+
*/
|
|
23
|
+
readonly retryable?: KvRetryableMode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type KvFailure = FlowFailure<BuiltinErrorMap["ServiceUnavailable"]>;
|
|
27
|
+
|
|
28
|
+
type KvErrShape = {
|
|
29
|
+
readonly name?: unknown;
|
|
30
|
+
readonly code?: unknown;
|
|
31
|
+
readonly errno?: unknown;
|
|
32
|
+
readonly message?: unknown;
|
|
33
|
+
readonly cause?: unknown;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const RETRY_TOKEN = new Set(["BUSY", "BUSYKEY", "TRYAGAIN", "LOADING"]);
|
|
37
|
+
const UNAVAILABLE_TOKEN = new Set([
|
|
38
|
+
"CLUSTERDOWN",
|
|
39
|
+
"MASTERDOWN",
|
|
40
|
+
"READONLY",
|
|
41
|
+
"MISCONF",
|
|
42
|
+
"OOM",
|
|
43
|
+
"MOVED",
|
|
44
|
+
"ASK",
|
|
45
|
+
"UNBLOCKED",
|
|
46
|
+
]);
|
|
47
|
+
const CONN_CODE = new Set([
|
|
48
|
+
"ECONNREFUSED",
|
|
49
|
+
"ECONNRESET",
|
|
50
|
+
"ETIMEDOUT",
|
|
51
|
+
"ENOTFOUND",
|
|
52
|
+
"EPIPE",
|
|
53
|
+
"ENETUNREACH",
|
|
54
|
+
"EAI_AGAIN",
|
|
55
|
+
"ECONNABORTED",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* True when `err` is a Redis `BUSY` / `TRYAGAIN` / `LOADING` reply.
|
|
60
|
+
*
|
|
61
|
+
* Left thrown during retries; mapped to `ServiceUnavailable` after exhaust.
|
|
62
|
+
*
|
|
63
|
+
* @param err - Caught driver error
|
|
64
|
+
*/
|
|
65
|
+
export function isRetryableKvError(err: unknown): boolean {
|
|
66
|
+
return walk(err, (node) => retrySignal(node));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Map a Redis-wire KV error to `ServiceUnavailable`, or `undefined` when it
|
|
71
|
+
* is not an availability / retryable Redis failure.
|
|
72
|
+
*
|
|
73
|
+
* @param err - Caught value from `fx.store` KV ops
|
|
74
|
+
* @param options - Retryable handling
|
|
75
|
+
*/
|
|
76
|
+
export function kvErrorToFailure(
|
|
77
|
+
err: unknown,
|
|
78
|
+
options: KvErrorToFailureOptions = {},
|
|
79
|
+
): KvFailure | undefined {
|
|
80
|
+
const retryable = options.retryable ?? "leave";
|
|
81
|
+
let current: unknown = err;
|
|
82
|
+
for (let i = 0; i < 4; i++) {
|
|
83
|
+
if (!current || typeof current !== "object") break;
|
|
84
|
+
if (retrySignal(current)) {
|
|
85
|
+
if (retryable === "leave") return undefined;
|
|
86
|
+
return fail.serviceUnavailable();
|
|
87
|
+
}
|
|
88
|
+
if (isUnavailable(current)) return fail.serviceUnavailable();
|
|
89
|
+
current = causeOf(current);
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function retrySignal(err: unknown): boolean {
|
|
95
|
+
return RETRY_TOKEN.has(redisToken(err));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isUnavailable(err: unknown): boolean {
|
|
99
|
+
const token = redisToken(err);
|
|
100
|
+
if (UNAVAILABLE_TOKEN.has(token)) return true;
|
|
101
|
+
if (CONN_CODE.has(token)) return true;
|
|
102
|
+
if (/^ERR_REDIS_(CONNECTION|CONNECT|CLOSED|TIMEOUT|NETWORK)/.test(token)) return true;
|
|
103
|
+
const message = errMessage(err);
|
|
104
|
+
if (/max number of clients/i.test(message)) return true;
|
|
105
|
+
if (/connection (closed|refused|reset|timed out)/i.test(message)) return true;
|
|
106
|
+
if (/socket hang up|connect econn|read econn/i.test(message)) return true;
|
|
107
|
+
if (/ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EPIPE/i.test(message)) return true;
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function redisToken(err: unknown): string {
|
|
112
|
+
if (!err || typeof err !== "object") return "";
|
|
113
|
+
const e = err as KvErrShape;
|
|
114
|
+
if (typeof e.code === "string" && e.code.length > 0) return e.code.toUpperCase();
|
|
115
|
+
const message = typeof e.message === "string" ? e.message.trim() : "";
|
|
116
|
+
const first = /^([A-Za-z][A-Za-z0-9_]+)/.exec(message)?.[1];
|
|
117
|
+
return first ? first.toUpperCase() : "";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function errMessage(err: unknown): string {
|
|
121
|
+
if (!err || typeof err !== "object") return "";
|
|
122
|
+
const message = (err as KvErrShape).message;
|
|
123
|
+
return typeof message === "string" ? message : "";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function causeOf(err: unknown): unknown {
|
|
127
|
+
if (!err || typeof err !== "object") return undefined;
|
|
128
|
+
return (err as KvErrShape).cause;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function walk(err: unknown, pred: (node: unknown) => boolean): boolean {
|
|
132
|
+
let current: unknown = err;
|
|
133
|
+
for (let i = 0; i < 4; i++) {
|
|
134
|
+
if (pred(current)) return true;
|
|
135
|
+
current = causeOf(current);
|
|
136
|
+
if (current === undefined) break;
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { flow, type AnyFlowDef, type FlowDef, type FlowErrorMap } from "../../kernel/flow.ts";
|
|
16
16
|
import { applyBoundaryContract, stampBoundaryContract } from "../../kernel/boundary-contract.ts";
|
|
17
|
-
import { fail } from "../../kernel/
|
|
17
|
+
import { fail } from "../../kernel/fail-helpers.ts";
|
|
18
18
|
import type { Fx } from "../../kernel/fx.ts";
|
|
19
19
|
import {
|
|
20
20
|
openLiveStream,
|
|
@@ -53,7 +53,11 @@ export interface ResourceOptions {
|
|
|
53
53
|
* used to extend `update` with the path id (wire sends `{ id, ...patch }`).
|
|
54
54
|
*/
|
|
55
55
|
readonly idSchema?: unknown;
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Extra typed errors for get / update / remove.
|
|
58
|
+
*
|
|
59
|
+
* Built-in `NotFound` is always on — omit unless tightening the payload.
|
|
60
|
+
*/
|
|
57
61
|
readonly errors?: FlowErrorMap;
|
|
58
62
|
/** `:id` column; defaults to the table primary key. */
|
|
59
63
|
readonly id?: unknown;
|
|
@@ -191,7 +195,7 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
|
|
|
191
195
|
const { mode, limit, maxLimit, cursorColumns: resolvedCursor } = scope;
|
|
192
196
|
const countMode = scope.count;
|
|
193
197
|
|
|
194
|
-
const errors = options.errors
|
|
198
|
+
const errors = options.errors;
|
|
195
199
|
const breaking = options.breaking === true;
|
|
196
200
|
|
|
197
201
|
/** Run the list query and shape rows + meta. */
|
|
@@ -309,7 +313,7 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
|
|
|
309
313
|
findById(t: unknown, id: string): Promise<SqlRow | null>;
|
|
310
314
|
};
|
|
311
315
|
const row = await store.findById(table, String(id));
|
|
312
|
-
if (!row) return fail(
|
|
316
|
+
if (!row) return fail.notFound();
|
|
313
317
|
return row;
|
|
314
318
|
},
|
|
315
319
|
});
|
|
@@ -341,7 +345,7 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
|
|
|
341
345
|
update(t: unknown): { set(row: SqlRow): { where(w: unknown): Promise<number> } };
|
|
342
346
|
};
|
|
343
347
|
const existing = await store.findById(table, String(id));
|
|
344
|
-
if (!existing) return fail(
|
|
348
|
+
if (!existing) return fail.notFound();
|
|
345
349
|
if (Object.keys(patch).length > 0) {
|
|
346
350
|
await store
|
|
347
351
|
.update(table)
|
|
@@ -349,7 +353,7 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
|
|
|
349
353
|
.where(leafOp(idDrizzleCol, "=", String(id)));
|
|
350
354
|
}
|
|
351
355
|
const row = await store.findById(table, String(id));
|
|
352
|
-
if (!row) return fail(
|
|
356
|
+
if (!row) return fail.notFound();
|
|
353
357
|
return row;
|
|
354
358
|
},
|
|
355
359
|
});
|
|
@@ -371,7 +375,7 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
|
|
|
371
375
|
delete(t: unknown, id: string): Promise<boolean>;
|
|
372
376
|
};
|
|
373
377
|
const deleted = await store.delete(table, String(id));
|
|
374
|
-
if (!deleted) return fail(
|
|
378
|
+
if (!deleted) return fail.notFound();
|
|
375
379
|
return fx.json.empty();
|
|
376
380
|
},
|
|
377
381
|
});
|
|
@@ -44,6 +44,7 @@ import type {
|
|
|
44
44
|
StoreDecl,
|
|
45
45
|
} from "./declare.ts";
|
|
46
46
|
import { openSqlKvNamespace } from "./kv-sql.ts";
|
|
47
|
+
import { withStoreErrorMap } from "./store-errors.ts";
|
|
47
48
|
import { createGatedFilesStoreHandle, type GatedFilesFxBridge } from "./files-fx.ts";
|
|
48
49
|
import {
|
|
49
50
|
createFilesImagePipeline,
|
|
@@ -434,14 +435,15 @@ export function createStoreRuntime(options: CreateStoreRuntimeOptions): StoreRun
|
|
|
434
435
|
kvNs.set(decl.name, ns);
|
|
435
436
|
}
|
|
436
437
|
const driverId = ns.driverId;
|
|
438
|
+
const handle = ns;
|
|
437
439
|
return {
|
|
438
440
|
ref: `kv:${decl.name}`,
|
|
439
441
|
driverId,
|
|
440
|
-
get: (key) =>
|
|
441
|
-
set: (key, value, ttl) =>
|
|
442
|
-
delete: (key) =>
|
|
443
|
-
list: (prefix) =>
|
|
444
|
-
ttlMs: (key) =>
|
|
442
|
+
get: (key) => withStoreErrorMap(() => handle.get(key)),
|
|
443
|
+
set: (key, value, ttl) => withStoreErrorMap(() => handle.set(key, value, ttl)),
|
|
444
|
+
delete: (key) => withStoreErrorMap(() => handle.delete(key)),
|
|
445
|
+
list: (prefix) => withStoreErrorMap(() => handle.list(prefix)),
|
|
446
|
+
ttlMs: (key) => withStoreErrorMap(() => handle.ttlMs(key)),
|
|
445
447
|
};
|
|
446
448
|
}
|
|
447
449
|
|
|
@@ -458,19 +460,22 @@ export function createStoreRuntime(options: CreateStoreRuntimeOptions): StoreRun
|
|
|
458
460
|
});
|
|
459
461
|
fileBuckets.set(decl.name, bucket);
|
|
460
462
|
}
|
|
463
|
+
const handle = bucket;
|
|
461
464
|
const access = {
|
|
462
|
-
get: (key: string) =>
|
|
463
|
-
put: (key: string, data: Uint8Array | string) =>
|
|
465
|
+
get: (key: string) => withStoreErrorMap(() => handle.get(key)),
|
|
466
|
+
put: (key: string, data: Uint8Array | string) =>
|
|
467
|
+
withStoreErrorMap(() => handle.put(key, data)),
|
|
464
468
|
};
|
|
465
469
|
return {
|
|
466
470
|
ref: `files:${decl.name}`,
|
|
467
|
-
driverId:
|
|
468
|
-
put: (key, data) =>
|
|
469
|
-
get: (key) =>
|
|
470
|
-
delete: (key) =>
|
|
471
|
-
list: (prefix) =>
|
|
471
|
+
driverId: handle.driverId,
|
|
472
|
+
put: (key, data) => withStoreErrorMap(() => handle.put(key, data)),
|
|
473
|
+
get: (key) => withStoreErrorMap(() => handle.get(key)),
|
|
474
|
+
delete: (key) => withStoreErrorMap(() => handle.delete(key)),
|
|
475
|
+
list: (prefix) => withStoreErrorMap(() => handle.list(prefix)),
|
|
472
476
|
image: (source, imageOpts) => createFilesImagePipeline(access, source, imageOpts),
|
|
473
|
-
putImage: (key, data, putOpts) =>
|
|
477
|
+
putImage: (key, data, putOpts) =>
|
|
478
|
+
withStoreErrorMap(() => putImageToBucket(access, key, data, putOpts)),
|
|
474
479
|
};
|
|
475
480
|
}
|
|
476
481
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLSTATE / SQLite constraint → typed FlowFailure.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { isFlowFailure } from "../../kernel/hooks.ts";
|
|
7
|
+
import { isRetryableSqlError, sqlErrorToFailure } from "./sql-errors.ts";
|
|
8
|
+
|
|
9
|
+
describe("sqlErrorToFailure", () => {
|
|
10
|
+
test("unique / exclusion → Conflict without copying message", () => {
|
|
11
|
+
const unique = sqlErrorToFailure({
|
|
12
|
+
code: "23505",
|
|
13
|
+
message: 'duplicate key value violates unique constraint "users_email_key"',
|
|
14
|
+
detail: "Key (email)=(a@b.c) already exists.",
|
|
15
|
+
constraint: "users_email_key",
|
|
16
|
+
table: "users",
|
|
17
|
+
column: "email",
|
|
18
|
+
});
|
|
19
|
+
expect(unique?.error.code).toBe("Conflict");
|
|
20
|
+
expect(unique?.error.data).toEqual({
|
|
21
|
+
sqlstate: "23505",
|
|
22
|
+
constraint: "users_email_key",
|
|
23
|
+
table: "users",
|
|
24
|
+
column: "email",
|
|
25
|
+
});
|
|
26
|
+
expect(JSON.stringify(unique)).not.toContain("already exists");
|
|
27
|
+
expect(JSON.stringify(unique)).not.toContain("a@b.c");
|
|
28
|
+
|
|
29
|
+
const exclusion = sqlErrorToFailure({ code: "23P01", constraint: "no_overlap" });
|
|
30
|
+
expect(exclusion?.error.code).toBe("Conflict");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("restrict / restrict_violation → ForeignKey", () => {
|
|
34
|
+
expect(sqlErrorToFailure({ code: "23001", table: "notes" })?.error.code).toBe("ForeignKey");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("invalid / too_long / out_of_range → DatabaseError 422 reasons", () => {
|
|
38
|
+
expect(sqlErrorToFailure({ code: "22P02" })?.error.data).toMatchObject({
|
|
39
|
+
reason: "invalid",
|
|
40
|
+
sqlstate: "22P02",
|
|
41
|
+
});
|
|
42
|
+
expect(sqlErrorToFailure({ code: "22007" })?.error.data).toMatchObject({ reason: "invalid" });
|
|
43
|
+
expect(sqlErrorToFailure({ code: "22023" })?.error.data).toMatchObject({ reason: "invalid" });
|
|
44
|
+
expect(sqlErrorToFailure({ code: "42804" })?.error.data).toMatchObject({ reason: "invalid" });
|
|
45
|
+
expect(sqlErrorToFailure({ code: "22001", column: "title" })?.error.data).toMatchObject({
|
|
46
|
+
reason: "too_long",
|
|
47
|
+
column: "title",
|
|
48
|
+
});
|
|
49
|
+
expect(sqlErrorToFailure({ code: "22003" })?.error.data).toMatchObject({
|
|
50
|
+
reason: "out_of_range",
|
|
51
|
+
});
|
|
52
|
+
expect(sqlErrorToFailure({ code: "22008" })?.error.data).toMatchObject({
|
|
53
|
+
reason: "out_of_range",
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("walks Drizzle .cause for unique SQLSTATE", () => {
|
|
58
|
+
const wrapped = {
|
|
59
|
+
message: "Failed query: insert into users constraint users_email_key",
|
|
60
|
+
cause: {
|
|
61
|
+
code: "23505",
|
|
62
|
+
constraint: "users_email_key",
|
|
63
|
+
message: 'duplicate key value violates unique constraint "users_email_key"',
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
const mapped = sqlErrorToFailure(wrapped);
|
|
67
|
+
expect(mapped?.error.code).toBe("Conflict");
|
|
68
|
+
expect(mapped?.error.data).toMatchObject({ constraint: "users_email_key", sqlstate: "23505" });
|
|
69
|
+
expect(JSON.stringify(mapped)).not.toContain("duplicate key");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("foreign key → ForeignKey", () => {
|
|
73
|
+
const fk = sqlErrorToFailure({
|
|
74
|
+
code: "23503",
|
|
75
|
+
table: "notes",
|
|
76
|
+
constraint: "notes_user_id_fkey",
|
|
77
|
+
message: 'insert or update on table "notes" violates foreign key constraint',
|
|
78
|
+
});
|
|
79
|
+
expect(fk?.error.code).toBe("ForeignKey");
|
|
80
|
+
expect(fk?.error.data).toMatchObject({
|
|
81
|
+
sqlstate: "23503",
|
|
82
|
+
table: "notes",
|
|
83
|
+
constraint: "notes_user_id_fkey",
|
|
84
|
+
});
|
|
85
|
+
expect(JSON.stringify(fk)).not.toContain("violates");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("not_null / check → DatabaseError reasons", () => {
|
|
89
|
+
const notNull = sqlErrorToFailure({ code: "23502", column: "title", table: "posts" });
|
|
90
|
+
expect(notNull?.error.code).toBe("DatabaseError");
|
|
91
|
+
expect(notNull?.error.data).toMatchObject({ reason: "not_null", column: "title" });
|
|
92
|
+
|
|
93
|
+
const check = sqlErrorToFailure({ code: "23514", constraint: "positive_clicks" });
|
|
94
|
+
expect(check?.error.code).toBe("DatabaseError");
|
|
95
|
+
expect(check?.error.data).toMatchObject({ reason: "check", constraint: "positive_clicks" });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("40001 / 40P01 left thrown until retryable: unavailable", () => {
|
|
99
|
+
const ser = { code: "40001", message: "could not serialize access" };
|
|
100
|
+
expect(isRetryableSqlError(ser)).toBe(true);
|
|
101
|
+
expect(sqlErrorToFailure(ser, { retryable: "leave" })).toBeUndefined();
|
|
102
|
+
const exhausted = sqlErrorToFailure(ser, { retryable: "unavailable" });
|
|
103
|
+
expect(exhausted?.error.code).toBe("ServiceUnavailable");
|
|
104
|
+
expect(JSON.stringify(exhausted)).not.toContain("serialize");
|
|
105
|
+
|
|
106
|
+
expect(sqlErrorToFailure({ code: "40P01" }, { retryable: "leave" })).toBeUndefined();
|
|
107
|
+
expect(sqlErrorToFailure({ code: "40P01" }, { retryable: "unavailable" })?.error.code).toBe(
|
|
108
|
+
"ServiceUnavailable",
|
|
109
|
+
);
|
|
110
|
+
expect(isRetryableSqlError({ code: "55P03" })).toBe(true);
|
|
111
|
+
expect(sqlErrorToFailure({ code: "55P03" }, { retryable: "leave" })).toBeUndefined();
|
|
112
|
+
expect(sqlErrorToFailure({ code: "55P03" }, { retryable: "unavailable" })?.error.code).toBe(
|
|
113
|
+
"ServiceUnavailable",
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("connection / paused → ServiceUnavailable", () => {
|
|
118
|
+
expect(sqlErrorToFailure({ code: "08006" })?.error.code).toBe("ServiceUnavailable");
|
|
119
|
+
expect(sqlErrorToFailure({ code: "53300" })?.error.code).toBe("ServiceUnavailable");
|
|
120
|
+
expect(sqlErrorToFailure({ code: "53100" })?.error.code).toBe("ServiceUnavailable");
|
|
121
|
+
expect(sqlErrorToFailure({ code: "57P01" })?.error.code).toBe("ServiceUnavailable");
|
|
122
|
+
expect(sqlErrorToFailure({ code: "57014" })?.error.code).toBe("ServiceUnavailable");
|
|
123
|
+
expect(sqlErrorToFailure({ code: "25P03" })?.error.code).toBe("ServiceUnavailable");
|
|
124
|
+
expect(sqlErrorToFailure({ code: "ERR_POSTGRES_CONNECTION_CLOSED" })?.error.code).toBe(
|
|
125
|
+
"ServiceUnavailable",
|
|
126
|
+
);
|
|
127
|
+
expect(
|
|
128
|
+
sqlErrorToFailure({ name: "SharedPostgresPausedError", code: "ERR_OKE_POSTGRES_PAUSED" })
|
|
129
|
+
?.error.code,
|
|
130
|
+
).toBe("ServiceUnavailable");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("sqlite UNIQUE / FK / NOT NULL / CHECK", () => {
|
|
134
|
+
const unique = sqlErrorToFailure({
|
|
135
|
+
name: "SQLiteError",
|
|
136
|
+
message: "UNIQUE constraint failed: users.email",
|
|
137
|
+
errno: 2067,
|
|
138
|
+
});
|
|
139
|
+
expect(unique?.error.code).toBe("Conflict");
|
|
140
|
+
expect(unique?.error.data).toMatchObject({ table: "users", column: "email" });
|
|
141
|
+
|
|
142
|
+
const fk = sqlErrorToFailure({
|
|
143
|
+
name: "SQLiteError",
|
|
144
|
+
message: "FOREIGN KEY constraint failed",
|
|
145
|
+
errno: 787,
|
|
146
|
+
});
|
|
147
|
+
expect(fk?.error.code).toBe("ForeignKey");
|
|
148
|
+
|
|
149
|
+
const notNull = sqlErrorToFailure({
|
|
150
|
+
name: "SQLiteError",
|
|
151
|
+
message: "NOT NULL constraint failed: posts.title",
|
|
152
|
+
});
|
|
153
|
+
expect(notNull?.error.data).toMatchObject({
|
|
154
|
+
reason: "not_null",
|
|
155
|
+
table: "posts",
|
|
156
|
+
column: "title",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const check = sqlErrorToFailure({
|
|
160
|
+
name: "SQLiteError",
|
|
161
|
+
message: "CHECK constraint failed: positive_clicks",
|
|
162
|
+
});
|
|
163
|
+
expect(check?.error.data).toMatchObject({ reason: "check", constraint: "positive_clicks" });
|
|
164
|
+
|
|
165
|
+
const pk = sqlErrorToFailure({
|
|
166
|
+
name: "SQLiteError",
|
|
167
|
+
message: "PRIMARY KEY constraint failed: users.id",
|
|
168
|
+
errno: 1555,
|
|
169
|
+
});
|
|
170
|
+
expect(pk?.error.code).toBe("Conflict");
|
|
171
|
+
expect(pk?.error.data).toMatchObject({ table: "users", column: "id" });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("sqlite busy left thrown until retryable: unavailable", () => {
|
|
175
|
+
const busy = {
|
|
176
|
+
name: "SQLiteError",
|
|
177
|
+
code: "SQLITE_BUSY",
|
|
178
|
+
errno: 5,
|
|
179
|
+
message: "database is locked",
|
|
180
|
+
};
|
|
181
|
+
expect(isRetryableSqlError(busy)).toBe(true);
|
|
182
|
+
expect(sqlErrorToFailure(busy, { retryable: "leave" })).toBeUndefined();
|
|
183
|
+
expect(sqlErrorToFailure(busy, { retryable: "unavailable" })?.error.code).toBe(
|
|
184
|
+
"ServiceUnavailable",
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("unknown SQLSTATE → DatabaseError unknown; non-SQL stays undefined", () => {
|
|
189
|
+
const other = sqlErrorToFailure({ code: "22012", message: "division by zero" });
|
|
190
|
+
expect(other?.error.code).toBe("DatabaseError");
|
|
191
|
+
expect(other?.error.data).toMatchObject({ reason: "unknown", sqlstate: "22012" });
|
|
192
|
+
expect(JSON.stringify(other)).not.toContain("division");
|
|
193
|
+
|
|
194
|
+
expect(sqlErrorToFailure(new Error("boom"))).toBeUndefined();
|
|
195
|
+
expect(isFlowFailure(sqlErrorToFailure({ code: "23505" }))).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
});
|