okengine 0.21.1 → 0.22.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/AGENTS.md +4 -4
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +15 -9
- package/manifest.v1.schema.json +10 -0
- package/package.json +22 -20
- package/site/content/docs/ai/skills.mdx +1 -1
- package/site/content/docs/client/calling.mdx +27 -19
- package/site/content/docs/client/index.mdx +1 -1
- package/site/content/docs/elements/channel/email.mdx +1 -1
- package/site/content/docs/elements/channel/index.mdx +1 -1
- package/site/content/docs/elements/flow/http.mdx +4 -4
- package/site/content/docs/elements/flow/index.mdx +3 -3
- package/site/content/docs/elements/flow/meta.json +1 -1
- package/site/content/docs/elements/gate/tenancy.mdx +1 -1
- package/site/content/docs/elements/vault/rotation.mdx +15 -0
- package/site/content/docs/plugins/otp.mdx +31 -31
- package/site/content/docs/recipes/mailpit.mdx +1 -1
- package/site/content/docs/recipes/meilisearch.mdx +1 -1
- package/site/content/docs/recipes/pgdog.mdx +1 -1
- package/site/content/docs/reference/configuration.mdx +2 -2
- package/site/content/docs/reference/errors.mdx +12 -2
- package/site/content/docs/reference/fx.mdx +28 -15
- package/site/content/docs/reference/idempotency.mdx +192 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +2 -1
- package/site/content/docs/understand/meta.json +1 -1
- package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
- package/site/content/docs/understand/the-architecture.mdx +14 -12
- package/site/content/docs/understand/try-it.mdx +3 -3
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/cli/docker-cli.test.ts +1 -1
- package/src/cli/index.ts +9 -1
- package/src/cli/load-config.images.test.ts +10 -10
- package/src/cli/load-config.ts +1 -1
- package/src/cli/vault-cmd.test.ts +23 -0
- package/src/cli/vault-cmd.ts +7 -0
- package/src/client/create.ts +18 -1
- package/src/client/transport.test.ts +225 -0
- package/src/client/transport.ts +132 -19
- package/src/client/types.ts +29 -3
- package/src/compiler/effects-infer.ts +726 -16
- package/src/compiler/extract.test.ts +37 -0
- package/src/compiler/extract.ts +113 -12
- package/src/compiler/fixtures/skyport.expected.json +24 -0
- package/src/compiler/fx-follow.test.ts +164 -0
- package/src/compiler/fx-index.ts +399 -0
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/ui-next/dist/assets/{access-page-Bgt9bq2r.js → access-page-Ze6ckjdd.js} +2 -2
- package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
- package/src/console/ui-next/dist/assets/{cache-glyph-92uM5MO7.js → cache-glyph-rL0lHxat.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DtGVPtcs.js → call-pii-button-Deo4PP18.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-DN7l6zmC.js → collapsible-CoJ6amHf.js} +1 -1
- package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
- package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
- package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
- package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
- package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
- package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-BMHs-IzK.js → flows-page-D2E7BtKK.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-BlAEVgNW.js → highlighted-json-CPYBzDh0.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-BDf7OAHv.js → http-method-DTPevKmM.js} +1 -1
- package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
- package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
- package/src/console/ui-next/dist/assets/{replica-lag-B3GLNVfF.js → replica-lag-BM27lXld.js} +3 -3
- package/src/console/ui-next/dist/assets/{request-meta-DMbnAe3f.js → request-meta-BjFT7DNl.js} +1 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-Htk8Cm9t.js → trace-detail-sheet-Cmitq3da.js} +2 -2
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CFMPWX4f.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
- package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-CBYl0LW_.js → vault-page-BOMr0go9.js} +2 -2
- package/src/console/ui-next/dist/index.html +2 -2
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
- package/src/docker/docker.test.ts +13 -13
- package/src/docker/images-config.test.ts +12 -12
- package/src/docker/stack-id.test.ts +1 -1
- package/src/drivers/journal-postgres.ts +168 -3
- package/src/drivers/meilisearch.ts +2 -0
- package/src/drivers/vault-builtin.test.ts +10 -0
- package/src/drivers/vault-builtin.ts +13 -1
- package/src/drivers/vault-remote-bag.ts +5 -1
- package/src/drivers/vault-types.ts +6 -1
- package/src/elements/store/emit-drizzle.ts +2 -2
- package/src/elements/store/live-default.test.ts +2 -0
- package/src/elements/store/live-http.test.ts +1 -0
- package/src/elements/store/resource-list-docs.test.ts +2 -2
- package/src/elements/store/resource.test.ts +3 -3
- package/src/elements/store/schema-decl.test.ts +1 -0
- package/src/elements/store/sql-condition.ts +24 -7
- package/src/elements/store/upsert-app.test.ts +1 -1
- package/src/elements/vault/audit.test.ts +137 -0
- package/src/elements/vault/audit.ts +106 -13
- package/src/elements/vault/boot-chain.ts +9 -1
- package/src/elements/vault/builtin-adapter.ts +33 -3
- package/src/i18n/catalogs/ar.ts +4 -0
- package/src/i18n/catalogs/en.ts +4 -0
- package/src/kernel/abort-scope.ts +33 -2
- package/src/kernel/app.ts +191 -18
- package/src/kernel/auto-cache.test.ts +6 -6
- package/src/kernel/boot-bind/vault.ts +1 -0
- package/src/kernel/boot.ts +19 -1
- package/src/kernel/builtin-errors.ts +8 -0
- package/src/kernel/client-descriptor.ts +1 -0
- package/src/kernel/concurrency.test.ts +33 -0
- package/src/kernel/effects-stamping.test.ts +2 -2
- package/src/kernel/errors-compiler.ts +20 -0
- package/src/kernel/external-effects.test.ts +36 -0
- package/src/kernel/flow.ts +24 -0
- package/src/kernel/fx-fetch.ts +5 -1
- package/src/kernel/fx.ts +5 -1
- package/src/kernel/idempotency-store.ts +578 -0
- package/src/kernel/idempotency.test.ts +556 -0
- package/src/kernel/idempotency.ts +304 -0
- package/src/kernel/index.ts +1 -0
- package/src/kernel/journal.ts +56 -0
- package/src/manifest/types.ts +8 -0
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
- package/src/plugins/otp.test.ts +65 -1
- package/src/plugins/otp.ts +59 -7
- package/src/release/absolute-regression.test.ts +63 -0
- package/src/release/http-graph.test.ts +25 -0
- package/src/release/http-graph.ts +90 -0
- package/src/release/index.ts +3 -0
- package/src/release/limits.ts +17 -2
- package/src/release/measure.ts +105 -14
- package/src/test/create-test-app.test.ts +3 -1
- package/src/test/create-test-app.ts +40 -0
- package/src/test/live-signals.test.ts +1 -0
- package/src/test/provisions.integration.test.ts +1 -0
- package/src/test/tenant-isolation.test.ts +1 -0
- package/src/console/ui-next/dist/assets/agent-disclosure-B43CXZZR.js +0 -1
- package/src/console/ui-next/dist/assets/duration-tone-BmIR9FV8.js +0 -9
- package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
- package/src/console/ui-next/dist/assets/index-BKpaes3n.js +0 -63
- package/src/console/ui-next/dist/assets/observability-page-WnVLI-0j.js +0 -4
- package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-KvFDingJ.js +0 -41
- package/src/console/ui-next/dist/assets/units-page-C4NdNuxP.js +0 -1
|
@@ -11,8 +11,8 @@ import { join } from "node:path";
|
|
|
11
11
|
|
|
12
12
|
const ROOT = join(import.meta.dir, "../..");
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
const FORBIDDEN_HONO = ["Ho", "no"].join("");
|
|
14
|
+
/** Word-boundary router peer — `-F` false-positives on `Honored` / `canHonor`. */
|
|
15
|
+
const FORBIDDEN_HONO = ["\\b", "Ho", "no", "\\b"].join("");
|
|
16
16
|
/** Forbidden peer name. */
|
|
17
17
|
const FORBIDDEN_ELYSIA = ["Ely", "sia"].join("");
|
|
18
18
|
/** Forbidden peer name (covers Enc + ore.ts). */
|
|
@@ -104,7 +104,7 @@ function assertZeroGitGrepRegex(pattern: string, ignorePrefixes: readonly string
|
|
|
104
104
|
|
|
105
105
|
describe("named competitor mention removal gate", () => {
|
|
106
106
|
test("tracked tree has zero mentions of the thin multi-runtime router peer", () => {
|
|
107
|
-
|
|
107
|
+
assertZeroGitGrepRegex(FORBIDDEN_HONO);
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
test("tracked tree has zero mentions of the Bun-first peer framework", () => {
|
package/src/cli/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Bare `oke` (TTY) → interactive Ink TUI. `oke <command>` runs directly.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { VaultAuditConfig } from "../elements/vault/types.ts";
|
|
8
9
|
import { aiCli } from "./ai.ts";
|
|
9
10
|
import { branchCli } from "./branch.ts";
|
|
10
11
|
import { buildCli } from "./build.ts";
|
|
@@ -28,6 +29,7 @@ import { stackCli } from "./stack.ts";
|
|
|
28
29
|
import { startCli } from "./start.ts";
|
|
29
30
|
import { testCli } from "./test.ts";
|
|
30
31
|
import { upgradeCli } from "./upgrade.ts";
|
|
32
|
+
import { loadOkeConfig } from "./load-config.ts";
|
|
31
33
|
import { vaultCli } from "./vault-cmd.ts";
|
|
32
34
|
|
|
33
35
|
const rawArgv = process.argv.slice(2);
|
|
@@ -90,7 +92,13 @@ if (cmd === "client" && sub === "add") {
|
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
if (cmd === "vault") {
|
|
93
|
-
|
|
95
|
+
let audit: VaultAuditConfig | undefined;
|
|
96
|
+
try {
|
|
97
|
+
audit = (await loadOkeConfig()).config.vault?.audit;
|
|
98
|
+
} catch {
|
|
99
|
+
audit = undefined;
|
|
100
|
+
}
|
|
101
|
+
process.exit(await vaultCli(sub ? [sub, ...rest] : rest, audit === undefined ? {} : { audit }));
|
|
94
102
|
}
|
|
95
103
|
|
|
96
104
|
if (cmd === "docker") {
|
|
@@ -18,19 +18,19 @@ describe("flattenImagesConfig", () => {
|
|
|
18
18
|
sql: "postgres:18-alpine",
|
|
19
19
|
kv: "redis:8-alpine",
|
|
20
20
|
files: "rustfs/rustfs:1.0.0",
|
|
21
|
-
index: "getmeili/meilisearch:v1.
|
|
21
|
+
index: "getmeili/meilisearch:v1.54",
|
|
22
22
|
},
|
|
23
|
-
channel: { email: "axllent/mailpit:v1.31.
|
|
24
|
-
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.
|
|
23
|
+
channel: { email: "axllent/mailpit:v1.31.2" },
|
|
24
|
+
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.59",
|
|
25
25
|
proxy: "caddy:2-alpine",
|
|
26
26
|
}),
|
|
27
27
|
).toEqual({
|
|
28
28
|
"store.sql": "postgres:18-alpine",
|
|
29
29
|
"store.kv": "redis:8-alpine",
|
|
30
30
|
"store.files": "rustfs/rustfs:1.0.0",
|
|
31
|
-
"store.index": "getmeili/meilisearch:v1.
|
|
32
|
-
"channel.email": "axllent/mailpit:v1.31.
|
|
33
|
-
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.
|
|
31
|
+
"store.index": "getmeili/meilisearch:v1.54",
|
|
32
|
+
"channel.email": "axllent/mailpit:v1.31.2",
|
|
33
|
+
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.59",
|
|
34
34
|
proxy: "caddy:2-alpine",
|
|
35
35
|
});
|
|
36
36
|
});
|
|
@@ -53,7 +53,7 @@ describe("defaultImagesFromConfig", () => {
|
|
|
53
53
|
});
|
|
54
54
|
expect(images).toEqual({
|
|
55
55
|
"store.sql": "postgres:18-alpine",
|
|
56
|
-
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.
|
|
56
|
+
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.59",
|
|
57
57
|
"store.kv": "redis:8-alpine",
|
|
58
58
|
});
|
|
59
59
|
});
|
|
@@ -68,7 +68,7 @@ describe("defaultImagesFromConfig", () => {
|
|
|
68
68
|
},
|
|
69
69
|
});
|
|
70
70
|
expect(images["store.sql"]).toBe("pgvector/pgvector:pg17");
|
|
71
|
-
expect(images.pgdog).toBe("ghcr.io/pgdogdev/pgdog:v0.1.
|
|
71
|
+
expect(images.pgdog).toBe("ghcr.io/pgdogdev/pgdog:v0.1.59");
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
test("clock postgres alone pulls store.sql image", () => {
|
|
@@ -79,7 +79,7 @@ describe("defaultImagesFromConfig", () => {
|
|
|
79
79
|
});
|
|
80
80
|
expect(images).toEqual({
|
|
81
81
|
"store.sql": "postgres:18-alpine",
|
|
82
|
-
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.
|
|
82
|
+
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.59",
|
|
83
83
|
});
|
|
84
84
|
});
|
|
85
85
|
|
|
@@ -91,7 +91,7 @@ describe("defaultImagesFromConfig", () => {
|
|
|
91
91
|
});
|
|
92
92
|
expect(images).toEqual({
|
|
93
93
|
"store.sql": "postgres:18-alpine",
|
|
94
|
-
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.
|
|
94
|
+
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.59",
|
|
95
95
|
});
|
|
96
96
|
});
|
|
97
97
|
|
package/src/cli/load-config.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type { Manifest } from "../manifest/types.ts";
|
|
|
15
15
|
/** Default image pins when `images` is omitted but prod drivers need containers. */
|
|
16
16
|
const DEFAULT_SQL_IMAGE = "postgres:18-alpine";
|
|
17
17
|
const DEFAULT_PGVECTOR_IMAGE = "pgvector/pgvector:pg17";
|
|
18
|
-
const DEFAULT_PGDOG_IMAGE = "ghcr.io/pgdogdev/pgdog:v0.1.
|
|
18
|
+
const DEFAULT_PGDOG_IMAGE = "ghcr.io/pgdogdev/pgdog:v0.1.59";
|
|
19
19
|
const DEFAULT_KV_IMAGE = "redis:8-alpine";
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -282,6 +282,29 @@ describe("oke vault audit", () => {
|
|
|
282
282
|
await h.close();
|
|
283
283
|
}
|
|
284
284
|
});
|
|
285
|
+
|
|
286
|
+
test("audit verify refuses a webhook sink", async () => {
|
|
287
|
+
const sql = createMemoryVaultSql();
|
|
288
|
+
const errors: string[] = [];
|
|
289
|
+
const original = console.error;
|
|
290
|
+
console.error = (...args: unknown[]) => {
|
|
291
|
+
errors.push(args.map((part) => String(part)).join(" "));
|
|
292
|
+
};
|
|
293
|
+
try {
|
|
294
|
+
const code = await vaultCli(["audit", "verify"], {
|
|
295
|
+
sql,
|
|
296
|
+
env: {},
|
|
297
|
+
stdinIsTTY: false,
|
|
298
|
+
audit: { sink: "webhook", webhookUrl: "https://audit.example/hook" },
|
|
299
|
+
write: () => undefined,
|
|
300
|
+
});
|
|
301
|
+
expect(code).toBe(1);
|
|
302
|
+
expect(errors.join("\n")).toContain('audit.sink "db"');
|
|
303
|
+
} finally {
|
|
304
|
+
console.error = original;
|
|
305
|
+
await sql.close();
|
|
306
|
+
}
|
|
307
|
+
});
|
|
285
308
|
});
|
|
286
309
|
|
|
287
310
|
describe("oke vault backup / restore", () => {
|
package/src/cli/vault-cmd.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { SqlConnection } from "../drivers/types.ts";
|
|
|
24
24
|
import { openBuiltinVaultAdapter, VAULT_MASTER_KEY_ENV } from "../drivers/vault-builtin.ts";
|
|
25
25
|
import { parseDotenv, formatDotenv } from "../drivers/vault-dotenv-parse.ts";
|
|
26
26
|
import type { BuiltinVaultAdapter } from "../elements/vault/builtin-adapter.ts";
|
|
27
|
+
import type { VaultAuditConfig } from "../elements/vault/types.ts";
|
|
27
28
|
import { promptHidden, readStdinSecure } from "./vault-secure-input.ts";
|
|
28
29
|
|
|
29
30
|
/** Env var for the *new* master key when resuming an interrupted `rotate-master`. */
|
|
@@ -70,6 +71,11 @@ export interface VaultCmdOptions {
|
|
|
70
71
|
* closes a connection it did not open.
|
|
71
72
|
*/
|
|
72
73
|
readonly sql?: SqlConnection;
|
|
74
|
+
/**
|
|
75
|
+
* `vault.audit` from `oke.config.ts`. The `oke` binary loads this; tests
|
|
76
|
+
* pass it directly. Omitted means the SQL hash chain.
|
|
77
|
+
*/
|
|
78
|
+
readonly audit?: VaultAuditConfig;
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
/**
|
|
@@ -332,6 +338,7 @@ async function withVault<T>(
|
|
|
332
338
|
masterKey,
|
|
333
339
|
...(flags.url === undefined ? {} : { url: flags.url }),
|
|
334
340
|
...(options.sql === undefined ? {} : { connection: options.sql }),
|
|
341
|
+
...(options.audit === undefined ? {} : { audit: options.audit }),
|
|
335
342
|
});
|
|
336
343
|
try {
|
|
337
344
|
return await run(opened.adapter);
|
package/src/client/create.ts
CHANGED
|
@@ -338,11 +338,28 @@ function isCallOpts(value: unknown): value is CallOpts {
|
|
|
338
338
|
if ("signal" in v) {
|
|
339
339
|
return v.signal === undefined || v.signal instanceof AbortSignal;
|
|
340
340
|
}
|
|
341
|
+
const keyOk =
|
|
342
|
+
"idempotencyKey" in v && (typeof v.idempotencyKey === "string" || v.idempotencyKey === false);
|
|
343
|
+
if (("retry" in v && typeof v.retry === "boolean") || keyOk) {
|
|
344
|
+
return Object.keys(v).every(
|
|
345
|
+
(k) => k === "response" || k === "signal" || k === "retry" || k === "idempotencyKey",
|
|
346
|
+
);
|
|
347
|
+
}
|
|
341
348
|
return false;
|
|
342
349
|
}
|
|
343
350
|
|
|
344
|
-
/** Per-call options on a Flow invoke (binary decode
|
|
351
|
+
/** Per-call options on a Flow invoke (binary decode, abort, retry opt-in). */
|
|
345
352
|
export interface CallOpts {
|
|
346
353
|
readonly response?: "json" | "blob" | "arrayBuffer";
|
|
347
354
|
readonly signal?: AbortSignal;
|
|
355
|
+
/**
|
|
356
|
+
* Repeat this call under the client `retry` policy even when it sends
|
|
357
|
+
* no idempotency key.
|
|
358
|
+
*/
|
|
359
|
+
readonly retry?: boolean;
|
|
360
|
+
/**
|
|
361
|
+
* Stable `Idempotency-Key`, or `false` to send none.
|
|
362
|
+
* See {@link ClientCallOpts.idempotencyKey}.
|
|
363
|
+
*/
|
|
364
|
+
readonly idempotencyKey?: string | false;
|
|
348
365
|
}
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { OKID_ALPHABET } from "../okid-shared.ts";
|
|
6
7
|
import { createClient } from "./create.ts";
|
|
8
|
+
import { IDEMPOTENCY_KEY_ALPHABET } from "./transport.ts";
|
|
7
9
|
import type { AppOf } from "./types.ts";
|
|
8
10
|
|
|
9
11
|
type PingApp = AppOf<{
|
|
@@ -15,11 +17,16 @@ type PingApp = AppOf<{
|
|
|
15
17
|
};
|
|
16
18
|
}>;
|
|
17
19
|
|
|
20
|
+
const getPing = {
|
|
21
|
+
"sys.ping": { method: "GET", path: "/ping" },
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
18
24
|
describe("transport — retry", () => {
|
|
19
25
|
test("retries 5xx then succeeds", async () => {
|
|
20
26
|
let n = 0;
|
|
21
27
|
const api = createClient<PingApp>("http://app.test", {
|
|
22
28
|
retry: { retries: 2, delay: 1, backoff: 1 },
|
|
29
|
+
routes: getPing,
|
|
23
30
|
fetch: async () => {
|
|
24
31
|
n += 1;
|
|
25
32
|
if (n < 3) return new Response("nope", { status: 503 });
|
|
@@ -37,6 +44,7 @@ describe("transport — retry", () => {
|
|
|
37
44
|
let n = 0;
|
|
38
45
|
const api = createClient<PingApp>("http://app.test", {
|
|
39
46
|
retry: { retries: 1, delay: 1, backoff: 1 },
|
|
47
|
+
routes: getPing,
|
|
40
48
|
fetch: async () => {
|
|
41
49
|
n += 1;
|
|
42
50
|
return new Response("nope", { status: 502 });
|
|
@@ -73,6 +81,223 @@ describe("transport — retry", () => {
|
|
|
73
81
|
expect(error?.code).toBe("InternalError");
|
|
74
82
|
expect(error?.message).toMatch(/password policy/i);
|
|
75
83
|
});
|
|
84
|
+
|
|
85
|
+
test("POST retries when it carries an idempotency key", async () => {
|
|
86
|
+
let handled = 0;
|
|
87
|
+
const keys: string[] = [];
|
|
88
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
89
|
+
retry: { retries: 2, delay: 1, backoff: 1 },
|
|
90
|
+
fetch: async (_input, init) => {
|
|
91
|
+
handled += 1;
|
|
92
|
+
keys.push(new Headers(init?.headers).get("idempotency-key") ?? "");
|
|
93
|
+
throw new TypeError("Failed to fetch");
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const lost = await api.sys.ping();
|
|
98
|
+
expect(lost.error?.code).toBe("TransportError");
|
|
99
|
+
expect(handled).toBe(3);
|
|
100
|
+
expect(keys[0]).toBe(keys[1]);
|
|
101
|
+
expect(keys[1]).toBe(keys[2]);
|
|
102
|
+
expect(keys[0]!.length).toBeGreaterThanOrEqual(16);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("idempotencyKey false runs once unless retry is opted in", async () => {
|
|
106
|
+
let handled = 0;
|
|
107
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
108
|
+
retry: { retries: 2, delay: 1, backoff: 1 },
|
|
109
|
+
fetch: async (_input, init) => {
|
|
110
|
+
handled += 1;
|
|
111
|
+
expect(new Headers(init?.headers).get("idempotency-key")).toBeNull();
|
|
112
|
+
throw new TypeError("Failed to fetch");
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const lost = await api.sys.ping({ idempotencyKey: false });
|
|
117
|
+
expect(lost.error?.code).toBe("TransportError");
|
|
118
|
+
expect(handled).toBe(1);
|
|
119
|
+
|
|
120
|
+
handled = 0;
|
|
121
|
+
const opted = await api.sys.ping({ idempotencyKey: false, retry: true });
|
|
122
|
+
expect(opted.error?.code).toBe("TransportError");
|
|
123
|
+
expect(handled).toBe(3);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("two calls mint two keys; a string key is reused", async () => {
|
|
127
|
+
const keys: string[] = [];
|
|
128
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
129
|
+
fetch: async (_input, init) => {
|
|
130
|
+
keys.push(new Headers(init?.headers).get("idempotency-key") ?? "");
|
|
131
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
await api.sys.ping();
|
|
135
|
+
await api.sys.ping();
|
|
136
|
+
expect(keys[0]).not.toBe(keys[1]);
|
|
137
|
+
keys.length = 0;
|
|
138
|
+
await api.sys.ping({ idempotencyKey: "form-key-0123456789" });
|
|
139
|
+
expect(keys).toEqual(["form-key-0123456789"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("inlined idempotency alphabet matches OKID_ALPHABET", () => {
|
|
143
|
+
expect(IDEMPOTENCY_KEY_ALPHABET).toBe(OKID_ALPHABET);
|
|
144
|
+
expect(IDEMPOTENCY_KEY_ALPHABET).toHaveLength(64);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("missing crypto sends no idempotency key", async () => {
|
|
148
|
+
const original = globalThis.crypto;
|
|
149
|
+
Object.defineProperty(globalThis, "crypto", {
|
|
150
|
+
value: undefined,
|
|
151
|
+
configurable: true,
|
|
152
|
+
writable: true,
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
156
|
+
fetch: async (_input, init) => {
|
|
157
|
+
expect(new Headers(init?.headers).get("idempotency-key")).toBeNull();
|
|
158
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
const { error } = await api.sys.ping();
|
|
162
|
+
expect(error).toBeNull();
|
|
163
|
+
} finally {
|
|
164
|
+
Object.defineProperty(globalThis, "crypto", {
|
|
165
|
+
value: original,
|
|
166
|
+
configurable: true,
|
|
167
|
+
writable: true,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("GET sends no idempotency key", async () => {
|
|
173
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
174
|
+
routes: getPing,
|
|
175
|
+
fetch: async (_input, init) => {
|
|
176
|
+
expect(new Headers(init?.headers).get("idempotency-key")).toBeNull();
|
|
177
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
const { error } = await api.sys.ping();
|
|
181
|
+
expect(error).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("401 resend reuses the idempotency key", async () => {
|
|
185
|
+
const keys: string[] = [];
|
|
186
|
+
let token = "old";
|
|
187
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
188
|
+
auth: {
|
|
189
|
+
getToken: () => token,
|
|
190
|
+
refresh: async () => {
|
|
191
|
+
token = "new";
|
|
192
|
+
return token;
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
fetch: async (_input, init) => {
|
|
196
|
+
const headers = new Headers(init?.headers);
|
|
197
|
+
keys.push(headers.get("idempotency-key") ?? "");
|
|
198
|
+
if (headers.get("authorization") === "Bearer old") {
|
|
199
|
+
return new Response("unauthorized", { status: 401 });
|
|
200
|
+
}
|
|
201
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
const { error } = await api.sys.ping();
|
|
205
|
+
expect(error).toBeNull();
|
|
206
|
+
expect(keys).toHaveLength(2);
|
|
207
|
+
expect(keys[0]).toBe(keys[1]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("IdempotencyInProgress waits for Retry-After then replays", async () => {
|
|
211
|
+
let n = 0;
|
|
212
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
213
|
+
retry: { retries: 2, delay: 1, backoff: 1 },
|
|
214
|
+
timeout: 30,
|
|
215
|
+
fetch: async (_input, init) => {
|
|
216
|
+
n += 1;
|
|
217
|
+
const signal = init?.signal;
|
|
218
|
+
if (n === 1) {
|
|
219
|
+
await new Promise<void>((_resolve, reject) => {
|
|
220
|
+
if (signal?.aborted) {
|
|
221
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
signal?.addEventListener("abort", () => {
|
|
225
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (n === 2) {
|
|
230
|
+
return Response.json(
|
|
231
|
+
{ data: null, error: { code: "IdempotencyInProgress", data: {} } },
|
|
232
|
+
{ status: 409, headers: { "retry-after": "0.01" } },
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return Response.json(
|
|
236
|
+
{ data: { ok: true }, error: null },
|
|
237
|
+
{ headers: { "idempotent-replayed": "true" } },
|
|
238
|
+
);
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const result = await api.sys.ping();
|
|
243
|
+
expect(result.error).toBeNull();
|
|
244
|
+
expect(result.data).toEqual({ ok: true });
|
|
245
|
+
expect(result.meta?.idempotentReplayed).toBe(true);
|
|
246
|
+
expect(n).toBe(3);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("other 409 envelopes are not retried", async () => {
|
|
250
|
+
let n = 0;
|
|
251
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
252
|
+
retry: { retries: 2, delay: 1, backoff: 1 },
|
|
253
|
+
fetch: async () => {
|
|
254
|
+
n += 1;
|
|
255
|
+
return Response.json(
|
|
256
|
+
{ data: null, error: { code: "Conflict", data: {} } },
|
|
257
|
+
{ status: 409 },
|
|
258
|
+
);
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
const { error } = await api.sys.ping();
|
|
262
|
+
expect(error?.code).toBe("Conflict");
|
|
263
|
+
expect(n).toBe(1);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("GET still retries a network error", async () => {
|
|
267
|
+
let n = 0;
|
|
268
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
269
|
+
retry: { retries: 1, delay: 1, backoff: 1 },
|
|
270
|
+
routes: getPing,
|
|
271
|
+
fetch: async () => {
|
|
272
|
+
n += 1;
|
|
273
|
+
if (n < 2) throw new TypeError("Failed to fetch");
|
|
274
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const { data, error } = await api.sys.ping();
|
|
279
|
+
expect(error).toBeNull();
|
|
280
|
+
expect(data).toEqual({ ok: true });
|
|
281
|
+
expect(n).toBe(2);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("QUERY still retries a network error", async () => {
|
|
285
|
+
let n = 0;
|
|
286
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
287
|
+
retry: { retries: 1, delay: 1, backoff: 1 },
|
|
288
|
+
routes: { "sys.ping": { method: "QUERY", path: "/ping" } },
|
|
289
|
+
fetch: async () => {
|
|
290
|
+
n += 1;
|
|
291
|
+
if (n < 2) throw new TypeError("Failed to fetch");
|
|
292
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const { data, error } = await api.sys.ping();
|
|
297
|
+
expect(error).toBeNull();
|
|
298
|
+
expect(data).toEqual({ ok: true });
|
|
299
|
+
expect(n).toBe(2);
|
|
300
|
+
});
|
|
76
301
|
});
|
|
77
302
|
|
|
78
303
|
describe("transport — timeout", () => {
|