okengine 0.20.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/package.json +3 -7
  2. package/site/content/docs/client/calling.mdx +117 -29
  3. package/site/content/docs/client/index.mdx +3 -3
  4. package/site/content/docs/elements/flow/http.mdx +18 -15
  5. package/site/content/docs/elements/flow/index.mdx +26 -18
  6. package/site/content/docs/elements/gate/tenancy.mdx +1 -1
  7. package/site/content/docs/elements/store/files.mdx +11 -5
  8. package/site/content/docs/elements/store/index.mdx +2 -3
  9. package/site/content/docs/elements/store/kv.mdx +8 -2
  10. package/site/content/docs/elements/store/sql.mdx +9 -6
  11. package/site/content/docs/elements/vault/index.mdx +11 -8
  12. package/site/content/docs/elements/vault/secrets.mdx +6 -3
  13. package/site/content/docs/index.mdx +1 -1
  14. package/site/content/docs/recipes/rustfs.mdx +1 -1
  15. package/site/content/docs/reference/configuration.mdx +1 -1
  16. package/site/content/docs/reference/errors.mdx +199 -25
  17. package/site/content/docs/reference/fx.mdx +6 -1
  18. package/site/content/docs/understand/the-architecture.mdx +2 -2
  19. package/site/content/docs/understand/try-it.mdx +758 -25
  20. package/src/cli/dev-app-runner.ts +2 -1
  21. package/src/cli/dev.test.ts +105 -2
  22. package/src/cli/dev.ts +37 -2
  23. package/src/cli/start.ts +2 -1
  24. package/src/client/create.ts +14 -27
  25. package/src/client/explain.test.ts +252 -0
  26. package/src/client/explain.ts +272 -0
  27. package/src/client/live.test.ts +44 -0
  28. package/src/client/live.ts +44 -101
  29. package/src/client/notes-contract.test.ts +10 -0
  30. package/src/client/sse.ts +26 -68
  31. package/src/client/stream.ts +25 -67
  32. package/src/client/transport.test.ts +67 -0
  33. package/src/client/transport.ts +51 -112
  34. package/src/client/types.ts +17 -6
  35. package/src/client/wire.ts +119 -0
  36. package/src/client-react/live-resource.ts +6 -2
  37. package/src/compiler/aot.ts +3 -32
  38. package/src/compiler/dynamic.ts +13 -11
  39. package/src/compiler/interpret.ts +45 -0
  40. package/src/compiler/response.ts +17 -27
  41. package/src/console/server/invoke-user-flow.test.ts +8 -2
  42. package/src/console/server/invoke-user-flow.ts +12 -18
  43. package/src/console/server/security.gate.test.ts +1 -1
  44. package/src/console/ui-next/dist/assets/{access-page-DFLu0wTA.js → access-page-Bgt9bq2r.js} +1 -1
  45. package/src/console/ui-next/dist/assets/{agent-disclosure-DGscxaF5.js → agent-disclosure-B43CXZZR.js} +1 -1
  46. package/src/console/ui-next/dist/assets/{cache-glyph-BGmRZk7d.js → cache-glyph-92uM5MO7.js} +1 -1
  47. package/src/console/ui-next/dist/assets/{call-pii-button--feUYxvG.js → call-pii-button-DtGVPtcs.js} +1 -1
  48. package/src/console/ui-next/dist/assets/{collapsible-JWvpaiGY.js → collapsible-DN7l6zmC.js} +1 -1
  49. package/src/console/ui-next/dist/assets/{duration-tone-D9yCJG4n.js → duration-tone-BmIR9FV8.js} +1 -1
  50. package/src/console/ui-next/dist/assets/{flows-page-Bs6MD9GB.js → flows-page-BMHs-IzK.js} +1 -1
  51. package/src/console/ui-next/dist/assets/{highlighted-json-xH8MrEnv.js → highlighted-json-BlAEVgNW.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{http-method-C4vB6ZIw.js → http-method-BDf7OAHv.js} +1 -1
  53. package/src/console/ui-next/dist/assets/{index-yTCY4AcS.js → index-BKpaes3n.js} +3 -3
  54. package/src/console/ui-next/dist/assets/{observability-page-BxJ3R6dU.js → observability-page-WnVLI-0j.js} +1 -1
  55. package/src/console/ui-next/dist/assets/{replica-lag-QRKB_IE8.js → replica-lag-B3GLNVfF.js} +1 -1
  56. package/src/console/ui-next/dist/assets/{request-meta-DqZ-fMu5.js → request-meta-DMbnAe3f.js} +1 -1
  57. package/src/console/ui-next/dist/assets/{store-page-Dixb6L7a.js → store-page-KvFDingJ.js} +1 -1
  58. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CazhjtiU.js → trace-detail-sheet-Htk8Cm9t.js} +1 -1
  59. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DlnqYKfr.js → tree-expand-toggle-CFMPWX4f.js} +1 -1
  60. package/src/console/ui-next/dist/assets/{units-page-BXTLjU2-.js → units-page-C4NdNuxP.js} +1 -1
  61. package/src/console/ui-next/dist/assets/{vault-page-39KR__bc.js → vault-page-CBYl0LW_.js} +1 -1
  62. package/src/console/ui-next/dist/index.html +1 -1
  63. package/src/docker/docker.test.ts +3 -3
  64. package/src/docker/images-config.test.ts +4 -4
  65. package/src/docker/stack-id.test.ts +1 -1
  66. package/src/elements/store/files-errors.test.ts +149 -0
  67. package/src/elements/store/files-errors.ts +189 -0
  68. package/src/elements/store/kv-errors.test.ts +98 -0
  69. package/src/elements/store/kv-errors.ts +139 -0
  70. package/src/elements/store/resource.ts +11 -7
  71. package/src/elements/store/runtime.ts +18 -13
  72. package/src/elements/store/sql-errors.test.ts +197 -0
  73. package/src/elements/store/sql-errors.ts +294 -0
  74. package/src/elements/store/sql-session.test.ts +52 -0
  75. package/src/elements/store/sql-session.ts +26 -4
  76. package/src/elements/store/store-errors.ts +47 -0
  77. package/src/http.ts +9 -1
  78. package/src/i18n/catalogs/ar.ts +18 -0
  79. package/src/i18n/catalogs/en.ts +18 -0
  80. package/src/index.ts +9 -1
  81. package/src/kernel/app.ts +23 -4
  82. package/src/kernel/builtin-errors.test.ts +117 -0
  83. package/src/kernel/builtin-errors.ts +129 -0
  84. package/src/kernel/call.test.ts +182 -0
  85. package/src/kernel/client-descriptor.test.ts +78 -0
  86. package/src/kernel/client-descriptor.ts +23 -0
  87. package/src/kernel/errors-text.ts +99 -0
  88. package/src/kernel/errors-vault.ts +16 -0
  89. package/src/kernel/errors.registry.test.ts +7 -0
  90. package/src/kernel/errors.ts +184 -159
  91. package/src/kernel/fail-helpers.ts +34 -0
  92. package/src/kernel/fx-sql-handle.ts +305 -0
  93. package/src/kernel/fx.test.ts +8 -0
  94. package/src/kernel/fx.ts +49 -335
  95. package/src/kernel/index.ts +12 -1
  96. package/src/kernel/json-result.ts +59 -0
  97. package/src/kernel/project-out.ts +6 -1
  98. package/src/okid-extended.ts +175 -0
  99. package/src/okid-shared.ts +103 -0
  100. package/src/okid.ts +30 -213
  101. package/src/release/build-lib.ts +9 -0
  102. package/src/runtime/dev-request-log.ts +29 -11
  103. package/src/term.test.ts +76 -0
  104. package/src/term.ts +166 -3
