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.
- package/package.json +3 -7
- 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/dev.test.ts +105 -2
- package/src/cli/dev.ts +37 -2
- package/src/cli/start.ts +2 -1
- package/src/client/create.ts +14 -27
- 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/live.ts +44 -101
- package/src/client/notes-contract.test.ts +10 -0
- package/src/client/sse.ts +26 -68
- package/src/client/stream.ts +25 -67
- package/src/client/transport.test.ts +67 -0
- package/src/client/transport.ts +51 -112
- package/src/client/types.ts +17 -6
- package/src/client/wire.ts +119 -0
- package/src/client-react/live-resource.ts +6 -2
- package/src/compiler/aot.ts +3 -32
- package/src/compiler/dynamic.ts +13 -11
- package/src/compiler/interpret.ts +45 -0
- 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-Bgt9bq2r.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-DGscxaF5.js → agent-disclosure-B43CXZZR.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-BGmRZk7d.js → cache-glyph-92uM5MO7.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button--feUYxvG.js → call-pii-button-DtGVPtcs.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-JWvpaiGY.js → collapsible-DN7l6zmC.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-D9yCJG4n.js → duration-tone-BmIR9FV8.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-Bs6MD9GB.js → flows-page-BMHs-IzK.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-xH8MrEnv.js → highlighted-json-BlAEVgNW.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-C4vB6ZIw.js → http-method-BDf7OAHv.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-yTCY4AcS.js → index-BKpaes3n.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-BxJ3R6dU.js → observability-page-WnVLI-0j.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-QRKB_IE8.js → replica-lag-B3GLNVfF.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DqZ-fMu5.js → request-meta-DMbnAe3f.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-Dixb6L7a.js → store-page-KvFDingJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CazhjtiU.js → trace-detail-sheet-Htk8Cm9t.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-DlnqYKfr.js → tree-expand-toggle-CFMPWX4f.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-BXTLjU2-.js → units-page-C4NdNuxP.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-39KR__bc.js → vault-page-CBYl0LW_.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 +26 -4
- 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 +23 -4
- 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/client-descriptor.test.ts +78 -0
- package/src/kernel/client-descriptor.ts +23 -0
- package/src/kernel/errors-text.ts +99 -0
- package/src/kernel/errors-vault.ts +16 -0
- package/src/kernel/errors.registry.test.ts +7 -0
- package/src/kernel/errors.ts +184 -159
- package/src/kernel/fail-helpers.ts +34 -0
- package/src/kernel/fx-sql-handle.ts +305 -0
- package/src/kernel/fx.test.ts +8 -0
- package/src/kernel/fx.ts +49 -335
- package/src/kernel/index.ts +12 -1
- package/src/kernel/json-result.ts +59 -0
- package/src/kernel/project-out.ts +6 -1
- package/src/okid-extended.ts +175 -0
- package/src/okid-shared.ts +103 -0
- package/src/okid.ts +30 -213
- package/src/release/build-lib.ts +9 -0
- 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/dev.test.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* context and exercises `oke.manifest.get` over HTTP on :6535.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
10
10
|
import { mkdir, mkdtemp, readdir } from "node:fs/promises";
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
import { join, resolve } from "node:path";
|
|
@@ -17,9 +17,19 @@ import {
|
|
|
17
17
|
} from "../compiler/generate-adopt.ts";
|
|
18
18
|
import type { Manifest } from "../manifest/types.ts";
|
|
19
19
|
import type { WideEvent } from "../runs/types.ts";
|
|
20
|
+
import type { ConsoleAppHandle } from "../console/server/app.ts";
|
|
21
|
+
import { createConsoleState } from "../console/server/state.ts";
|
|
20
22
|
import { isDataEnvelope, MCP_DATA_KIND } from "../mcp/data.ts";
|
|
21
23
|
import { mintMcpSession } from "../mcp/session.ts";
|
|
22
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
CLIENT_REGEN_DEBOUNCE_MS,
|
|
26
|
+
isClientRegenWatchPath,
|
|
27
|
+
isFlowsTreeWatchPath,
|
|
28
|
+
runDev,
|
|
29
|
+
type DevOptions,
|
|
30
|
+
type DevSession,
|
|
31
|
+
type DevWatchFn,
|
|
32
|
+
} from "./dev.ts";
|
|
23
33
|
import { mcpContextFromConsole } from "./mcp-from-console.ts";
|
|
24
34
|
|
|
25
35
|
/** Repo public entry — absolute import so temp apps need no install. */
|
|
@@ -803,6 +813,99 @@ describe("isFlowsTreeWatchPath", () => {
|
|
|
803
813
|
});
|
|
804
814
|
});
|
|
805
815
|
|
|
816
|
+
describe("isClientRegenWatchPath", () => {
|
|
817
|
+
test("flow files and schema declares regenerate the client; unrelated files do not", () => {
|
|
818
|
+
expect(isClientRegenWatchPath("flows/notes/get.ts")).toBe(true);
|
|
819
|
+
expect(isClientRegenWatchPath("schema.ts")).toBe(true);
|
|
820
|
+
expect(isClientRegenWatchPath("db/schema/links.ts")).toBe(true);
|
|
821
|
+
expect(isClientRegenWatchPath("lib/util.ts")).toBe(false);
|
|
822
|
+
expect(isClientRegenWatchPath("flows/index.ts")).toBe(false);
|
|
823
|
+
expect(isClientRegenWatchPath("flows/notes/get.ts.tmp")).toBe(false);
|
|
824
|
+
expect(isClientRegenWatchPath("db/drizzle/schema.ts")).toBe(false);
|
|
825
|
+
});
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
describe("oke dev client regen watch", () => {
|
|
829
|
+
let session: DevSession | undefined;
|
|
830
|
+
|
|
831
|
+
afterEach(() => {
|
|
832
|
+
session?.stop();
|
|
833
|
+
session = undefined;
|
|
834
|
+
mock.restore();
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
test("three flow saves within 50ms regenerate the client once", async () => {
|
|
838
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-dev-regen-"));
|
|
839
|
+
await mkdir(join(dir, "src"), { recursive: true });
|
|
840
|
+
await Bun.write(join(dir, "src/app.ts"), "export {};\n");
|
|
841
|
+
|
|
842
|
+
const extractMod = await import("../compiler/extract.ts");
|
|
843
|
+
const originalExtract = extractMod.extractManifest;
|
|
844
|
+
let extracts = 0;
|
|
845
|
+
mock.module(resolve(import.meta.dir, "../compiler/extract.ts"), () => ({
|
|
846
|
+
...extractMod,
|
|
847
|
+
extractManifest: (opts: Parameters<typeof originalExtract>[0]) => {
|
|
848
|
+
extracts++;
|
|
849
|
+
return originalExtract(opts);
|
|
850
|
+
},
|
|
851
|
+
}));
|
|
852
|
+
|
|
853
|
+
let regens = 0;
|
|
854
|
+
let listener: Parameters<DevWatchFn>[2] | undefined;
|
|
855
|
+
const result = await runDev({
|
|
856
|
+
cwd: dir,
|
|
857
|
+
stdinIsTTY: false,
|
|
858
|
+
appPort: 0,
|
|
859
|
+
consolePort: 0,
|
|
860
|
+
mcpPort: 0,
|
|
861
|
+
...stubSurfaces(),
|
|
862
|
+
serveConsole: async () => ({
|
|
863
|
+
port: 0,
|
|
864
|
+
stop() {},
|
|
865
|
+
console: {
|
|
866
|
+
state: createConsoleState({ cwd: dir, secret: SECRET }),
|
|
867
|
+
} as ConsoleAppHandle,
|
|
868
|
+
}),
|
|
869
|
+
startApp: async () => ({
|
|
870
|
+
port: 0,
|
|
871
|
+
url: new URL("http://127.0.0.1:9"),
|
|
872
|
+
stop() {},
|
|
873
|
+
}),
|
|
874
|
+
syncAdoptBarrel: async () => [],
|
|
875
|
+
regenClient: async () => {
|
|
876
|
+
regens++;
|
|
877
|
+
},
|
|
878
|
+
watchFs: (_path, _options, next) => {
|
|
879
|
+
listener = next;
|
|
880
|
+
return { close() {} };
|
|
881
|
+
},
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
expect(result.code).toBe(0);
|
|
885
|
+
session = result.session;
|
|
886
|
+
const boot = regens;
|
|
887
|
+
expect(boot).toBe(1);
|
|
888
|
+
const extractsAfterBoot = extracts;
|
|
889
|
+
expect(extractsAfterBoot).toBeGreaterThan(0);
|
|
890
|
+
if (!listener) throw new Error("watcher not started");
|
|
891
|
+
|
|
892
|
+
listener("change", "flows/notes/get.ts");
|
|
893
|
+
listener("change", "flows/notes/list.ts");
|
|
894
|
+
listener("change", "flows/notes/[id]/delete.ts");
|
|
895
|
+
await Bun.sleep(50);
|
|
896
|
+
expect(regens).toBe(boot);
|
|
897
|
+
expect(extracts).toBe(extractsAfterBoot);
|
|
898
|
+
|
|
899
|
+
await Bun.sleep(CLIENT_REGEN_DEBOUNCE_MS + 80);
|
|
900
|
+
expect(regens).toBe(boot + 1);
|
|
901
|
+
expect(extracts).toBe(extractsAfterBoot + 1);
|
|
902
|
+
|
|
903
|
+
listener("change", "lib/util.ts");
|
|
904
|
+
await Bun.sleep(CLIENT_REGEN_DEBOUNCE_MS + 80);
|
|
905
|
+
expect(regens).toBe(boot + 1);
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
|
|
806
909
|
describe("oke dev Docker-first", () => {
|
|
807
910
|
test("dryRun always plans compose (no mode ask)", async () => {
|
|
808
911
|
const dir = await mkdtemp(join(tmpdir(), "oke-dev-always-compose-"));
|
package/src/cli/dev.ts
CHANGED
|
@@ -1417,15 +1417,26 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
1417
1417
|
|
|
1418
1418
|
const watchFs: DevWatchFn =
|
|
1419
1419
|
options.watchFs ?? ((path, watchOptions, listener) => watch(path, watchOptions, listener));
|
|
1420
|
+
// Trailing timer, always re-armed. A burst resets the window and still
|
|
1421
|
+
// fires once after it goes quiet — a timestamp check that only `return`s
|
|
1422
|
+
// drops the save and never regenerates.
|
|
1423
|
+
const clientRegen = createDebouncedRunner(() => {
|
|
1424
|
+
void regen(appUrl);
|
|
1425
|
+
}, CLIENT_REGEN_DEBOUNCE_MS);
|
|
1426
|
+
const manifestRefresh = createDebouncedRunner(() => {
|
|
1427
|
+
void refreshManifestInto(consoleState);
|
|
1428
|
+
}, CLIENT_REGEN_DEBOUNCE_MS);
|
|
1420
1429
|
const watcher = watchFs(resolve(cwd, "src"), { recursive: true }, (_event, filename) => {
|
|
1421
1430
|
const rel = (filename?.toString() ?? "").replace(/\\/g, "/");
|
|
1422
1431
|
if (isFlowsTreeWatchPath(rel)) {
|
|
1423
1432
|
void syncAdoptBarrel(cwd);
|
|
1424
1433
|
}
|
|
1425
|
-
|
|
1434
|
+
if (isClientRegenWatchPath(rel)) {
|
|
1435
|
+
clientRegen.trigger();
|
|
1436
|
+
}
|
|
1426
1437
|
// Only live-extract when the host did not pin a Manifest (tests).
|
|
1427
1438
|
if (options.manifest === undefined) {
|
|
1428
|
-
|
|
1439
|
+
manifestRefresh.trigger();
|
|
1429
1440
|
}
|
|
1430
1441
|
if (autoPushEnabled && isDomainSchemaWatchPath(filename?.toString())) {
|
|
1431
1442
|
lastSchemaFilename = filename?.toString() ?? "schema.ts";
|
|
@@ -1444,6 +1455,8 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
1444
1455
|
stopComposeHealthWatch = null;
|
|
1445
1456
|
bootBoard.stop();
|
|
1446
1457
|
autoPushRunner.cancel();
|
|
1458
|
+
clientRegen.cancel();
|
|
1459
|
+
manifestRefresh.cancel();
|
|
1447
1460
|
watcher.close();
|
|
1448
1461
|
const host = attachedHost;
|
|
1449
1462
|
attachedHost = null;
|
|
@@ -1613,6 +1626,28 @@ export function isFlowsTreeWatchPath(rel: string): boolean {
|
|
|
1613
1626
|
return true;
|
|
1614
1627
|
}
|
|
1615
1628
|
|
|
1629
|
+
/** Quiet window before one `oke-client.d.ts` regen after a burst of saves. */
|
|
1630
|
+
export const CLIENT_REGEN_DEBOUNCE_MS = 200;
|
|
1631
|
+
|
|
1632
|
+
/**
|
|
1633
|
+
* True when a `src/` watcher event can change the typed client
|
|
1634
|
+
* (flow files, or a schema declare module those flows import).
|
|
1635
|
+
*
|
|
1636
|
+
* Editor temp files and the adopt barrel are excluded so a regen cannot
|
|
1637
|
+
* retrigger itself.
|
|
1638
|
+
*
|
|
1639
|
+
* @param rel - Watcher filename (POSIX or Windows)
|
|
1640
|
+
*/
|
|
1641
|
+
export function isClientRegenWatchPath(rel: string): boolean {
|
|
1642
|
+
const posix = rel.replace(/\\/g, "/");
|
|
1643
|
+
if (posix.endsWith(".tmp")) return false;
|
|
1644
|
+
if (isFlowsTreeWatchPath(posix)) return true;
|
|
1645
|
+
if (/(^|\/)drizzle\//.test(posix)) return false;
|
|
1646
|
+
if (/(^|\/)schema(\.decl)?\.tsx?$/.test(posix)) return true;
|
|
1647
|
+
if (/(^|\/)schema\/.+\.tsx?$/.test(posix)) return true;
|
|
1648
|
+
return false;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1616
1651
|
/**
|
|
1617
1652
|
* Frame a schema-sync failure for the `oke dev` loop: warn loud for developer
|
|
1618
1653
|
* bugs, soft-skip only known environmental gaps. Never crashes the session.
|
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
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Untriggered flows fall back to `POST /_oke/{unit}/{flow}` RPC.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { createTransport, type Transport } from "./transport.ts";
|
|
15
|
+
import { createTransport, transportEnvelope, type Transport } from "./transport.ts";
|
|
16
16
|
import { asThenableIterable, attachPager } from "./pager.ts";
|
|
17
17
|
import {
|
|
18
18
|
flattenLiveRoutes,
|
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
LiveHandlers,
|
|
34
34
|
ResolveApp,
|
|
35
35
|
} from "./types.ts";
|
|
36
|
+
import { methodAndPath, walkContracts } from "./wire.ts";
|
|
36
37
|
|
|
37
38
|
/** App-shaped value that carries a runtime `$routes` table from typed adopt. */
|
|
38
39
|
export interface AppWithRoutes {
|
|
@@ -169,19 +170,11 @@ function mergeHeaders(base: ClientHeaders | undefined, over: ClientHeaders): Cli
|
|
|
169
170
|
export function flattenRoutes(
|
|
170
171
|
$routes: ClientRouteMap | undefined,
|
|
171
172
|
): ClientOptions["routes"] | undefined {
|
|
172
|
-
if (!$routes) return undefined;
|
|
173
173
|
const out: Record<string, { readonly method: string; readonly path: string }> = {};
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const method = "method" in contract ? contract.method : undefined;
|
|
179
|
-
const path = "path" in contract ? contract.path : undefined;
|
|
180
|
-
if (typeof method === "string" && typeof path === "string") {
|
|
181
|
-
out[`${unit}.${flow}`] = { method, path };
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
174
|
+
walkContracts($routes, (unit, flow, contract) => {
|
|
175
|
+
const route = methodAndPath(contract);
|
|
176
|
+
if (route) out[`${unit}.${flow}`] = route;
|
|
177
|
+
});
|
|
185
178
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
186
179
|
}
|
|
187
180
|
|
|
@@ -194,27 +187,21 @@ export function flattenRoutes(
|
|
|
194
187
|
function proxy(transport: Transport, path: readonly string[], ctx: ProxyCtx): unknown {
|
|
195
188
|
const invoke = async (input?: unknown, callOpts?: CallOpts): Promise<ClientResult> => {
|
|
196
189
|
if (path.length < 2) {
|
|
190
|
+
const message = `Incomplete path: api.${path.join(".") || "?"}(…)`;
|
|
197
191
|
return attachPager(
|
|
198
|
-
|
|
199
|
-
data: null,
|
|
200
|
-
error: {
|
|
201
|
-
code: "TransportError" as const,
|
|
202
|
-
data: {
|
|
203
|
-
message: `Incomplete path: api.${path.join(".") || "?"}(…)`,
|
|
204
|
-
},
|
|
205
|
-
},
|
|
206
|
-
},
|
|
192
|
+
transportEnvelope(message),
|
|
207
193
|
(nextInput) => invoke(nextInput, callOpts),
|
|
208
194
|
input,
|
|
209
195
|
);
|
|
210
196
|
}
|
|
211
197
|
const unit = path[0]!;
|
|
212
198
|
const flow = path.slice(1).join(".");
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
199
|
+
const headers = ctx.perCallHeaders.read();
|
|
200
|
+
const result = await transport.call(
|
|
201
|
+
`${unit}/${flow}`,
|
|
202
|
+
input,
|
|
203
|
+
callOpts ? { headers, ...callOpts } : headers,
|
|
204
|
+
);
|
|
218
205
|
return attachPager(result, (nextInput) => invoke(nextInput, callOpts), input);
|
|
219
206
|
};
|
|
220
207
|
const call = (a?: unknown, b?: unknown): unknown => {
|
|
@@ -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
|
+
});
|