okengine 0.21.0 → 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.
Files changed (160) hide show
  1. package/AGENTS.md +4 -4
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +15 -9
  5. package/manifest.v1.schema.json +10 -0
  6. package/package.json +22 -25
  7. package/site/content/docs/ai/skills.mdx +1 -1
  8. package/site/content/docs/client/calling.mdx +27 -19
  9. package/site/content/docs/client/index.mdx +1 -1
  10. package/site/content/docs/elements/channel/email.mdx +1 -1
  11. package/site/content/docs/elements/channel/index.mdx +1 -1
  12. package/site/content/docs/elements/flow/http.mdx +4 -4
  13. package/site/content/docs/elements/flow/index.mdx +3 -3
  14. package/site/content/docs/elements/flow/meta.json +1 -1
  15. package/site/content/docs/elements/gate/tenancy.mdx +1 -1
  16. package/site/content/docs/elements/vault/rotation.mdx +15 -0
  17. package/site/content/docs/plugins/otp.mdx +31 -31
  18. package/site/content/docs/recipes/mailpit.mdx +1 -1
  19. package/site/content/docs/recipes/meilisearch.mdx +1 -1
  20. package/site/content/docs/recipes/pgdog.mdx +1 -1
  21. package/site/content/docs/reference/configuration.mdx +2 -2
  22. package/site/content/docs/reference/errors.mdx +12 -2
  23. package/site/content/docs/reference/fx.mdx +28 -15
  24. package/site/content/docs/reference/idempotency.mdx +192 -0
  25. package/site/content/docs/reference/index.mdx +6 -1
  26. package/site/content/docs/reference/meta.json +2 -1
  27. package/site/content/docs/understand/meta.json +1 -1
  28. package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
  29. package/site/content/docs/understand/the-architecture.mdx +14 -12
  30. package/site/content/docs/understand/try-it.mdx +3 -3
  31. package/src/cli/competitor-mention-removal.test.ts +3 -3
  32. package/src/cli/dev.test.ts +105 -2
  33. package/src/cli/dev.ts +37 -2
  34. package/src/cli/docker-cli.test.ts +1 -1
  35. package/src/cli/index.ts +9 -1
  36. package/src/cli/load-config.images.test.ts +10 -10
  37. package/src/cli/load-config.ts +1 -1
  38. package/src/cli/vault-cmd.test.ts +23 -0
  39. package/src/cli/vault-cmd.ts +7 -0
  40. package/src/client/create.ts +31 -27
  41. package/src/client/live.ts +44 -101
  42. package/src/client/sse.ts +20 -66
  43. package/src/client/stream.ts +25 -67
  44. package/src/client/transport.test.ts +225 -0
  45. package/src/client/transport.ts +158 -90
  46. package/src/client/types.ts +29 -3
  47. package/src/client/wire.ts +119 -0
  48. package/src/compiler/aot.ts +3 -32
  49. package/src/compiler/dynamic.ts +13 -11
  50. package/src/compiler/effects-infer.ts +726 -16
  51. package/src/compiler/extract.test.ts +37 -0
  52. package/src/compiler/extract.ts +113 -12
  53. package/src/compiler/fixtures/skyport.expected.json +24 -0
  54. package/src/compiler/fx-follow.test.ts +164 -0
  55. package/src/compiler/fx-index.ts +399 -0
  56. package/src/compiler/interpret.ts +45 -0
  57. package/src/console/server/flows-invoke.test.ts +2 -2
  58. package/src/console/ui-next/dist/assets/{access-page-tIbsiphz.js → access-page-Ze6ckjdd.js} +2 -2
  59. package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
  60. package/src/console/ui-next/dist/assets/{cache-glyph-BCC-DxKT.js → cache-glyph-rL0lHxat.js} +1 -1
  61. package/src/console/ui-next/dist/assets/{call-pii-button-jOmYISdc.js → call-pii-button-Deo4PP18.js} +1 -1
  62. package/src/console/ui-next/dist/assets/{collapsible-DC2xNaAb.js → collapsible-CoJ6amHf.js} +1 -1
  63. package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
  64. package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
  65. package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
  66. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
  67. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
  68. package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
  69. package/src/console/ui-next/dist/assets/{flows-page-gT1lsWQK.js → flows-page-D2E7BtKK.js} +1 -1
  70. package/src/console/ui-next/dist/assets/{highlighted-json-C2GZEJNI.js → highlighted-json-CPYBzDh0.js} +1 -1
  71. package/src/console/ui-next/dist/assets/{http-method-BdYjcIrD.js → http-method-DTPevKmM.js} +1 -1
  72. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
  73. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
  74. package/src/console/ui-next/dist/assets/{replica-lag-C4QdAF7J.js → replica-lag-BM27lXld.js} +3 -3
  75. package/src/console/ui-next/dist/assets/{request-meta-C43DHyld.js → request-meta-BjFT7DNl.js} +1 -1
  76. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
  77. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
  78. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Dcpo4Us_.js → trace-detail-sheet-Cmitq3da.js} +2 -2
  79. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CKJTuv43.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
  80. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
  81. package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
  82. package/src/console/ui-next/dist/assets/{vault-page-BwZ9YjTW.js → vault-page-BOMr0go9.js} +2 -2
  83. package/src/console/ui-next/dist/index.html +2 -2
  84. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
  85. package/src/docker/docker.test.ts +13 -13
  86. package/src/docker/images-config.test.ts +12 -12
  87. package/src/docker/stack-id.test.ts +1 -1
  88. package/src/drivers/journal-postgres.ts +168 -3
  89. package/src/drivers/meilisearch.ts +2 -0
  90. package/src/drivers/vault-builtin.test.ts +10 -0
  91. package/src/drivers/vault-builtin.ts +13 -1
  92. package/src/drivers/vault-remote-bag.ts +5 -1
  93. package/src/drivers/vault-types.ts +6 -1
  94. package/src/elements/store/emit-drizzle.ts +2 -2
  95. package/src/elements/store/live-default.test.ts +2 -0
  96. package/src/elements/store/live-http.test.ts +1 -0
  97. package/src/elements/store/resource-list-docs.test.ts +2 -2
  98. package/src/elements/store/resource.test.ts +3 -3
  99. package/src/elements/store/schema-decl.test.ts +1 -0
  100. package/src/elements/store/sql-condition.ts +24 -7
  101. package/src/elements/store/sql-session.ts +20 -4
  102. package/src/elements/store/upsert-app.test.ts +1 -1
  103. package/src/elements/vault/audit.test.ts +137 -0
  104. package/src/elements/vault/audit.ts +106 -13
  105. package/src/elements/vault/boot-chain.ts +9 -1
  106. package/src/elements/vault/builtin-adapter.ts +33 -3
  107. package/src/i18n/catalogs/ar.ts +4 -0
  108. package/src/i18n/catalogs/en.ts +4 -0
  109. package/src/kernel/abort-scope.ts +33 -2
  110. package/src/kernel/app.ts +200 -20
  111. package/src/kernel/auto-cache.test.ts +6 -6
  112. package/src/kernel/boot-bind/vault.ts +1 -0
  113. package/src/kernel/boot.ts +19 -1
  114. package/src/kernel/builtin-errors.ts +8 -0
  115. package/src/kernel/client-descriptor.test.ts +78 -0
  116. package/src/kernel/client-descriptor.ts +24 -0
  117. package/src/kernel/concurrency.test.ts +33 -0
  118. package/src/kernel/effects-stamping.test.ts +2 -2
  119. package/src/kernel/errors-compiler.ts +20 -0
  120. package/src/kernel/errors-text.ts +99 -0
  121. package/src/kernel/errors.ts +90 -138
  122. package/src/kernel/external-effects.test.ts +36 -0
  123. package/src/kernel/flow.ts +24 -0
  124. package/src/kernel/fx-fetch.ts +5 -1
  125. package/src/kernel/fx-sql-handle.ts +305 -0
  126. package/src/kernel/fx.ts +42 -330
  127. package/src/kernel/idempotency-store.ts +578 -0
  128. package/src/kernel/idempotency.test.ts +556 -0
  129. package/src/kernel/idempotency.ts +304 -0
  130. package/src/kernel/index.ts +1 -0
  131. package/src/kernel/journal.ts +56 -0
  132. package/src/kernel/json-result.ts +59 -0
  133. package/src/kernel/project-out.ts +6 -1
  134. package/src/manifest/types.ts +8 -0
  135. package/src/okid-extended.ts +175 -0
  136. package/src/okid-shared.ts +103 -0
  137. package/src/okid.ts +30 -213
  138. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  139. package/src/plugins/otp.test.ts +65 -1
  140. package/src/plugins/otp.ts +59 -7
  141. package/src/release/absolute-regression.test.ts +63 -0
  142. package/src/release/build-lib.ts +9 -0
  143. package/src/release/http-graph.test.ts +25 -0
  144. package/src/release/http-graph.ts +90 -0
  145. package/src/release/index.ts +3 -0
  146. package/src/release/limits.ts +17 -2
  147. package/src/release/measure.ts +105 -14
  148. package/src/test/create-test-app.test.ts +3 -1
  149. package/src/test/create-test-app.ts +40 -0
  150. package/src/test/live-signals.test.ts +1 -0
  151. package/src/test/provisions.integration.test.ts +1 -0
  152. package/src/test/tenant-isolation.test.ts +1 -0
  153. package/src/console/ui-next/dist/assets/agent-disclosure-CSKumwS2.js +0 -1
  154. package/src/console/ui-next/dist/assets/duration-tone-CwoV56jn.js +0 -9
  155. package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
  156. package/src/console/ui-next/dist/assets/index-Cul17AcV.js +0 -63
  157. package/src/console/ui-next/dist/assets/observability-page-oY9vdYBk.js +0 -4
  158. package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
  159. package/src/console/ui-next/dist/assets/store-page-CL0D9dOq.js +0 -41
  160. package/src/console/ui-next/dist/assets/units-page-1PlT19ft.js +0 -1