@@ -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
+ }
@@ -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()) {
@@ -4,7 +4,16 @@
4
4
 
5
5
  import type { ClientFetch, ClientOptions, ClientRouteMap, FlowContract } from "./types.ts";
6
6
  import type { LiveHandlers, LiveUnsubscribe } from "./types.ts";
7
- import { readSse, sseError } from "./sse.ts";
7
+ import { iterateSseFrames, sseError } from "./sse.ts";
8
+ import {
9
+ applyAuthHeader,
10
+ applyHeaderBag,
11
+ interpolatePath,
12
+ methodAndPath,
13
+ resolveHeaders,
14
+ toQuery,
15
+ walkContracts,
16
+ } from "./wire.ts";
8
17
 
9
18
  /** One HTTP exposure of a live signal. */
10
19
  export interface LiveExposure {
@@ -31,24 +40,16 @@ export function flattenLiveRoutes($routes: ClientRouteMap | undefined): {
31
40
  } {
32
41
  const bySignal: Record<string, LiveExposure[]> = {};
33
42
  const byFlow: Record<string, LiveExposure> = {};
34
- if (!$routes) return { bySignal, byFlow };
35
- for (const [unit, flows] of Object.entries($routes)) {
36
- if (!flows || typeof flows !== "object") continue;
37
- for (const [flow, contract] of Object.entries(flows)) {
38
- if (!contract || typeof contract !== "object") continue;
39
- const live = liveName(contract);
40
- const method = "method" in contract ? contract.method : undefined;
41
- const path = "path" in contract ? contract.path : undefined;
42
- if (typeof live !== "string" || typeof method !== "string" || typeof path !== "string") {
43
- continue;
44
- }
45
- const matchKey = matchKeyOf(contract);
46
- const id = `${unit}.${flow}`;
47
- const exposure: LiveExposure = { flow: id, method, path, matchKey };
48
- (bySignal[live] ??= []).push(exposure);
49
- byFlow[id] = exposure;
50
- }
51
- }
43
+ walkContracts($routes, (unit, flow, contract) => {
44
+ const live = liveName(contract);
45
+ const route = methodAndPath(contract);
46
+ if (typeof live !== "string" || !route) return;
47
+ const matchKey = matchKeyOf(contract);
48
+ const id = `${unit}.${flow}`;
49
+ const exposure: LiveExposure = { flow: id, method: route.method, path: route.path, matchKey };
50
+ (bySignal[live] ??= []).push(exposure);
51
+ byFlow[id] = exposure;
52
+ });
52
53
  return { bySignal, byFlow };
53
54
  }
54
55
 
@@ -87,11 +88,7 @@ export function pickLiveExposure(
87
88
  ): LiveExposure {
88
89
  if (via) {
89
90
  const hit = exposures.find((e) => e.flow === via);
90
- if (!hit) {
91
- throw new Error(
92
- `Unknown live via "${via}". Known: ${exposures.map((e) => e.flow).join(", ")}`,
93
- );
94
- }
91
+ if (!hit) throw new Error(`Unknown live via "${via}"`);
95
92
  return hit;
96
93
  }
97
94
  const keys =
@@ -100,19 +97,12 @@ export function pickLiveExposure(
100
97
  : [];
101
98
  const candidates = exposures.filter((e) => e.matchKey.every((k) => keys.includes(k)));
102
99
  if (candidates.length === 0) {
103
- throw new Error(
104
- `No live exposure matches input keys [${keys.join(", ")}]. Flows: ${exposures.map((e) => e.flow).join(", ")}`,
105
- );
106
- }
107
- let max = -1;
108
- for (const e of candidates) {
109
- if (e.matchKey.length > max) max = e.matchKey.length;
100
+ throw new Error(`No live exposure matches input keys [${keys.join(", ")}]`);
110
101
  }
102
+ const max = Math.max(...candidates.map((e) => e.matchKey.length));
111
103
  const top = candidates.filter((e) => e.matchKey.length === max);
112
104
  if (top.length !== 1) {
113
- throw new Error(
114
- `Multiple live exposures match: ${top.map((e) => e.flow).join(", ")}. Pass via: "unit.flow".`,
115
- );
105
+ throw new Error(`Multiple live exposures match. Pass via: "unit.flow".`);
116
106
  }
117
107
  return top[0]!;
118
108
  }
@@ -173,6 +163,11 @@ async function pump(
173
163
  let delayMs = LIVE_RESUBSCRIBE_INITIAL_MS;
174
164
  let attempt = 0;
175
165
  let lastSeenId: string | undefined;
166
+ /** Report `err`. Returns whether the loop should resubscribe. */
167
+ const note = (err: unknown): boolean => {
168
+ handlers.onError?.(err);
169
+ return auto;
170
+ };
176
171
  for (;;) {
177
172
  if (signal.aborted) return;
178
173
  if (attempt > 0 && auto) {
@@ -182,45 +177,26 @@ async function pump(
182
177
  }
183
178
  attempt += 1;
184
179
  try {
185
- const res = await openSse(base, exposure, input, opts, signal, lastSeenId);
180
+ let res = await openSse(base, exposure, input, opts, signal, lastSeenId);
186
181
  if (signal.aborted) return;
187
182
  if (res.status === 401 && opts.auth?.refresh) {
188
183
  await opts.auth.refresh();
189
- const retry = await openSse(base, exposure, input, opts, signal, lastSeenId);
190
- if (signal.aborted) return;
191
- lastSeenId = await consumeSse(retry, handlers, signal, lastSeenId);
184
+ res = await openSse(base, exposure, input, opts, signal, lastSeenId);
192
185
  if (signal.aborted) return;
193
- const closed = new Error("live connection closed");
194
- if (auto) {
195
- handlers.onError?.(closed);
196
- continue;
197
- }
198
- handlers.onError?.(closed);
199
- return;
200
186
  }
201
187
  if (res.status === 410) {
202
- const err = await liveResumeGapError(res);
203
188
  lastSeenId = undefined;
204
- handlers.onError?.(err);
205
- if (!auto) return;
206
- continue;
189
+ const text = await res.text().catch(() => "");
190
+ if (note(sseError(410, text))) continue;
191
+ return;
207
192
  }
208
193
  lastSeenId = await consumeSse(res, handlers, signal, lastSeenId);
209
194
  if (signal.aborted) return;
210
- const closed = new Error("live connection closed");
211
- if (auto) {
212
- handlers.onError?.(closed);
213
- continue;
214
- }
215
- handlers.onError?.(closed);
195
+ if (note(new Error("live connection closed"))) continue;
216
196
  return;
217
197
  } catch (err) {
218
198
  if (signal.aborted) return;
219
- if (auto) {
220
- handlers.onError?.(err);
221
- continue;
222
- }
223
- handlers.onError?.(err);
199
+ if (note(err)) continue;
224
200
  return;
225
201
  }
226
202
  }
@@ -233,23 +209,13 @@ async function consumeSse(
233
209
  lastSeenId: string | undefined,
234
210
  ): Promise<string | undefined> {
235
211
  let cursor = lastSeenId;
236
- await readSse(
237
- res,
238
- (event, id) => {
239
- handlers.onEvent(event);
240
- if (id !== undefined && id.length > 0) cursor = id;
241
- },
242
- signal,
243
- handlers.onOpen,
244
- );
212
+ for await (const frame of iterateSseFrames(res, signal, handlers.onOpen)) {
213
+ handlers.onEvent(frame.event);
214
+ if (frame.id !== undefined && frame.id.length > 0) cursor = frame.id;
215
+ }
245
216
  return cursor;
246
217
  }
247
218
 
248
- async function liveResumeGapError(res: Response): Promise<Error> {
249
- const text = await res.text().catch(() => "");
250
- return sseError(410, text);
251
- }
252
-
253
219
  /**
254
220
  * Abortable delay. Resolves immediately when `signal` is already aborted.
255
221
  *
@@ -280,19 +246,8 @@ async function openSse(
280
246
  ): Promise<Response> {
281
247
  const { url, method } = restGet(base, exposure.path, input);
282
248
  const headers = new Headers({ accept: "text/event-stream" });
283
- const extra = typeof opts.headers === "function" ? await opts.headers() : opts.headers;
284
- if (Array.isArray(extra)) {
285
- for (const [k, v] of extra) headers.set(k, v);
286
- } else if (extra) {
287
- for (const [k, v] of Object.entries(extra)) headers.set(k, v);
288
- }
289
- const token =
290
- opts.auth && "getToken" in opts.auth && typeof opts.auth.getToken === "function"
291
- ? await opts.auth.getToken()
292
- : undefined;
293
- if (token && !headers.has("authorization")) {
294
- headers.set("authorization", `Bearer ${token}`);
295
- }
249
+ applyHeaderBag(headers, await resolveHeaders(opts));
250
+ await applyAuthHeader(headers, opts);
296
251
  if (lastSeenId) headers.set("last-event-id", lastSeenId);
297
252
  const fetchFn: ClientFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
298
253
  return fetchFn(url, {
@@ -304,18 +259,6 @@ async function openSse(
304
259
  }
305
260
 
306
261
  function restGet(base: string, path: string, input: unknown): { url: string; method: string } {
307
- const params =
308
- input !== null && typeof input === "object" ? (input as Record<string, unknown>) : {};
309
- let pathOut = path;
310
- const query: string[] = [];
311
- for (const [k, v] of Object.entries(params)) {
312
- const token = `:${k}`;
313
- if (pathOut.includes(token)) {
314
- pathOut = pathOut.replaceAll(token, encodeURIComponent(String(v)));
315
- } else if (v !== undefined) {
316
- query.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
317
- }
318
- }
319
- const qs = query.length ? `?${query.join("&")}` : "";
320
- return { url: `${base}${pathOut}${qs}`, method: "GET" };
262
+ const { path: pathOut, rest } = interpolatePath(path, input);
263
+ return { url: `${base}${pathOut}${toQuery(rest)}`, method: "GET" };
321
264
  }
@@ -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;