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,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in failure HTTP status map.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { fail } from "./errors.ts";
|
|
7
|
+
import { httpStatusForFailure, statusForBuiltinError } from "./builtin-errors.ts";
|
|
8
|
+
import { encodeExecuteResult, statusForFailure } from "../compiler/response.ts";
|
|
9
|
+
|
|
10
|
+
describe("statusForBuiltinError", () => {
|
|
11
|
+
test("maps REST codes", () => {
|
|
12
|
+
expect(statusForBuiltinError("NotFound")).toBe(404);
|
|
13
|
+
expect(statusForBuiltinError("Conflict")).toBe(409);
|
|
14
|
+
expect(statusForBuiltinError("ForeignKey")).toBe(409);
|
|
15
|
+
expect(statusForBuiltinError("Unauthorized")).toBe(401);
|
|
16
|
+
expect(statusForBuiltinError("Forbidden")).toBe(403);
|
|
17
|
+
expect(statusForBuiltinError("RateLimited")).toBe(429);
|
|
18
|
+
expect(statusForBuiltinError("AuthRateLimited")).toBe(429);
|
|
19
|
+
expect(statusForBuiltinError("ValidationError")).toBe(422);
|
|
20
|
+
expect(statusForBuiltinError("UnsupportedMediaType")).toBe(415);
|
|
21
|
+
expect(statusForBuiltinError("ServiceUnavailable")).toBe(503);
|
|
22
|
+
expect(statusForBuiltinError("InternalError")).toBe(500);
|
|
23
|
+
expect(statusForBuiltinError("AuthFailed")).toBe(400);
|
|
24
|
+
expect(statusForBuiltinError("InvalidQuery")).toBe(400);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("DatabaseError status follows data.reason", () => {
|
|
28
|
+
expect(statusForBuiltinError("DatabaseError", { reason: "not_null" })).toBe(422);
|
|
29
|
+
expect(statusForBuiltinError("DatabaseError", { reason: "check" })).toBe(422);
|
|
30
|
+
expect(statusForBuiltinError("DatabaseError", { reason: "invalid" })).toBe(422);
|
|
31
|
+
expect(statusForBuiltinError("DatabaseError", { reason: "too_long" })).toBe(422);
|
|
32
|
+
expect(statusForBuiltinError("DatabaseError", { reason: "out_of_range" })).toBe(422);
|
|
33
|
+
expect(statusForBuiltinError("DatabaseError", { reason: "retryable" })).toBe(503);
|
|
34
|
+
expect(statusForBuiltinError("DatabaseError", { reason: "unknown" })).toBe(500);
|
|
35
|
+
expect(statusForBuiltinError("DatabaseError", {})).toBe(500);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("domain codes are undefined so HTTP defaults to 400", () => {
|
|
39
|
+
expect(statusForBuiltinError("OutOfStock")).toBeUndefined();
|
|
40
|
+
expect(httpStatusForFailure("OutOfStock")).toBe(400);
|
|
41
|
+
expect(httpStatusForFailure("OKE1110")).toBe(500);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("statusForFailure", () => {
|
|
46
|
+
test("reads DatabaseError reason from the envelope", () => {
|
|
47
|
+
expect(statusForFailure(fail("DatabaseError", { reason: "not_null" }))).toBe(422);
|
|
48
|
+
expect(statusForFailure(fail("NotFound", { id: "n1" }))).toBe(404);
|
|
49
|
+
expect(statusForFailure(fail("FlightFull", { seatsLeft: 0 }))).toBe(400);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("encodeExecuteResult InternalError", () => {
|
|
54
|
+
test("never copies the thrown Error.message", async () => {
|
|
55
|
+
const res = await encodeExecuteResult({ error: new Error("secret leak") });
|
|
56
|
+
expect(res.status).toBe(500);
|
|
57
|
+
const body = (await res.json()) as {
|
|
58
|
+
data: null;
|
|
59
|
+
error: { code: string; message?: string; data?: unknown };
|
|
60
|
+
};
|
|
61
|
+
expect(body.error.code).toBe("InternalError");
|
|
62
|
+
expect(JSON.stringify(body)).not.toContain("secret leak");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("maps leftover unique SQLSTATE to Conflict", async () => {
|
|
66
|
+
const res = await encodeExecuteResult({
|
|
67
|
+
error: {
|
|
68
|
+
code: "23505",
|
|
69
|
+
message: "duplicate key value violates unique constraint",
|
|
70
|
+
detail: "Key (email)=(a@b.c) already exists.",
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
expect(res.status).toBe(409);
|
|
74
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
75
|
+
expect(body.error.code).toBe("Conflict");
|
|
76
|
+
expect(JSON.stringify(body)).not.toContain("a@b.c");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("maps leftover Redis connection failure to ServiceUnavailable", async () => {
|
|
80
|
+
const res = await encodeExecuteResult({
|
|
81
|
+
error: Object.assign(new Error("Connection closed"), { code: "ECONNREFUSED" }),
|
|
82
|
+
});
|
|
83
|
+
expect(res.status).toBe(503);
|
|
84
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
85
|
+
expect(body.error.code).toBe("ServiceUnavailable");
|
|
86
|
+
expect(JSON.stringify(body)).not.toContain("Connection closed");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("maps leftover S3 AccessDenied to Forbidden", async () => {
|
|
90
|
+
const res = await encodeExecuteResult({
|
|
91
|
+
error: Object.assign(new Error("Access Denied"), { code: "AccessDenied", status: 403 }),
|
|
92
|
+
});
|
|
93
|
+
expect(res.status).toBe(403);
|
|
94
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
95
|
+
expect(body.error.code).toBe("Forbidden");
|
|
96
|
+
expect(JSON.stringify(body)).not.toContain("Access Denied");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("leaves WRONGTYPE as InternalError", async () => {
|
|
100
|
+
const res = await encodeExecuteResult({
|
|
101
|
+
error: new Error("WRONGTYPE Operation against a key holding the wrong kind of value"),
|
|
102
|
+
});
|
|
103
|
+
expect(res.status).toBe(500);
|
|
104
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
105
|
+
expect(body.error.code).toBe("InternalError");
|
|
106
|
+
expect(JSON.stringify(body)).not.toContain("WRONGTYPE");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("maps exhausted serialization failure to ServiceUnavailable", async () => {
|
|
110
|
+
const res = await encodeExecuteResult({
|
|
111
|
+
error: { code: "40001", message: "could not serialize access" },
|
|
112
|
+
});
|
|
113
|
+
expect(res.status).toBe(503);
|
|
114
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
115
|
+
expect(body.error.code).toBe("ServiceUnavailable");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in flow-boundary error codes — HTTP status + payload types.
|
|
3
|
+
*
|
|
4
|
+
* Always-on for `fx.fail` helpers and the typed client. Domain codes
|
|
5
|
+
* (`OutOfStock`, `FlightFull`) stay on the exposure `errors:` bag.
|
|
6
|
+
* No Zod — this module sits on the kernel edge graph.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Built-in codes with a fixed HTTP status (not `DatabaseError`). */
|
|
10
|
+
export const BUILTIN_ERROR_STATUS = {
|
|
11
|
+
ValidationError: 422,
|
|
12
|
+
Unauthorized: 401,
|
|
13
|
+
Forbidden: 403,
|
|
14
|
+
NotFound: 404,
|
|
15
|
+
Conflict: 409,
|
|
16
|
+
ForeignKey: 409,
|
|
17
|
+
UnsupportedMediaType: 415,
|
|
18
|
+
RateLimited: 429,
|
|
19
|
+
AuthRateLimited: 429,
|
|
20
|
+
InvalidQuery: 400,
|
|
21
|
+
AuthFailed: 400,
|
|
22
|
+
InternalError: 500,
|
|
23
|
+
ServiceUnavailable: 503,
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
/** Built-in error code (including `DatabaseError`). */
|
|
27
|
+
export type BuiltinErrorCode = keyof typeof BUILTIN_ERROR_STATUS | "DatabaseError";
|
|
28
|
+
|
|
29
|
+
/** `DatabaseError.data.reason` — encoder maps these to HTTP status. */
|
|
30
|
+
export type DatabaseErrorReason =
|
|
31
|
+
| "not_null"
|
|
32
|
+
| "check"
|
|
33
|
+
| "invalid"
|
|
34
|
+
| "too_long"
|
|
35
|
+
| "out_of_range"
|
|
36
|
+
| "retryable"
|
|
37
|
+
| "unknown";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One `ValidationError` issue — same shape as the validation layer,
|
|
41
|
+
* inlined so this module stays off that graph.
|
|
42
|
+
*/
|
|
43
|
+
export type BuiltinValidationIssue = {
|
|
44
|
+
readonly message: string;
|
|
45
|
+
readonly path: ReadonlyArray<string | number>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Loose identity / constraint bag used by several built-in codes. */
|
|
49
|
+
export type BuiltinErrorBag = {
|
|
50
|
+
readonly id?: string;
|
|
51
|
+
readonly key?: string;
|
|
52
|
+
readonly code?: string;
|
|
53
|
+
readonly reason?: string;
|
|
54
|
+
readonly gate?: string;
|
|
55
|
+
readonly constraint?: string;
|
|
56
|
+
readonly table?: string;
|
|
57
|
+
readonly column?: string;
|
|
58
|
+
readonly sqlstate?: string;
|
|
59
|
+
readonly retryAfterMs?: number;
|
|
60
|
+
readonly retryAfter?: number;
|
|
61
|
+
readonly contentType?: string;
|
|
62
|
+
readonly [key: string]: unknown;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Built-in error code → payload. Client unions this with declared `errors:`.
|
|
67
|
+
*/
|
|
68
|
+
export type BuiltinErrorMap = {
|
|
69
|
+
readonly ValidationError: { readonly issues: readonly BuiltinValidationIssue[] };
|
|
70
|
+
readonly Unauthorized: BuiltinErrorBag;
|
|
71
|
+
readonly Forbidden: BuiltinErrorBag;
|
|
72
|
+
readonly NotFound: BuiltinErrorBag;
|
|
73
|
+
readonly Conflict: BuiltinErrorBag;
|
|
74
|
+
readonly ForeignKey: BuiltinErrorBag;
|
|
75
|
+
readonly UnsupportedMediaType: BuiltinErrorBag;
|
|
76
|
+
readonly RateLimited: { readonly retryAfterMs?: number };
|
|
77
|
+
readonly AuthRateLimited: BuiltinErrorBag;
|
|
78
|
+
readonly InvalidQuery: BuiltinErrorBag;
|
|
79
|
+
readonly AuthFailed: BuiltinErrorBag;
|
|
80
|
+
readonly DatabaseError: {
|
|
81
|
+
readonly reason: DatabaseErrorReason;
|
|
82
|
+
readonly sqlstate?: string;
|
|
83
|
+
readonly constraint?: string;
|
|
84
|
+
readonly table?: string;
|
|
85
|
+
readonly column?: string;
|
|
86
|
+
};
|
|
87
|
+
readonly InternalError: Record<string, never>;
|
|
88
|
+
readonly ServiceUnavailable: { readonly retryAfter?: number };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* HTTP status for a built-in failure code, or `undefined` for domain codes.
|
|
93
|
+
*
|
|
94
|
+
* `DatabaseError` uses `data.reason`: `retryable` → 503, `unknown` / missing
|
|
95
|
+
* → 500, any other reason (`not_null`, `check`, `invalid`, …) → 422.
|
|
96
|
+
*
|
|
97
|
+
* @param code - Failure `error.code`
|
|
98
|
+
* @param data - Failure `error.data` (reason for `DatabaseError`)
|
|
99
|
+
*/
|
|
100
|
+
export function statusForBuiltinError(code: string, data?: unknown): number | undefined {
|
|
101
|
+
if (code === "DatabaseError") {
|
|
102
|
+
const reason = databaseErrorReason(data);
|
|
103
|
+
if (reason === "retryable") return 503;
|
|
104
|
+
if (reason !== undefined && reason !== "unknown") return 422;
|
|
105
|
+
return 500;
|
|
106
|
+
}
|
|
107
|
+
if (Object.prototype.hasOwnProperty.call(BUILTIN_ERROR_STATUS, code)) {
|
|
108
|
+
return BUILTIN_ERROR_STATUS[code as keyof typeof BUILTIN_ERROR_STATUS];
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* HTTP status for a typed flow failure. Domain codes default to 400.
|
|
115
|
+
*
|
|
116
|
+
* @param code - Failure code
|
|
117
|
+
* @param data - Failure payload
|
|
118
|
+
*/
|
|
119
|
+
export function httpStatusForFailure(code: string, data?: unknown): number {
|
|
120
|
+
const mapped = statusForBuiltinError(code, data);
|
|
121
|
+
if (mapped !== undefined) return mapped;
|
|
122
|
+
return code.startsWith("OKE") ? 500 : 400;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function databaseErrorReason(data: unknown): string | undefined {
|
|
126
|
+
if (data === null || typeof data !== "object") return undefined;
|
|
127
|
+
const reason = (data as { reason?: unknown }).reason;
|
|
128
|
+
return typeof reason === "string" ? reason : undefined;
|
|
129
|
+
}
|
package/src/kernel/call.test.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test, beforeEach } from "bun:test";
|
|
2
2
|
import { oke } from "./app.ts";
|
|
3
|
+
import { fail } from "./fail-helpers.ts";
|
|
3
4
|
import { flow, resetFlowSeq } from "./flow.ts";
|
|
5
|
+
import { isFlowFailure } from "./hooks.ts";
|
|
4
6
|
import { on, resetBindings } from "./on.ts";
|
|
5
7
|
import { http } from "./triggers.ts";
|
|
6
8
|
|
|
@@ -120,3 +122,183 @@ describe("fx.call — untriggered flows", () => {
|
|
|
120
122
|
expect(runs).toBe(2);
|
|
121
123
|
});
|
|
122
124
|
});
|
|
125
|
+
|
|
126
|
+
describe("error pipeline — RPC / call propagation", () => {
|
|
127
|
+
test("regression: unhandled RPC exception must never become 204 No Content", async () => {
|
|
128
|
+
const boom = flow("math.boom", {
|
|
129
|
+
do: () => {
|
|
130
|
+
throw new Error("sensitive leak");
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
const app = oke({ autoBoot: false, name: "rpc-boom" }).adopt(boom);
|
|
134
|
+
|
|
135
|
+
const res = await app.fetch(
|
|
136
|
+
new Request("http://localhost/_oke/math/boom", {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { "content-type": "application/json" },
|
|
139
|
+
body: "{}",
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(res.status).toBe(500);
|
|
144
|
+
expect(res.status).not.toBe(204);
|
|
145
|
+
const body = (await res.json()) as {
|
|
146
|
+
data: unknown;
|
|
147
|
+
error: { code: string; data: unknown; message?: string };
|
|
148
|
+
};
|
|
149
|
+
expect(body.data).toBeNull();
|
|
150
|
+
expect(body.error.code).toBe("InternalError");
|
|
151
|
+
expect(body.error.data).toEqual({});
|
|
152
|
+
expect(body.error.message).toBe("Something went wrong. Try again.");
|
|
153
|
+
expect(JSON.stringify(body)).not.toContain("sensitive leak");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("RPC store auto-map unique violation is Conflict 409", async () => {
|
|
157
|
+
const clash = flow("math.clash", {
|
|
158
|
+
do: () => {
|
|
159
|
+
throw Object.assign(new Error("duplicate key"), {
|
|
160
|
+
code: "23505",
|
|
161
|
+
constraint: "users_email_key",
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
const app = oke({ autoBoot: false, name: "rpc-clash" }).adopt(clash);
|
|
166
|
+
|
|
167
|
+
const res = await app.fetch(
|
|
168
|
+
new Request("http://localhost/_oke/math/clash", {
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: { "content-type": "application/json" },
|
|
171
|
+
body: "{}",
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
expect(res.status).toBe(409);
|
|
176
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
177
|
+
expect(body.error.code).toBe("Conflict");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("ExecuteResult.error is a derived projection of ctx.error", async () => {
|
|
181
|
+
const boom = flow("math.boom", {
|
|
182
|
+
do: () => {
|
|
183
|
+
throw new Error("disk failed");
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
const app = oke({ autoBoot: false, name: "exec-err" }).adopt(boom);
|
|
187
|
+
const result = await app.execute(boom, {}, { kind: "internal" });
|
|
188
|
+
|
|
189
|
+
expect(result.failure).toBeUndefined();
|
|
190
|
+
expect(result.ctx.error).toBeDefined();
|
|
191
|
+
expect(result.error).toBe(result.ctx.error);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("app.call returns FlowFailure value for controlled fail", async () => {
|
|
195
|
+
const missing = flow("notes.missing", {
|
|
196
|
+
do: () => fail.notFound({ id: "1" }),
|
|
197
|
+
});
|
|
198
|
+
const app = oke({ autoBoot: false, name: "call-fail" }).adopt(missing);
|
|
199
|
+
const out = await app.call(missing, {});
|
|
200
|
+
expect(isFlowFailure(out)).toBe(true);
|
|
201
|
+
if (isFlowFailure(out)) {
|
|
202
|
+
expect(out.error.code).toBe("NotFound");
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("app.call rethrows unhandled exception (never undefined)", async () => {
|
|
207
|
+
const boom = flow("notes.boom", {
|
|
208
|
+
do: () => {
|
|
209
|
+
throw new Error("disk failed");
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
const app = oke({ autoBoot: false, name: "call-throw" }).adopt(boom);
|
|
213
|
+
await expect(app.call(boom, {})).rejects.toThrow("disk failed");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("fx.call returns FlowFailure value for controlled fail", async () => {
|
|
217
|
+
const child = flow("child.fail", {
|
|
218
|
+
do: () => fail.notFound({ id: "x" }),
|
|
219
|
+
});
|
|
220
|
+
on(
|
|
221
|
+
http.post("/run"),
|
|
222
|
+
flow("parent.run", {
|
|
223
|
+
effects: { calls: ["child.fail"] },
|
|
224
|
+
do: async (_input: Record<string, never>, fx) => {
|
|
225
|
+
const out = await fx.call("child.fail", {});
|
|
226
|
+
expect(isFlowFailure(out)).toBe(true);
|
|
227
|
+
return isFlowFailure(out) ? out : { unexpected: true };
|
|
228
|
+
},
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
const app = oke({ autoBoot: false, name: "fx-fail" }).adopt(child);
|
|
232
|
+
const res = await app.fetch(
|
|
233
|
+
new Request("http://localhost/run", {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: { "content-type": "application/json" },
|
|
236
|
+
body: "{}",
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
expect(res.status).toBe(404);
|
|
240
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
241
|
+
expect(body.error.code).toBe("NotFound");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("fx.call rethrows when child throws", async () => {
|
|
245
|
+
const child = flow("child.boom", {
|
|
246
|
+
do: () => {
|
|
247
|
+
throw new Error("worker crash");
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
on(
|
|
251
|
+
http.post("/run"),
|
|
252
|
+
flow("parent.run", {
|
|
253
|
+
effects: { calls: ["child.boom"] },
|
|
254
|
+
do: async (_input: Record<string, never>, fx) => {
|
|
255
|
+
return fx.call("child.boom", {});
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
const app = oke({ autoBoot: false, name: "fx-throw" }).adopt(child);
|
|
260
|
+
const res = await app.fetch(
|
|
261
|
+
new Request("http://localhost/run", {
|
|
262
|
+
method: "POST",
|
|
263
|
+
headers: { "content-type": "application/json" },
|
|
264
|
+
body: "{}",
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
expect(res.status).toBe(500);
|
|
268
|
+
const body = (await res.json()) as { data: unknown; error: { code: string; message?: string } };
|
|
269
|
+
expect(body.data).toBeNull();
|
|
270
|
+
expect(body.error.code).toBe("InternalError");
|
|
271
|
+
expect(JSON.stringify(body)).not.toContain("worker crash");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("nested fx.call chain propagates throw to outer boundary", async () => {
|
|
275
|
+
const deep = flow("deep.boom", {
|
|
276
|
+
do: () => {
|
|
277
|
+
throw new Error("deep crash");
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
const mid = flow("mid.call", {
|
|
281
|
+
effects: { calls: ["deep.boom"] },
|
|
282
|
+
do: async (_input: Record<string, never>, fx) => fx.call("deep.boom", {}),
|
|
283
|
+
});
|
|
284
|
+
on(
|
|
285
|
+
http.post("/run"),
|
|
286
|
+
flow("outer.run", {
|
|
287
|
+
effects: { calls: ["mid.call"] },
|
|
288
|
+
do: async (_input: Record<string, never>, fx) => fx.call("mid.call", {}),
|
|
289
|
+
}),
|
|
290
|
+
);
|
|
291
|
+
const app = oke({ autoBoot: false, name: "nested-throw" }).adopt(deep).adopt(mid);
|
|
292
|
+
const res = await app.fetch(
|
|
293
|
+
new Request("http://localhost/run", {
|
|
294
|
+
method: "POST",
|
|
295
|
+
headers: { "content-type": "application/json" },
|
|
296
|
+
body: "{}",
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
expect(res.status).toBe(500);
|
|
300
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
301
|
+
expect(body.error.code).toBe("InternalError");
|
|
302
|
+
expect(JSON.stringify(body)).not.toContain("deep crash");
|
|
303
|
+
});
|
|
304
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OKE1510 — kept off the kernel edge graph.
|
|
3
|
+
*
|
|
4
|
+
* Vault boot gaps. Loaded via computed `import.meta.require` from
|
|
5
|
+
* {@link lookupOkeError}.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { OkeErrorDefinition } from "./errors.ts";
|
|
9
|
+
|
|
10
|
+
/** Vault contract has no value in any resolution layer (boot). */
|
|
11
|
+
export const VAULT_SECRET_MISSING: OkeErrorDefinition = {
|
|
12
|
+
code: 1510,
|
|
13
|
+
domain: "vault",
|
|
14
|
+
cause: "{count} secrets have no value in any resolution layer.",
|
|
15
|
+
fix: "Set each name (`oke vault set <name>`, or `.env.local`).",
|
|
16
|
+
};
|
|
@@ -20,6 +20,7 @@ import { CHANNEL_SCHEMA } from "./errors-channel.ts";
|
|
|
20
20
|
import { FLOW_NAME_DUPLICATE, FLOW_UNNAMED } from "./errors-flow-name.ts";
|
|
21
21
|
import { ONCE_SIGNAL_MULTI_FLOW } from "./errors-once-signal.ts";
|
|
22
22
|
import { TENANT_NOT_MEMBER, TENANT_REQUIRED, TENANT_UNKNOWN_SCOPE } from "./errors-tenant.ts";
|
|
23
|
+
import { VAULT_SECRET_MISSING } from "./errors-vault.ts";
|
|
23
24
|
import {
|
|
24
25
|
assertCodesInDomainRanges,
|
|
25
26
|
assertUniqueCodes,
|
|
@@ -65,8 +66,10 @@ describe("OKE error-code registry", () => {
|
|
|
65
66
|
expect(files).toContain("errors-live-resume.ts");
|
|
66
67
|
expect(files).toContain("errors-channel.ts");
|
|
67
68
|
expect(files).toContain("errors-tenant.ts");
|
|
69
|
+
expect(files).toContain("errors-vault.ts");
|
|
68
70
|
const codes = defs.map((d) => d.code);
|
|
69
71
|
expect(codes).toContain(1210);
|
|
72
|
+
expect(codes).toContain(1510);
|
|
70
73
|
expect(codes).toContain(1605);
|
|
71
74
|
expect(codes).toContain(1810);
|
|
72
75
|
expect(codes).toContain(1820);
|
|
@@ -160,6 +163,10 @@ describe("OKE error-code registry", () => {
|
|
|
160
163
|
expect(lookupOkeError(1605)).toEqual(CHANNEL_SCHEMA);
|
|
161
164
|
});
|
|
162
165
|
|
|
166
|
+
test("lookupOkeError finds the lazy VAULT_SECRET_MISSING entry", () => {
|
|
167
|
+
expect(lookupOkeError(1510)).toEqual(VAULT_SECRET_MISSING);
|
|
168
|
+
});
|
|
169
|
+
|
|
163
170
|
test("lookupOkeError finds lazy tenant entries", () => {
|
|
164
171
|
expect(lookupOkeError(1810)).toEqual(TENANT_REQUIRED);
|
|
165
172
|
expect(lookupOkeError(1820)).toEqual(TENANT_NOT_MEMBER);
|
package/src/kernel/errors.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { docsUrl as absoluteDocsUrl } from "../docs-origin.ts";
|
|
10
10
|
import { getActiveDefaultLocale, getActiveLocale } from "../i18n/locale-context.ts";
|
|
11
11
|
import { lazyRequire } from "./lazy-require.ts";
|
|
12
|
+
import type { BuiltinErrorMap } from "./builtin-errors.ts";
|
|
12
13
|
|
|
13
14
|
/** Catalogs — kept off the edge `fail` / `OKE_ERRORS` static graph. */
|
|
14
15
|
function loadMessages(): typeof import("../i18n/messages.ts") {
|
|
@@ -144,6 +145,93 @@ export interface FailOptions {
|
|
|
144
145
|
readonly message?: string;
|
|
145
146
|
}
|
|
146
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Callable `fail` plus built-in helpers (`fail.notFound`, `fail.forbidden`, …).
|
|
150
|
+
*/
|
|
151
|
+
export interface FailFn {
|
|
152
|
+
/**
|
|
153
|
+
* Flow-boundary failure value (does not throw).
|
|
154
|
+
*
|
|
155
|
+
* @param code - Declared or built-in error code
|
|
156
|
+
* @param data - Error payload
|
|
157
|
+
* @param opts - Optional message override
|
|
158
|
+
*/
|
|
159
|
+
<E>(code: string, data: E, opts?: FailOptions): FlowFailure<E>;
|
|
160
|
+
/** Missing row / object — HTTP 404. */
|
|
161
|
+
notFound(
|
|
162
|
+
data?: BuiltinErrorMap["NotFound"],
|
|
163
|
+
opts?: FailOptions,
|
|
164
|
+
): FlowFailure<BuiltinErrorMap["NotFound"]>;
|
|
165
|
+
/** Not authenticated — HTTP 401. */
|
|
166
|
+
unauthorized(
|
|
167
|
+
data?: BuiltinErrorMap["Unauthorized"],
|
|
168
|
+
opts?: FailOptions,
|
|
169
|
+
): FlowFailure<BuiltinErrorMap["Unauthorized"]>;
|
|
170
|
+
/** Authenticated but denied — HTTP 403. */
|
|
171
|
+
forbidden(
|
|
172
|
+
data?: BuiltinErrorMap["Forbidden"],
|
|
173
|
+
opts?: FailOptions,
|
|
174
|
+
): FlowFailure<BuiltinErrorMap["Forbidden"]>;
|
|
175
|
+
/** Unique / exclusion conflict — HTTP 409. */
|
|
176
|
+
conflict(
|
|
177
|
+
data?: BuiltinErrorMap["Conflict"],
|
|
178
|
+
opts?: FailOptions,
|
|
179
|
+
): FlowFailure<BuiltinErrorMap["Conflict"]>;
|
|
180
|
+
/** Foreign-key violation — HTTP 409. */
|
|
181
|
+
foreignKey(
|
|
182
|
+
data?: BuiltinErrorMap["ForeignKey"],
|
|
183
|
+
opts?: FailOptions,
|
|
184
|
+
): FlowFailure<BuiltinErrorMap["ForeignKey"]>;
|
|
185
|
+
/** Rate budget exhausted — HTTP 429. */
|
|
186
|
+
rateLimited(
|
|
187
|
+
data?: BuiltinErrorMap["RateLimited"],
|
|
188
|
+
opts?: FailOptions,
|
|
189
|
+
): FlowFailure<BuiltinErrorMap["RateLimited"]>;
|
|
190
|
+
/** Maintenance / connection — HTTP 503. */
|
|
191
|
+
serviceUnavailable(
|
|
192
|
+
data?: BuiltinErrorMap["ServiceUnavailable"],
|
|
193
|
+
opts?: FailOptions,
|
|
194
|
+
): FlowFailure<BuiltinErrorMap["ServiceUnavailable"]>;
|
|
195
|
+
/** SQL / store constraint that is not unique or FK. */
|
|
196
|
+
database(
|
|
197
|
+
data: BuiltinErrorMap["DatabaseError"],
|
|
198
|
+
opts?: FailOptions,
|
|
199
|
+
): FlowFailure<BuiltinErrorMap["DatabaseError"]>;
|
|
200
|
+
/** Unhandled throw — HTTP 500. Catalog message only. */
|
|
201
|
+
internal(
|
|
202
|
+
data?: BuiltinErrorMap["InternalError"],
|
|
203
|
+
opts?: FailOptions,
|
|
204
|
+
): FlowFailure<BuiltinErrorMap["InternalError"]>;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Create a flow-boundary failure value (does not throw).
|
|
209
|
+
*
|
|
210
|
+
* When `opts.message` is omitted, attaches a localized message from the
|
|
211
|
+
* built-in / app catalogs (`errors.{code}.{reason}` → `errors.{code}`) using
|
|
212
|
+
* the active request locale. Custom codes with no catalog entry stay
|
|
213
|
+
* message-less.
|
|
214
|
+
*
|
|
215
|
+
* @param code - Declared error code from the flow's `errors` map
|
|
216
|
+
* @param data - Error payload
|
|
217
|
+
* @param opts - Optional message override
|
|
218
|
+
*/
|
|
219
|
+
function failImpl<E>(code: string, data: E, opts?: FailOptions): FlowFailure<E> {
|
|
220
|
+
const message =
|
|
221
|
+
opts?.message !== undefined
|
|
222
|
+
? opts.message
|
|
223
|
+
: loadFailureMessage().resolveFailureMessage(code, data);
|
|
224
|
+
const error: FlowErrorValue<E> = message !== undefined ? { code, data, message } : { code, data };
|
|
225
|
+
return { data: null, error };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Flow-boundary failure (callable). Built-in helpers live on the lazy
|
|
230
|
+
* `fail-helpers` chunk so they stay off the kernel edge graph — {@link createFx}
|
|
231
|
+
* and the public `okengine` export load that chunk.
|
|
232
|
+
*/
|
|
233
|
+
export const fail: FailFn = failImpl as FailFn;
|
|
234
|
+
|
|
147
235
|
/**
|
|
148
236
|
* Permanent error registry. Codes are stable within their domain range after
|
|
149
237
|
* the domain-range renumber; each entry declares `domain` for range guards.
|
|
@@ -368,6 +456,12 @@ export function lookupOkeError(code: OkeErrorCode): OkeErrorDefinition | undefin
|
|
|
368
456
|
["errors", "live", "resume"].join("-"),
|
|
369
457
|
).LIVE_RESUME_GAP;
|
|
370
458
|
}
|
|
459
|
+
if (code === 1510) {
|
|
460
|
+
return lazyRequire<typeof import("./errors-vault.ts")>(
|
|
461
|
+
import.meta.dir,
|
|
462
|
+
["errors", "vault"].join("-"),
|
|
463
|
+
).VAULT_SECRET_MISSING;
|
|
464
|
+
}
|
|
371
465
|
if (code === 1605) {
|
|
372
466
|
return lazyRequire<typeof import("./errors-channel.ts")>(
|
|
373
467
|
import.meta.dir,
|
|
@@ -383,27 +477,6 @@ export function lookupOkeError(code: OkeErrorCode): OkeErrorDefinition | undefin
|
|
|
383
477
|
return undefined;
|
|
384
478
|
}
|
|
385
479
|
|
|
386
|
-
/**
|
|
387
|
-
* Create a flow-boundary failure value (does not throw).
|
|
388
|
-
*
|
|
389
|
-
* When `opts.message` is omitted, attaches a localized message from the
|
|
390
|
-
* built-in / app catalogs (`errors.{code}.{reason}` → `errors.{code}`) using
|
|
391
|
-
* the active request locale. Custom codes with no catalog entry stay
|
|
392
|
-
* message-less.
|
|
393
|
-
*
|
|
394
|
-
* @param code - Declared error code from the flow's `errors` map
|
|
395
|
-
* @param data - Error payload
|
|
396
|
-
* @param opts - Optional message override
|
|
397
|
-
*/
|
|
398
|
-
export function fail<E>(code: string, data: E, opts?: FailOptions): FlowFailure<E> {
|
|
399
|
-
const message =
|
|
400
|
-
opts?.message !== undefined
|
|
401
|
-
? opts.message
|
|
402
|
-
: loadFailureMessage().resolveFailureMessage(code, data);
|
|
403
|
-
const error: FlowErrorValue<E> = message !== undefined ? { code, data, message } : { code, data };
|
|
404
|
-
return { data: null, error };
|
|
405
|
-
}
|
|
406
|
-
|
|
407
480
|
/**
|
|
408
481
|
* Format the canonical multi-line OKE error message (§21).
|
|
409
482
|
*
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in `fail.notFound` / `fail.forbidden` / … helpers.
|
|
3
|
+
*
|
|
4
|
+
* Kept off the kernel edge graph — loaded via computed `import.meta.require`.
|
|
5
|
+
* Mutates the shared {@link fail} function so `fx.fail` and `import { fail }`
|
|
6
|
+
* share one identity.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { fail as failImpl, type FailFn, type FailOptions } from "./errors.ts";
|
|
10
|
+
import type { BuiltinErrorMap } from "./builtin-errors.ts";
|
|
11
|
+
|
|
12
|
+
const empty = {};
|
|
13
|
+
const helper =
|
|
14
|
+
(code: string) =>
|
|
15
|
+
(data = empty, opts?: FailOptions) =>
|
|
16
|
+
failImpl(code, data, opts);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Callable `fail` plus helpers. Same function object as kernel `fail`.
|
|
20
|
+
*/
|
|
21
|
+
export const fail: FailFn = Object.assign(failImpl, {
|
|
22
|
+
notFound: helper("NotFound"),
|
|
23
|
+
unauthorized: helper("Unauthorized"),
|
|
24
|
+
forbidden: helper("Forbidden"),
|
|
25
|
+
conflict: helper("Conflict"),
|
|
26
|
+
foreignKey: helper("ForeignKey"),
|
|
27
|
+
rateLimited: helper("RateLimited"),
|
|
28
|
+
serviceUnavailable: helper("ServiceUnavailable"),
|
|
29
|
+
database: (data: BuiltinErrorMap["DatabaseError"], opts?: FailOptions) =>
|
|
30
|
+
failImpl("DatabaseError", data, opts),
|
|
31
|
+
internal: helper("InternalError"),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export type { FailFn, FailOptions } from "./errors.ts";
|
package/src/kernel/fx.test.ts
CHANGED
|
@@ -728,6 +728,14 @@ describe("errors — registry", () => {
|
|
|
728
728
|
});
|
|
729
729
|
});
|
|
730
730
|
|
|
731
|
+
test("fx.fail.notFound is callable without errors: declaration", () => {
|
|
732
|
+
const fx = createFx({ flow: "x", effects: {} });
|
|
733
|
+
const result = fx.fail.notFound({ id: "n1" });
|
|
734
|
+
expect(result.error.code).toBe("NotFound");
|
|
735
|
+
expect(result.error.data).toEqual({ id: "n1" });
|
|
736
|
+
expect(result.data).toBeNull();
|
|
737
|
+
});
|
|
738
|
+
|
|
731
739
|
test("capability token allows only declared resources", () => {
|
|
732
740
|
const token = createCapabilityToken("f", {
|
|
733
741
|
reads: ["sql:a"],
|