@@ -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", () => {
@@ -12,12 +12,26 @@ import type {
12
12
  ClientHeaders,
13
13
  ClientOptions,
14
14
  } from "./types.ts";
15
+ import {
16
+ applyAuthHeader,
17
+ applyHeaderBag,
18
+ interpolatePath,
19
+ resolveHeaders,
20
+ toQuery,
21
+ } from "./wire.ts";
15
22
 
16
- /** Per-call transport options (binary decode, abort). */
23
+ /** Per-call transport options (binary decode, abort, retry opt-in). */
17
24
  export interface TransportCallOptions {
18
25
  readonly headers?: ClientHeaders;
19
26
  readonly response?: "json" | "blob" | "arrayBuffer";
20
27
  readonly signal?: AbortSignal;
28
+ /**
29
+ * Repeat this call under the client `retry` policy even when it sends
30
+ * no idempotency key.
31
+ */
32
+ readonly retry?: boolean;
33
+ /** Stable key, or `false` to send none. */
34
+ readonly idempotencyKey?: string | false;
21
35
  }
22
36
 
23
37
  /** Internal transport handle. */
@@ -56,10 +70,25 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
56
70
  let delay = delay0;
57
71
  const callClientOpts: ClientOptions =
58
72
  callOpts.signal !== undefined ? { ...opts, signal: callOpts.signal } : opts;
