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
|
@@ -90,7 +90,8 @@ try {
|
|
|
90
90
|
// `bun --hot` keeps the watcher alive on an uncaught throw, so `oke dev`
|
|
91
91
|
// would wait the full ready timeout after VaultBootError. Exit so the
|
|
92
92
|
// parent sees a dead child immediately.
|
|
93
|
-
|
|
93
|
+
const { formatFatalError } = await import("../term.ts");
|
|
94
|
+
console.error(formatFatalError(err));
|
|
94
95
|
process.exit(1);
|
|
95
96
|
}
|
|
96
97
|
const handle = createBunRuntime().serve(mod.app, {
|
package/src/cli/start.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { resolve } from "node:path";
|
|
6
6
|
import { APP_PORT } from "../runtime/types.ts";
|
|
7
|
+
import { formatFatalError } from "../term.ts";
|
|
7
8
|
|
|
8
9
|
/** Options for {@link runStart}. */
|
|
9
10
|
export interface StartOptions {
|
|
@@ -77,7 +78,7 @@ export async function runStart(options: StartOptions = {}): Promise<number> {
|
|
|
77
78
|
await import(entry);
|
|
78
79
|
return 0;
|
|
79
80
|
} catch (err) {
|
|
80
|
-
console.error(
|
|
81
|
+
console.error(formatFatalError(err));
|
|
81
82
|
return 1;
|
|
82
83
|
}
|
|
83
84
|
}
|
package/src/client/create.ts
CHANGED
|
@@ -194,14 +194,14 @@ export function flattenRoutes(
|
|
|
194
194
|
function proxy(transport: Transport, path: readonly string[], ctx: ProxyCtx): unknown {
|
|
195
195
|
const invoke = async (input?: unknown, callOpts?: CallOpts): Promise<ClientResult> => {
|
|
196
196
|
if (path.length < 2) {
|
|
197
|
+
const message = `Incomplete path: api.${path.join(".") || "?"}(…)`;
|
|
197
198
|
return attachPager(
|
|
198
199
|
{
|
|
199
200
|
data: null,
|
|
200
201
|
error: {
|
|
201
202
|
code: "TransportError" as const,
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
},
|
|
203
|
+
message,
|
|
204
|
+
data: { message },
|
|
205
205
|
},
|
|
206
206
|
},
|
|
207
207
|
(nextInput) => invoke(nextInput, callOpts),
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client `explain` / `match` / `matchError` — UX kinds, fields, result matcher.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { explain, match, matchError } from "./explain.ts";
|
|
7
|
+
import type { BuiltinErrorMap } from "../kernel/builtin-errors.ts";
|
|
8
|
+
import type { ClientError } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
/** Compile-time equality. */
|
|
11
|
+
type Eq<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
|
|
12
|
+
type Assert<T extends true> = T;
|
|
13
|
+
|
|
14
|
+
describe("explain — kinds", () => {
|
|
15
|
+
test("maps built-in codes", () => {
|
|
16
|
+
expect(explain({ code: "Unauthorized", data: {} }).kind).toBe("auth");
|
|
17
|
+
expect(explain({ code: "AuthFailed", data: {} }).kind).toBe("auth");
|
|
18
|
+
expect(explain({ code: "Forbidden", data: {} }).kind).toBe("permission");
|
|
19
|
+
expect(explain({ code: "NotFound", data: { id: "n1" } }).kind).toBe("missing");
|
|
20
|
+
expect(explain({ code: "Conflict", data: {} }).kind).toBe("conflict");
|
|
21
|
+
expect(explain({ code: "ForeignKey", data: {} }).kind).toBe("conflict");
|
|
22
|
+
expect(explain({ code: "ValidationError", data: { issues: [] } }).kind).toBe("invalid");
|
|
23
|
+
expect(explain({ code: "InvalidQuery", data: {} }).kind).toBe("invalid");
|
|
24
|
+
expect(explain({ code: "UnsupportedMediaType", data: {} }).kind).toBe("invalid");
|
|
25
|
+
expect(explain({ code: "RateLimited", data: {} }).kind).toBe("limited");
|
|
26
|
+
expect(explain({ code: "AuthRateLimited", data: {} }).kind).toBe("limited");
|
|
27
|
+
expect(explain({ code: "ServiceUnavailable", data: {} }).kind).toBe("unavailable");
|
|
28
|
+
expect(explain({ code: "InternalError", data: {} }).kind).toBe("failed");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("DatabaseError reasons", () => {
|
|
32
|
+
expect(explain({ code: "DatabaseError", data: { reason: "not_null" } }).kind).toBe("invalid");
|
|
33
|
+
expect(explain({ code: "DatabaseError", data: { reason: "check" } }).kind).toBe("invalid");
|
|
34
|
+
expect(explain({ code: "DatabaseError", data: { reason: "invalid" } }).kind).toBe("invalid");
|
|
35
|
+
expect(explain({ code: "DatabaseError", data: { reason: "too_long" } }).kind).toBe("invalid");
|
|
36
|
+
expect(explain({ code: "DatabaseError", data: { reason: "out_of_range" } }).kind).toBe(
|
|
37
|
+
"invalid",
|
|
38
|
+
);
|
|
39
|
+
expect(explain({ code: "DatabaseError", data: { reason: "retryable" } }).kind).toBe(
|
|
40
|
+
"unavailable",
|
|
41
|
+
);
|
|
42
|
+
expect(explain({ code: "DatabaseError", data: { reason: "unknown" } }).kind).toBe("failed");
|
|
43
|
+
expect(explain({ code: "DatabaseError", data: {} }).kind).toBe("failed");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("TransportError 404 is missing; other transport is unavailable", () => {
|
|
47
|
+
expect(
|
|
48
|
+
explain({
|
|
49
|
+
code: "TransportError",
|
|
50
|
+
data: { message: "Not Found", status: 404 },
|
|
51
|
+
}).kind,
|
|
52
|
+
).toBe("missing");
|
|
53
|
+
expect(
|
|
54
|
+
explain({
|
|
55
|
+
code: "TransportError",
|
|
56
|
+
data: { message: "offline" },
|
|
57
|
+
}).kind,
|
|
58
|
+
).toBe("unavailable");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("domain codes are failed", () => {
|
|
62
|
+
expect(explain({ code: "FlightFull", data: { seatsLeft: 0 } }).kind).toBe("failed");
|
|
63
|
+
expect(explain({ code: "OutOfStock", data: { available: 0 } }).kind).toBe("failed");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("explain — message, retry, fields", () => {
|
|
68
|
+
test("prefers envelope message, then transport data.message, then code", () => {
|
|
69
|
+
expect(
|
|
70
|
+
explain({ code: "NotFound", data: {}, message: "The requested resource was not found." })
|
|
71
|
+
.message,
|
|
72
|
+
).toBe("The requested resource was not found.");
|
|
73
|
+
expect(
|
|
74
|
+
explain({ code: "TransportError", data: { message: "HTTP 502", status: 502 } }).message,
|
|
75
|
+
).toBe("HTTP 502");
|
|
76
|
+
expect(explain({ code: "OutOfStock", data: {} }).message).toBe("OutOfStock");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("retryable and retryAfterMs", () => {
|
|
80
|
+
const limited = explain({ code: "RateLimited", data: { retryAfterMs: 1500 } });
|
|
81
|
+
expect(limited.retryable).toBe(true);
|
|
82
|
+
expect(limited.retryAfterMs).toBe(1500);
|
|
83
|
+
const unavailable = explain({ code: "ServiceUnavailable", data: { retryAfter: 12 } });
|
|
84
|
+
expect(unavailable.retryable).toBe(true);
|
|
85
|
+
expect(unavailable.retryAfterMs).toBe(12_000);
|
|
86
|
+
const missing = explain({ code: "NotFound", data: { id: "n1" } });
|
|
87
|
+
expect(missing.retryable).toBe(false);
|
|
88
|
+
expect(missing.retryAfterMs).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("ValidationError fields join path; empty path is _", () => {
|
|
92
|
+
const e = explain({
|
|
93
|
+
code: "ValidationError",
|
|
94
|
+
data: {
|
|
95
|
+
issues: [
|
|
96
|
+
{ message: "Required", path: ["email"] },
|
|
97
|
+
{ message: "Too short", path: ["user", "name"] },
|
|
98
|
+
{ message: "Invalid", path: [] },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
expect(e.kind).toBe("invalid");
|
|
103
|
+
expect(e.fields).toEqual({
|
|
104
|
+
email: "Required",
|
|
105
|
+
"user.name": "Too short",
|
|
106
|
+
_: "Invalid",
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("matchError", () => {
|
|
112
|
+
type Err = ClientError<{
|
|
113
|
+
FlightFull: { seatsLeft: number };
|
|
114
|
+
NotFound: Record<string, never>;
|
|
115
|
+
}>;
|
|
116
|
+
|
|
117
|
+
test("named arm narrows FlightFull data; _ handles the rest", () => {
|
|
118
|
+
const full: Err = { code: "FlightFull", data: { seatsLeft: 2 } };
|
|
119
|
+
const seats = matchError(full, {
|
|
120
|
+
FlightFull: (data) => {
|
|
121
|
+
type _D = Assert<Eq<typeof data, { seatsLeft: number }>>;
|
|
122
|
+
const keep: _D = true;
|
|
123
|
+
expect(keep).toBe(true);
|
|
124
|
+
return data.seatsLeft;
|
|
125
|
+
},
|
|
126
|
+
_: () => -1,
|
|
127
|
+
});
|
|
128
|
+
expect(seats).toBe(2);
|
|
129
|
+
|
|
130
|
+
const miss: Err = { code: "NotFound", data: {} };
|
|
131
|
+
const fallback = matchError<Err, string | number>(miss, {
|
|
132
|
+
FlightFull: (data) => data.seatsLeft,
|
|
133
|
+
_: (e) => {
|
|
134
|
+
expect(e.kind).toBe("missing");
|
|
135
|
+
return e.code;
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
expect(fallback).toBe("NotFound");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("kind arm runs when the code is unnamed; code wins over kind", () => {
|
|
142
|
+
const denied: Err = { code: "NotFound", data: {} };
|
|
143
|
+
const kindHit = matchError(denied, {
|
|
144
|
+
missing: (e) => {
|
|
145
|
+
type _K = Assert<Eq<(typeof e)["kind"], "missing">>;
|
|
146
|
+
const keep: _K = true;
|
|
147
|
+
expect(keep).toBe(true);
|
|
148
|
+
expect(e.kind).toBe("missing");
|
|
149
|
+
expect(e.code).toBe("NotFound");
|
|
150
|
+
return e.message;
|
|
151
|
+
},
|
|
152
|
+
_: () => "dump",
|
|
153
|
+
});
|
|
154
|
+
expect(kindHit).toBe("NotFound");
|
|
155
|
+
|
|
156
|
+
const full: Err = { code: "FlightFull", data: { seatsLeft: 3 } };
|
|
157
|
+
const codeWins = matchError(full, {
|
|
158
|
+
FlightFull: (data) => data.seatsLeft,
|
|
159
|
+
failed: () => -2,
|
|
160
|
+
_: () => -3,
|
|
161
|
+
});
|
|
162
|
+
expect(codeWins).toBe(3);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("invalid kind exposes ValidationError fields", () => {
|
|
166
|
+
const err = {
|
|
167
|
+
code: "ValidationError",
|
|
168
|
+
data: { issues: [{ message: "Required", path: ["email"] }] },
|
|
169
|
+
} as const;
|
|
170
|
+
const fields = matchError(err, {
|
|
171
|
+
invalid: (e) => e.fields,
|
|
172
|
+
_: () => undefined,
|
|
173
|
+
});
|
|
174
|
+
expect(fields).toEqual({ email: "Required" });
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("match", () => {
|
|
179
|
+
type Result =
|
|
180
|
+
| { readonly data: { readonly confirmationCode: string }; readonly error: null }
|
|
181
|
+
| {
|
|
182
|
+
readonly data: null;
|
|
183
|
+
readonly error: ClientError<{
|
|
184
|
+
FlightFull: { seatsLeft: number };
|
|
185
|
+
NotFound: Record<string, never>;
|
|
186
|
+
}>;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
test("ok arm narrows success data", () => {
|
|
190
|
+
const result: Result = { data: { confirmationCode: "SK-4812" }, error: null };
|
|
191
|
+
const code = match(result, {
|
|
192
|
+
ok: (data) => {
|
|
193
|
+
type _D = Assert<Eq<typeof data, { readonly confirmationCode: string }>>;
|
|
194
|
+
const keep: _D = true;
|
|
195
|
+
expect(keep).toBe(true);
|
|
196
|
+
return data.confirmationCode;
|
|
197
|
+
},
|
|
198
|
+
_: () => "",
|
|
199
|
+
});
|
|
200
|
+
expect(code).toBe("SK-4812");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("named code, kind, and _ on one call", () => {
|
|
204
|
+
const full: Result = {
|
|
205
|
+
data: null,
|
|
206
|
+
error: { code: "FlightFull", data: { seatsLeft: 1 } },
|
|
207
|
+
};
|
|
208
|
+
expect(
|
|
209
|
+
match<Result, string>(full, {
|
|
210
|
+
ok: () => "ok",
|
|
211
|
+
FlightFull: (data) => `wait:${data.seatsLeft}`,
|
|
212
|
+
auth: () => "signin",
|
|
213
|
+
_: (e) => e.message,
|
|
214
|
+
}),
|
|
215
|
+
).toBe("wait:1");
|
|
216
|
+
|
|
217
|
+
const missing: Result = { data: null, error: { code: "NotFound", data: {} } };
|
|
218
|
+
expect(
|
|
219
|
+
match<Result, string>(missing, {
|
|
220
|
+
ok: () => "ok",
|
|
221
|
+
FlightFull: (data) => `wait:${data.seatsLeft}`,
|
|
222
|
+
missing: (e) => `gone:${e.code}`,
|
|
223
|
+
_: (e) => e.message,
|
|
224
|
+
}),
|
|
225
|
+
).toBe("gone:NotFound");
|
|
226
|
+
|
|
227
|
+
const transport = {
|
|
228
|
+
data: null,
|
|
229
|
+
error: { code: "TransportError" as const, data: { message: "offline" } },
|
|
230
|
+
};
|
|
231
|
+
expect(
|
|
232
|
+
match(transport, {
|
|
233
|
+
ok: () => "ok",
|
|
234
|
+
unavailable: (e) => {
|
|
235
|
+
expect(e.retryable).toBe(true);
|
|
236
|
+
return e.message;
|
|
237
|
+
},
|
|
238
|
+
_: () => "dump",
|
|
239
|
+
}),
|
|
240
|
+
).toBe("offline");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("BuiltinErrorMap ValidationError issues", () => {
|
|
245
|
+
test("issues are { message, path }", () => {
|
|
246
|
+
type Issue = BuiltinErrorMap["ValidationError"]["issues"][number];
|
|
247
|
+
type _Msg = Assert<Eq<Issue["message"], string>>;
|
|
248
|
+
type _Path = Assert<Eq<Issue["path"], ReadonlyArray<string | number>>>;
|
|
249
|
+
const ok: [_Msg, _Path] = [true, true];
|
|
250
|
+
expect(ok).toEqual([true, true]);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collapse unknown flow / transport errors into a small UX kind.
|
|
3
|
+
*
|
|
4
|
+
* Import from `okengine/client/explain` (kept off the 5 kB `okengine/client`
|
|
5
|
+
* edge graph). Prefer {@link match} on the envelope. Name a code when this
|
|
6
|
+
* screen has special recovery (`FlightFull` → waitlist). Kind arms handle
|
|
7
|
+
* chrome (`auth` → sign-in). {@link matchError} / {@link match} require `_`
|
|
8
|
+
* so that dump bucket is never optional.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** UX family for toasts, sign-in, forms — not an HTTP status. */
|
|
12
|
+
export type ErrorKind =
|
|
13
|
+
| "auth"
|
|
14
|
+
| "permission"
|
|
15
|
+
| "missing"
|
|
16
|
+
| "conflict"
|
|
17
|
+
| "invalid"
|
|
18
|
+
| "limited"
|
|
19
|
+
| "unavailable"
|
|
20
|
+
| "failed";
|
|
21
|
+
|
|
22
|
+
const ERROR_KINDS: readonly ErrorKind[] = [
|
|
23
|
+
"auth",
|
|
24
|
+
"permission",
|
|
25
|
+
"missing",
|
|
26
|
+
"conflict",
|
|
27
|
+
"invalid",
|
|
28
|
+
"limited",
|
|
29
|
+
"unavailable",
|
|
30
|
+
"failed",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const KIND_SET: ReadonlySet<string> = new Set(ERROR_KINDS);
|
|
34
|
+
|
|
35
|
+
/** Display-ready failure from {@link explain}. */
|
|
36
|
+
export interface ExplainedError {
|
|
37
|
+
readonly kind: ErrorKind;
|
|
38
|
+
readonly code: string;
|
|
39
|
+
/** Envelope message, transport message, or `code` — never empty. */
|
|
40
|
+
readonly message: string;
|
|
41
|
+
readonly retryable: boolean;
|
|
42
|
+
readonly retryAfterMs?: number;
|
|
43
|
+
/** `ValidationError` path → message (`.`-joined; empty path is `_`). */
|
|
44
|
+
readonly fields?: Record<string, string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Input accepted by {@link explain} (flow error or transport). */
|
|
48
|
+
export type ExplainableError = {
|
|
49
|
+
readonly code: string;
|
|
50
|
+
readonly data?: unknown;
|
|
51
|
+
readonly message?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Envelope `{ data, error }` accepted by {@link match}.
|
|
56
|
+
*
|
|
57
|
+
* @typeParam O - Success data
|
|
58
|
+
* @typeParam Err - Failure (flow error or transport)
|
|
59
|
+
*/
|
|
60
|
+
export type MatchableResult<O, Err extends ExplainableError = ExplainableError> =
|
|
61
|
+
| { readonly data: O; readonly error: null }
|
|
62
|
+
| { readonly data: null; readonly error: Err };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Cases for {@link matchError}: named codes, optional UX kinds, required `_`.
|
|
66
|
+
*
|
|
67
|
+
* Code arms receive typed `data`. Kind arms and `_` receive {@link ExplainedError}.
|
|
68
|
+
* Dispatch order: named code → kind → `_`. Kind names (`auth`, `failed`, …) are
|
|
69
|
+
* reserved — they are never treated as domain codes.
|
|
70
|
+
*
|
|
71
|
+
* @typeParam Err - Envelope error
|
|
72
|
+
* @typeParam R - Handler return
|
|
73
|
+
*/
|
|
74
|
+
export type MatchErrorCases<Err extends ExplainableError, R> = {
|
|
75
|
+
[K in Exclude<Err["code"], ErrorKind | "_">]?: (
|
|
76
|
+
data: Extract<Err, { readonly code: K }> extends { readonly data: infer D } ? D : unknown,
|
|
77
|
+
) => R;
|
|
78
|
+
} & {
|
|
79
|
+
[K in ErrorKind]?: (explained: ExplainedError & { readonly kind: K }) => R;
|
|
80
|
+
} & {
|
|
81
|
+
/** Required dump bucket — unknown / unhandled codes and kinds. */
|
|
82
|
+
readonly _: (explained: ExplainedError) => R;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Cases for {@link match}: required `ok` plus {@link MatchErrorCases}.
|
|
87
|
+
*
|
|
88
|
+
* @typeParam O - Success data
|
|
89
|
+
* @typeParam Err - Envelope error
|
|
90
|
+
* @typeParam R - Handler return
|
|
91
|
+
*/
|
|
92
|
+
export type MatchResultCases<O, Err extends ExplainableError, R> = {
|
|
93
|
+
readonly ok: (data: O) => R;
|
|
94
|
+
} & MatchErrorCases<Err, R>;
|
|
95
|
+
|
|
96
|
+
const KIND: Record<string, ErrorKind> = {
|
|
97
|
+
Unauthorized: "auth",
|
|
98
|
+
AuthFailed: "auth",
|
|
99
|
+
Forbidden: "permission",
|
|
100
|
+
NotFound: "missing",
|
|
101
|
+
Conflict: "conflict",
|
|
102
|
+
ForeignKey: "conflict",
|
|
103
|
+
ValidationError: "invalid",
|
|
104
|
+
InvalidQuery: "invalid",
|
|
105
|
+
UnsupportedMediaType: "invalid",
|
|
106
|
+
RateLimited: "limited",
|
|
107
|
+
AuthRateLimited: "limited",
|
|
108
|
+
ServiceUnavailable: "unavailable",
|
|
109
|
+
InternalError: "failed",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const INVALID_DB = new Set(["not_null", "check", "invalid", "too_long", "out_of_range"]);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Collapse a flow or transport error into chrome (`kind`, `message`, `fields`).
|
|
116
|
+
*
|
|
117
|
+
* Domain codes (`OutOfStock`, `FlightFull`) are `failed` — product UX stays
|
|
118
|
+
* on `error.code`. Does not throw.
|
|
119
|
+
*
|
|
120
|
+
* @param error - Envelope `error` from a {@link MatchableResult}
|
|
121
|
+
*/
|
|
122
|
+
export function explain(error: ExplainableError): ExplainedError {
|
|
123
|
+
const kind = kindOf(error);
|
|
124
|
+
const retryable = kind === "unavailable" || kind === "limited";
|
|
125
|
+
const retryAfterMs = retryable ? retryDelay(error.data) : undefined;
|
|
126
|
+
const fields = error.code === "ValidationError" ? fieldsOf(error.data) : undefined;
|
|
127
|
+
return {
|
|
128
|
+
kind,
|
|
129
|
+
code: error.code,
|
|
130
|
+
message: messageOf(error),
|
|
131
|
+
retryable,
|
|
132
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
133
|
+
...(fields !== undefined ? { fields } : {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Run a named code handler, a UX {@link ErrorKind} arm, or `_`.
|
|
139
|
+
*
|
|
140
|
+
* `_` is required so store auto-map, transport, and domain codes always have
|
|
141
|
+
* a path. Named arms narrow `data` (e.g. `FlightFull` → `seatsLeft`). Kind
|
|
142
|
+
* arms receive {@link ExplainedError} (`auth` → sign-in, `invalid` → fields).
|
|
143
|
+
*
|
|
144
|
+
* @param error - Envelope error
|
|
145
|
+
* @param cases - Optional per-code / per-kind handlers plus required `_`
|
|
146
|
+
*/
|
|
147
|
+
export function matchError<Err extends ExplainableError, R>(
|
|
148
|
+
error: Err,
|
|
149
|
+
cases: MatchErrorCases<Err, R>,
|
|
150
|
+
): R {
|
|
151
|
+
const code = error.code;
|
|
152
|
+
const codeFn = lookupCode<R>(cases, code);
|
|
153
|
+
if (codeFn !== undefined) return codeFn(error.data);
|
|
154
|
+
const explained = explain(error);
|
|
155
|
+
const kindFn = lookupKind<R>(cases, explained.kind);
|
|
156
|
+
if (kindFn !== undefined) return kindFn(explained);
|
|
157
|
+
return cases._(explained);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Run `ok` on success, otherwise the same arms as {@link matchError}.
|
|
162
|
+
*
|
|
163
|
+
* One import, one call — no `isOk` then `matchError` split.
|
|
164
|
+
*
|
|
165
|
+
* @param result - Envelope `{ data, error }`
|
|
166
|
+
* @param cases - Required `ok`, optional per-code / per-kind, required `_`
|
|
167
|
+
*/
|
|
168
|
+
export function match<T extends MatchableResult<unknown, ExplainableError>, R>(
|
|
169
|
+
result: T,
|
|
170
|
+
cases: MatchResultCases<MatchData<T>, MatchErr<T>, R>,
|
|
171
|
+
): R {
|
|
172
|
+
if (result.error === null) return cases.ok(result.data as MatchData<T>);
|
|
173
|
+
return matchError(result.error as MatchErr<T>, cases);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Success `data` from a {@link MatchableResult} union (null-error branch only). */
|
|
177
|
+
type MatchData<T> = T extends { readonly error: null; readonly data: infer O } ? O : never;
|
|
178
|
+
|
|
179
|
+
/** Failure from a {@link MatchableResult} union (`error` excluding `null`). */
|
|
180
|
+
type MatchErr<T> = T extends { readonly error: infer E }
|
|
181
|
+
? E extends null
|
|
182
|
+
? never
|
|
183
|
+
: E extends ExplainableError
|
|
184
|
+
? E
|
|
185
|
+
: never
|
|
186
|
+
: never;
|
|
187
|
+
|
|
188
|
+
function lookupCode<R>(cases: object, code: string): ((data: unknown) => R) | undefined {
|
|
189
|
+
if (code === "_" || code === "ok" || KIND_SET.has(code)) return undefined;
|
|
190
|
+
const handler = (cases as Record<string, unknown>)[code];
|
|
191
|
+
return typeof handler === "function" ? (handler as (data: unknown) => R) : undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function lookupKind<R>(
|
|
195
|
+
cases: object,
|
|
196
|
+
kind: ErrorKind,
|
|
197
|
+
): ((explained: ExplainedError) => R) | undefined {
|
|
198
|
+
const handler = (cases as Record<string, unknown>)[kind];
|
|
199
|
+
return typeof handler === "function" ? (handler as (explained: ExplainedError) => R) : undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function kindOf(error: ExplainableError): ErrorKind {
|
|
203
|
+
if (error.code === "TransportError") {
|
|
204
|
+
return transportStatus(error.data) === 404 ? "missing" : "unavailable";
|
|
205
|
+
}
|
|
206
|
+
if (error.code === "DatabaseError") {
|
|
207
|
+
const reason = dataReason(error.data);
|
|
208
|
+
if (reason === "retryable") return "unavailable";
|
|
209
|
+
if (reason !== undefined && INVALID_DB.has(reason)) return "invalid";
|
|
210
|
+
return "failed";
|
|
211
|
+
}
|
|
212
|
+
return KIND[error.code] ?? "failed";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function messageOf(error: ExplainableError): string {
|
|
216
|
+
if (typeof error.message === "string" && error.message.length > 0) return error.message;
|
|
217
|
+
const data = error.data;
|
|
218
|
+
if (
|
|
219
|
+
error.code === "TransportError" &&
|
|
220
|
+
data !== null &&
|
|
221
|
+
typeof data === "object" &&
|
|
222
|
+
"message" in data &&
|
|
223
|
+
typeof (data as { message: unknown }).message === "string" &&
|
|
224
|
+
(data as { message: string }).message.length > 0
|
|
225
|
+
) {
|
|
226
|
+
return (data as { message: string }).message;
|
|
227
|
+
}
|
|
228
|
+
return error.code;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function retryDelay(data: unknown): number | undefined {
|
|
232
|
+
if (data === null || typeof data !== "object") return undefined;
|
|
233
|
+
const rec = data as { retryAfterMs?: unknown; retryAfter?: unknown };
|
|
234
|
+
if (typeof rec.retryAfterMs === "number") return rec.retryAfterMs;
|
|
235
|
+
if (typeof rec.retryAfter === "number") return rec.retryAfter * 1000;
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function fieldsOf(data: unknown): Record<string, string> | undefined {
|
|
240
|
+
if (data === null || typeof data !== "object" || !("issues" in data)) return undefined;
|
|
241
|
+
const issues = (data as { issues: unknown }).issues;
|
|
242
|
+
if (!Array.isArray(issues) || issues.length === 0) return undefined;
|
|
243
|
+
const fields: Record<string, string> = {};
|
|
244
|
+
for (const issue of issues) {
|
|
245
|
+
if (issue === null || typeof issue !== "object") continue;
|
|
246
|
+
const rec = issue as { message?: unknown; path?: unknown };
|
|
247
|
+
if (typeof rec.message !== "string") continue;
|
|
248
|
+
fields[pathKey(rec.path)] = rec.message;
|
|
249
|
+
}
|
|
250
|
+
return Object.keys(fields).length > 0 ? fields : undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function pathKey(path: unknown): string {
|
|
254
|
+
if (!Array.isArray(path) || path.length === 0) return "_";
|
|
255
|
+
const parts: string[] = [];
|
|
256
|
+
for (const p of path) {
|
|
257
|
+
if (typeof p === "string" || typeof p === "number") parts.push(String(p));
|
|
258
|
+
}
|
|
259
|
+
return parts.length > 0 ? parts.join(".") : "_";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function transportStatus(data: unknown): number | undefined {
|
|
263
|
+
if (data === null || typeof data !== "object" || !("status" in data)) return undefined;
|
|
264
|
+
const status = (data as { status: unknown }).status;
|
|
265
|
+
return typeof status === "number" ? status : undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function dataReason(data: unknown): string | undefined {
|
|
269
|
+
if (data === null || typeof data !== "object" || !("reason" in data)) return undefined;
|
|
270
|
+
const reason = (data as { reason: unknown }).reason;
|
|
271
|
+
return typeof reason === "string" ? reason : undefined;
|
|
272
|
+
}
|
package/src/client/live.test.ts
CHANGED
|
@@ -413,6 +413,50 @@ describe("createClient — live", () => {
|
|
|
413
413
|
});
|
|
414
414
|
});
|
|
415
415
|
|
|
416
|
+
describe("sseError — envelope message fallback", () => {
|
|
417
|
+
test("prefers top-level error.message", async () => {
|
|
418
|
+
const { sseError } = await import("./sse.ts");
|
|
419
|
+
const err = sseError(
|
|
420
|
+
401,
|
|
421
|
+
JSON.stringify({
|
|
422
|
+
data: null,
|
|
423
|
+
error: { code: "Unauthorized", message: "Auth required", data: {} },
|
|
424
|
+
}),
|
|
425
|
+
);
|
|
426
|
+
expect(err.message).toBe("Auth required");
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("falls back to error.data.message", async () => {
|
|
430
|
+
const { sseError } = await import("./sse.ts");
|
|
431
|
+
const err = sseError(
|
|
432
|
+
401,
|
|
433
|
+
JSON.stringify({
|
|
434
|
+
data: null,
|
|
435
|
+
error: { code: "Unauthorized", data: { message: "Auth required" } },
|
|
436
|
+
}),
|
|
437
|
+
);
|
|
438
|
+
expect(err.message).toBe("Auth required");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("falls back to error.code", async () => {
|
|
442
|
+
const { sseError } = await import("./sse.ts");
|
|
443
|
+
const err = sseError(
|
|
444
|
+
401,
|
|
445
|
+
JSON.stringify({
|
|
446
|
+
data: null,
|
|
447
|
+
error: { code: "Unauthorized" },
|
|
448
|
+
}),
|
|
449
|
+
);
|
|
450
|
+
expect(err.message).toBe("Unauthorized");
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("falls back to HTTP status for plain body", async () => {
|
|
454
|
+
const { sseError } = await import("./sse.ts");
|
|
455
|
+
const err = sseError(401, "nope");
|
|
456
|
+
expect(err.message).toBe("nope");
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
416
460
|
async function waitFor(pred: () => boolean, ms = 500): Promise<void> {
|
|
417
461
|
const start = Date.now();
|
|
418
462
|
while (!pred()) {
|
|
@@ -76,6 +76,16 @@ describe("Notes — typeof app carries contracts", () => {
|
|
|
76
76
|
expect(typeof api.notes.create).toBe("function");
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
test("create without errors: bag still types built-in NotFound", () => {
|
|
80
|
+
const app = oke({ name: "notes" }).adopt({ notes: { create } });
|
|
81
|
+
const api = createClient<typeof app>("http://localhost:6530");
|
|
82
|
+
type CreateError = NonNullable<Awaited<ReturnType<typeof api.notes.create>>["error"]>;
|
|
83
|
+
type NotFoundData = Extract<CreateError, { code: "NotFound" }>["data"];
|
|
84
|
+
type _Id = Assert<Eq<NotFoundData["id"], string | undefined>>;
|
|
85
|
+
const ok: [_Id] = [true];
|
|
86
|
+
expect(ok).toEqual([true]);
|
|
87
|
+
});
|
|
88
|
+
|
|
79
89
|
test("Notes client block: createClient<App> narrows data and NotFound", async () => {
|
|
80
90
|
const app = oke({ name: "notes" }).adopt({ notes });
|
|
81
91
|
type App = typeof app;
|
package/src/client/sse.ts
CHANGED
|
@@ -124,8 +124,12 @@ export function sseError(status: number, body: string): Error {
|
|
|
124
124
|
try {
|
|
125
125
|
const json: unknown = JSON.parse(body);
|
|
126
126
|
if (json !== null && typeof json === "object" && "error" in json) {
|
|
127
|
-
const err = (
|
|
128
|
-
|
|
127
|
+
const err = (
|
|
128
|
+
json as {
|
|
129
|
+
error?: { code?: string; message?: string; data?: { message?: string } };
|
|
130
|
+
}
|
|
131
|
+
).error;
|
|
132
|
+
message = err?.message ?? err?.data?.message ?? err?.code ?? message;
|
|
129
133
|
}
|
|
130
134
|
} catch {
|
|
131
135
|
message = body.slice(0, 200);
|