73
+ const method = (opts.routes?.[key.replace("/", ".")]?.method ?? "POST").toUpperCase();
74
+ const idempotencyKey = resolveIdempotencyKey(method, callOpts.idempotencyKey);
75
+ const allowRetry =
76
+ callOpts.retry === true ||
77
+ method === "GET" ||
78
+ method === "QUERY" ||
79
+ idempotencyKey !== undefined;
59
80
 
60
81
  for (;;) {
61
82
  try {
62
- const res = await once(base, key, input, callClientOpts, fetchFn, callOpts.headers);
83
+ const res = await once(
84
+ base,
85
+ key,
86
+ input,
87
+ callClientOpts,
88
+ fetchFn,
89
+ callOpts.headers,
90
+ idempotencyKey,
91
+ );
63
92
  if (
64
93
  res.status === 401 &&
65
94
  opts.auth &&
@@ -71,9 +100,22 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
71
100
  await opts.auth.refresh();
72
101
  continue;
73
102
  }
103
+ if (res.status === 409 && allowRetry && attempt < retries) {
104
+ const seconds = Number(res.headers.get("retry-after"));
105
+ const wait = seconds > 0 ? seconds * 1000 : delay;
106
+ const structured = await decodeIfEnvelope(res);
107
+ if (structured?.error?.code === "IdempotencyInProgress") {
108
+ await sleep(wait);
109
+ delay *= backoff;
110
+ attempt += 1;
111
+ continue;
112
+ }
113
+ if (structured) return stampReplay(structured, res);
114
+ return transportEnvelope("HTTP 409", 409);
115
+ }
74
116
  if (res.status >= 500) {
75
117
  const structured = await decodeIfEnvelope(res);
76
- if (structured) return structured;
118
+ if (structured) return stampReplay(structured, res);
77
119
  throw new Error(`HTTP ${res.status}`);
78
120
  }
79
121
  if (callOpts.response === "blob" || callOpts.response === "arrayBuffer") {
@@ -82,7 +124,7 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
82
124
  return decode(res);
83
125
  } catch (err) {
84
126
  const transient = isTransient(err);
85
- if (!transient || attempt >= retries) {
127
+ if (!transient || !allowRetry || attempt >= retries) {
86
128
  return transportEnvelope(err instanceof Error ? err.message : String(err));
87
129
  }
88
130
  await sleep(delay);
@@ -102,7 +144,7 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
102
144
  * @param message - Human-readable failure text
103
145
  * @param status - Optional HTTP status
104
146
  */
105
- function transportEnvelope(message: string, status?: number): ClientEnvelope {
147
+ export function transportEnvelope(message: string, status?: number): ClientEnvelope {
106
148
  return {
107
149
  data: null,
108
150
  error: {
@@ -120,10 +162,20 @@ function normalizeCallOpts(
120
162
  if (Array.isArray(headersOrOpts)) return { headers: headersOrOpts };
121
163
  if (
122
164
  typeof headersOrOpts === "object" &&
123
- ("response" in headersOrOpts || "signal" in headersOrOpts || "headers" in headersOrOpts)
165
+ ("response" in headersOrOpts ||
166
+ "signal" in headersOrOpts ||
167
+ "headers" in headersOrOpts ||
168
+ "retry" in headersOrOpts ||
169
+ "idempotencyKey" in headersOrOpts)
124
170
  ) {
125
171
  const o = headersOrOpts as TransportCallOptions;
126
- if (o.response !== undefined || o.signal !== undefined || o.headers !== undefined) {
172
+ if (
173
+ o.response !== undefined ||
174
+ o.signal !== undefined ||
175
+ o.headers !== undefined ||
176
+ o.retry !== undefined ||
177
+ o.idempotencyKey !== undefined
178
+ ) {
127
179
  return o;
128
180
  }
129
181
  }
@@ -131,7 +183,9 @@ function normalizeCallOpts(
131
183
  }
132
184
 
133
185
  /**
134
- * Single HTTP attempt. Throws on network / abort / 5xx (retryable).
186
+ * Single HTTP attempt. Throws on network / abort / non-envelope 5xx.
187
+ * A client timeout is rethrown as a plain `Error` so the retry loop can
188
+ * repeat it. A caller `AbortSignal` stays an `AbortError` and is not retried.
135
189
  *
136
190
  * @param base - Origin
137
191
  * @param key - `unit/flow`
@@ -147,6 +201,7 @@ async function once(
147
201
  opts: ClientOptions,
148
202
  fetchFn: ClientFetch,
149
203
  callHeaders?: ClientHeaders,
204
+ idempotencyKey?: string,
150
205
  ): Promise<Response> {
151
206
  const route = opts.routes?.[key.replace("/", ".")];
152
207
  const { url, method, body } = route
@@ -154,45 +209,32 @@ async function once(
154
209
  : rpcRequest(base, key, input);
155
210
 
156
211
  const headers = new Headers();
157
- const extra = typeof opts.headers === "function" ? await opts.headers() : opts.headers;
158
- if (Array.isArray(extra)) {
159
- for (const [k, v] of extra) headers.set(k, v);
160
- } else if (extra) {
161
- for (const [k, v] of Object.entries(extra)) headers.set(k, v);
162
- }
163
- if (Array.isArray(callHeaders)) {
164
- for (const [k, v] of callHeaders) headers.set(k, v);
165
- } else if (callHeaders) {
166
- for (const [k, v] of Object.entries(callHeaders)) headers.set(k, v);
167
- }
212
+ applyHeaderBag(headers, await resolveHeaders(opts));
213
+ applyHeaderBag(headers, callHeaders);
214
+ if (idempotencyKey !== undefined) headers.set("Idempotency-Key", idempotencyKey);
168
215
  if (body !== undefined && !headers.has("content-type") && typeof body === "string") {
169
216
  headers.set("content-type", "application/json");
170
217
  }
218
+ await applyAuthHeader(headers, opts);
171
219
 
172
- const token =
173
- opts.auth && "getToken" in opts.auth && typeof opts.auth.getToken === "function"
174
- ? await opts.auth.getToken()
175
- : undefined;
176
- if (token && !headers.has("authorization")) {
177
- headers.set("authorization", `Bearer ${token}`);
178
- }
220
+ const timeout = opts.timeout !== undefined ? AbortSignal.timeout(opts.timeout) : undefined;
221
+ const signal =
222
+ opts.signal && timeout ? AbortSignal.any([opts.signal, timeout]) : (opts.signal ?? timeout);
179
223
 
180
- const signals: AbortSignal[] = [];
181
- if (opts.signal) signals.push(opts.signal);
182
- if (opts.timeout !== undefined) {
183
- const t = AbortSignal.timeout(opts.timeout);
184
- signals.push(t);
224
+ try {
225
+ return await fetchFn(url, {
226
+ method,
227
+ headers,
228
+ body: body as RequestInit["body"],
229
+ signal,
230
+ ...(opts.credentials !== undefined ? { credentials: opts.credentials } : {}),
231
+ });
232
+ } catch (err) {
233
+ if (isAbortError(err) && timeout?.aborted === true && opts.signal?.aborted !== true) {
234
+ throw new Error(err instanceof Error ? err.message : "timeout");
235
+ }
236
+ throw err;
185
237
  }
186
- const signal =
187
- signals.length === 0 ? undefined : signals.length === 1 ? signals[0] : AbortSignal.any(signals);
188
-
189
- return await fetchFn(url, {
190
- method,
191
- headers,
192
- body: body as RequestInit["body"],
193
- signal,
194
- ...(opts.credentials !== undefined ? { credentials: opts.credentials } : {}),
195
- });
196
238
  }
197
239
 
198
240
  /**
@@ -236,58 +278,43 @@ function restRequest(
236
278
  if (isRawBody(input)) {
237
279
  return { url: `${base}${path}`, method: method.toUpperCase(), body: input };
238
280
  }
239
- const params =
240
- input !== null && typeof input === "object" ? (input as Record<string, unknown>) : {};
241
- let pathOut = path;
242
- const query: string[] = [];
243
- const rest: Record<string, unknown> = {};
244
-
245
- for (const [k, v] of Object.entries(params)) {
246
- const token = `:${k}`;
247
- if (pathOut.includes(token)) {
248
- pathOut = pathOut.replaceAll(token, encodeURIComponent(String(v)));
249
- } else {
250
- rest[k] = v;
251
- }
252
- }
281
+ const interpolated = interpolatePath(path, input);
282
+ const pathOut = interpolated.path;
283
+ const rest = interpolated.rest;
253
284
 
254
285
  const upper = method.toUpperCase();
286
+ const hasRest = Object.keys(rest).length > 0;
255
287
  let body: ClientBodyInit | undefined;
288
+ let qs = "";
256
289
  if (upper === "GET" || upper === "HEAD") {
257
- for (const [k, v] of Object.entries(rest)) {
258
- if (v !== undefined) {
259
- query.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
260
- }
261
- }
290
+ qs = toQuery(rest);
262
291
  } else if (upper === "QUERY") {
263
292
  // RFC 10008 QUERY always carries JSON content (empty object when only path params).
264
- body = JSON.stringify(Object.keys(rest).length > 0 ? rest : {});
265
- } else if (Object.keys(rest).length > 0 || path === pathOut) {
266
- body = JSON.stringify(Object.keys(rest).length > 0 ? rest : (input ?? {}));
293
+ body = JSON.stringify(hasRest ? rest : {});
294
+ } else if (hasRest || path === pathOut) {
295
+ body = JSON.stringify(hasRest ? rest : (input ?? {}));
267
296
  }
268
297
 
269
- const qs = query.length ? `?${query.join("&")}` : "";
270
298
  return { url: `${base}${pathOut}${qs}`, method: upper, body };
271
299
  }
272
300
 
273
301
  function isRawBody(input: unknown): input is ClientBodyInit {
274
- if (input === null || input === undefined) return false;
275
- if (typeof Blob !== "undefined" && input instanceof Blob) return true;
276
- if (typeof FormData !== "undefined" && input instanceof FormData) return true;
277
- if (typeof ArrayBuffer !== "undefined" && input instanceof ArrayBuffer) return true;
278
- if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(input)) return true;
279
- if (typeof ReadableStream !== "undefined" && input instanceof ReadableStream) return true;
302
+ if (input instanceof Blob) return true;
303
+ if (input instanceof FormData) return true;
304
+ if (input instanceof ArrayBuffer) return true;
305
+ if (ArrayBuffer.isView(input)) return true;
306
+ if (input instanceof ReadableStream) return true;
280
307
  return false;
281
308
  }
282
309
 
283
310
  async function decode(res: Response): Promise<ClientEnvelope> {
284
311
  if (res.status === 204) {
285
- return { data: undefined, error: null };
312
+ return stampReplay({ data: undefined, error: null }, res);
286
313
  }
287
314
 
288
315
  const text = await res.text();
289
316
  if (!text) {
290
- if (res.ok) return { data: undefined, error: null };
317
+ if (res.ok) return stampReplay({ data: undefined, error: null }, res);
291
318
  return transportEnvelope(`HTTP ${res.status}`, res.status);
292
319
  }
293
320
 
@@ -299,11 +326,11 @@ async function decode(res: Response): Promise<ClientEnvelope> {
299
326
  }
300
327
 
301
328
  if (json !== null && typeof json === "object" && "data" in json && "error" in json) {
302
- return json as ClientEnvelope;
329
+ return stampReplay(json as ClientEnvelope, res);
303
330
  }
304
331
 
305
332
  if (res.ok) {
306
- return { data: json, error: null };
333
+ return stampReplay({ data: json, error: null }, res);
307
334
  }
308
335
 
309
336
  return transportEnvelope(`HTTP ${res.status}`, res.status);
@@ -311,28 +338,69 @@ async function decode(res: Response): Promise<ClientEnvelope> {
311
338
 
312
339
  async function decodeBinary(res: Response, mode: "blob" | "arrayBuffer"): Promise<ClientEnvelope> {
313
340
  if (!res.ok) {
314
- const structured = await decodeIfEnvelopeClone(res);
341
+ const structured = await decodeIfEnvelope(res);
315
342
  if (structured) return structured;
316
343
  return transportEnvelope(`HTTP ${res.status}`, res.status);
317
344
  }
318
345
  const data = mode === "blob" ? await res.blob() : await res.arrayBuffer();
319
- return { data, error: null };
346
+ return stampReplay({ data, error: null }, res);
320
347
  }
321
348
 
322
- /** Try JSON envelope from an error response without assuming the body is reusable. */
323
- async function decodeIfEnvelopeClone(res: Response): Promise<ClientEnvelope | null> {
324
- const text = await res.text();
325
- if (!text) return null;
326
- let json: unknown;
327
- try {
328
- json = JSON.parse(text);
329
- } catch {
330
- return null;
331
- }
332
- if (json !== null && typeof json === "object" && "data" in json && "error" in json) {
333
- return json as ClientEnvelope;
334
- }
335
- return null;
349
+ /**
350
+ * Default `okid()` alphabet, inlined.
351
+ *
352
+ * Same 64-character Base64URL order as {@link OKID_ALPHABET}. Importing
353
+ * `okid` would pull `lazyRequire` and every alphabet constant into the client.
354
+ */
355
+ export const IDEMPOTENCY_KEY_ALPHABET =
356
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
357
+
358
+ /**
359
+ * 21-char key from `crypto.getRandomValues`.
360
+ *
361
+ * `getRandomValues` works on plain HTTP. When `crypto` is missing entirely,
362
+ * returns `undefined` so the call sends no key and runs once.
363
+ */
364
+ export function mintIdempotencyKey(): string | undefined {
365
+ const webCrypto = globalThis.crypto;
366
+ if (webCrypto?.getRandomValues === undefined) return undefined;
367
+ const bytes = new Uint8Array(21);
368
+ webCrypto.getRandomValues(bytes);
369
+ let out = "";
370
+ for (let i = 0; i < 21; i++) out += IDEMPOTENCY_KEY_ALPHABET[bytes[i]! & 63]!;
371
+ return out;
372
+ }
373
+
374
+ /**
375
+ * Key for this logical call, or `undefined` when none is sent.
376
+ *
377
+ * @param method - HTTP method, already uppercased
378
+ * @param opt - Per-call override
379
+ */
380
+ function resolveIdempotencyKey(
381
+ method: string,
382
+ opt: string | false | undefined,
383
+ ): string | undefined {
384
+ if (opt === false) return undefined;
385
+ if (typeof opt === "string") return opt;
386
+ if (method === "GET") return undefined;
387
+ return mintIdempotencyKey();
388
+ }
389
+
390
+ /**
391
+ * Copy `Idempotent-Replayed: true` onto result meta.
392
+ *
393
+ * @param envelope - Decoded envelope
394
+ * @param res - HTTP response
395
+ */
396
+ function stampReplay(envelope: ClientEnvelope, res: Response): ClientEnvelope {
397
+ if (res.headers.get("idempotent-replayed") !== "true") return envelope;
398
+ const prev = envelope.meta;
399
+ return { ...envelope, meta: { ...prev, idempotentReplayed: true } };
400
+ }
401
+
402
+ function isAbortError(err: unknown): boolean {
403
+ return err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
336
404
  }
337
405
 
338
406
  function isTransient(err: unknown): boolean {