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.
- 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 -25
- 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/dev.test.ts +105 -2
- package/src/cli/dev.ts +37 -2
- 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 +31 -27
- package/src/client/live.ts +44 -101
- package/src/client/sse.ts +20 -66
- package/src/client/stream.ts +25 -67
- package/src/client/transport.test.ts +225 -0
- package/src/client/transport.ts +158 -90
- package/src/client/types.ts +29 -3
- package/src/client/wire.ts +119 -0
- package/src/compiler/aot.ts +3 -32
- package/src/compiler/dynamic.ts +13 -11
- 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/compiler/interpret.ts +45 -0
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/ui-next/dist/assets/{access-page-tIbsiphz.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-BCC-DxKT.js → cache-glyph-rL0lHxat.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-jOmYISdc.js → call-pii-button-Deo4PP18.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-DC2xNaAb.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-gT1lsWQK.js → flows-page-D2E7BtKK.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-C2GZEJNI.js → highlighted-json-CPYBzDh0.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-BdYjcIrD.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-C4QdAF7J.js → replica-lag-BM27lXld.js} +3 -3
- package/src/console/ui-next/dist/assets/{request-meta-C43DHyld.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-Dcpo4Us_.js → trace-detail-sheet-Cmitq3da.js} +2 -2
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CKJTuv43.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-BwZ9YjTW.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/sql-session.ts +20 -4
- 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 +200 -20
- 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.test.ts +78 -0
- package/src/kernel/client-descriptor.ts +24 -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/errors-text.ts +99 -0
- package/src/kernel/errors.ts +90 -138
- 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-sql-handle.ts +305 -0
- package/src/kernel/fx.ts +42 -330
- 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/kernel/json-result.ts +59 -0
- package/src/kernel/project-out.ts +6 -1
- package/src/manifest/types.ts +8 -0
- package/src/okid-extended.ts +175 -0
- package/src/okid-shared.ts +103 -0
- package/src/okid.ts +30 -213
- 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/build-lib.ts +9 -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-CSKumwS2.js +0 -1
- package/src/console/ui-next/dist/assets/duration-tone-CwoV56jn.js +0 -9
- package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
- package/src/console/ui-next/dist/assets/index-Cul17AcV.js +0 -63
- package/src/console/ui-next/dist/assets/observability-page-oY9vdYBk.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-CL0D9dOq.js +0 -41
- package/src/console/ui-next/dist/assets/units-page-1PlT19ft.js +0 -1
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP idempotency — claim, replay, lease reclaim, and the client timeout path.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { createClient } from "../client/create.ts";
|
|
8
|
+
import type { AppOf } from "../client/types.ts";
|
|
9
|
+
import {
|
|
10
|
+
createPostgresJournalFake,
|
|
11
|
+
createPostgresJournalStore,
|
|
12
|
+
} from "../drivers/journal-postgres.ts";
|
|
13
|
+
import { gate } from "../elements/gate.ts";
|
|
14
|
+
import { store } from "../elements/store.ts";
|
|
15
|
+
import { oke, type OkeApp } from "./app.ts";
|
|
16
|
+
import { flow, resetFlowSeq, type AnyFlowDef } from "./flow.ts";
|
|
17
|
+
import { createMemoryJournalStore, type JournalStore } from "./journal.ts";
|
|
18
|
+
import type { JournalRuntime } from "./boot-bind/journal.ts";
|
|
19
|
+
import type { Binding } from "./on.ts";
|
|
20
|
+
import { resetBindings } from "./on.ts";
|
|
21
|
+
import { decideClaim, IDEMPOTENCY_LEASE_MS } from "./idempotency-store.ts";
|
|
22
|
+
import { http } from "./triggers.ts";
|
|
23
|
+
|
|
24
|
+
const KEY = "idem-key-0123456789";
|
|
25
|
+
|
|
26
|
+
const apps: OkeApp[] = [];
|
|
27
|
+
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
resetBindings();
|
|
30
|
+
resetFlowSeq();
|
|
31
|
+
for (const app of apps) await app.bootResult?.close();
|
|
32
|
+
apps.length = 0;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function journalOf(store: JournalStore, leaseMs = IDEMPOTENCY_LEASE_MS): JournalRuntime {
|
|
36
|
+
return { store, instanceId: "idem-test", leaseMs, driverId: "memory" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function start(
|
|
40
|
+
name: string,
|
|
41
|
+
bindings: readonly Binding[],
|
|
42
|
+
journal?: JournalRuntime,
|
|
43
|
+
stores?: ReturnType<typeof store.kv>[],
|
|
44
|
+
policies?: ReturnType<typeof gate.policy>[],
|
|
45
|
+
): Promise<OkeApp> {
|
|
46
|
+
resetBindings();
|
|
47
|
+
resetFlowSeq();
|
|
48
|
+
const app = oke({
|
|
49
|
+
name,
|
|
50
|
+
env: "test",
|
|
51
|
+
registry: "ignore",
|
|
52
|
+
gate: {
|
|
53
|
+
unguardedHttp: "allow",
|
|
54
|
+
...(policies !== undefined ? { policies } : {}),
|
|
55
|
+
},
|
|
56
|
+
bindings: [...bindings],
|
|
57
|
+
...(stores !== undefined ? { stores } : {}),
|
|
58
|
+
...(journal !== undefined ? { elements: { journal } } : {}),
|
|
59
|
+
});
|
|
60
|
+
await app.boot({ env: "test", ...(policies !== undefined ? { gates: policies } : {}) });
|
|
61
|
+
apps.push(app);
|
|
62
|
+
return app;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function chargeBinding(run: () => Promise<{ n: number }> | { n: number }): Binding {
|
|
66
|
+
return {
|
|
67
|
+
trigger: http.post("/charge", { in: z.object({ n: z.number() }) }),
|
|
68
|
+
flow: flow("pay.charge", {
|
|
69
|
+
effects: { writes: ["sql:payments"] },
|
|
70
|
+
do: async () => run(),
|
|
71
|
+
}) as AnyFlowDef,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function post(body: unknown, key: string | false = KEY): Request {
|
|
76
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
77
|
+
if (key !== false) headers.set("idempotency-key", key);
|
|
78
|
+
return new Request("http://localhost/charge", {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers,
|
|
81
|
+
body: JSON.stringify(body),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe("idempotency store", () => {
|
|
86
|
+
test("a live same-key claim is busy; a different payload mismatches", () => {
|
|
87
|
+
const scope = { tenant: "", principal: "anon", flow: "pay.charge", key: KEY };
|
|
88
|
+
const row = {
|
|
89
|
+
...scope,
|
|
90
|
+
fingerprint: "aaa",
|
|
91
|
+
status: "in_progress" as const,
|
|
92
|
+
claimToken: "t1",
|
|
93
|
+
leaseExpiresAt: 2_000,
|
|
94
|
+
createdAt: 0,
|
|
95
|
+
expiresAt: 9_000,
|
|
96
|
+
};
|
|
97
|
+
const input = {
|
|
98
|
+
scope,
|
|
99
|
+
fingerprint: "aaa",
|
|
100
|
+
claimToken: "t2",
|
|
101
|
+
now: 1_000,
|
|
102
|
+
leaseMs: 30_000,
|
|
103
|
+
ttlMs: 1,
|
|
104
|
+
};
|
|
105
|
+
expect(decideClaim(row, input, new Set(["t1"])).op).toBe("busy");
|
|
106
|
+
expect(decideClaim(row, { ...input, fingerprint: "bbb" }, new Set()).op).toBe("mismatch");
|
|
107
|
+
expect(decideClaim({ ...row, leaseExpiresAt: 0 }, input, new Set()).op).toBe("reclaim");
|
|
108
|
+
}, 30_000);
|
|
109
|
+
|
|
110
|
+
test("postgres fake replays one claim and rejects a second payload", async () => {
|
|
111
|
+
const journal = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
|
|
112
|
+
const idem = journal.idempotency!;
|
|
113
|
+
const scope = { tenant: "", principal: "anon", flow: "pay.charge", key: KEY };
|
|
114
|
+
const first = await idem.claim({
|
|
115
|
+
scope,
|
|
116
|
+
fingerprint: "aaa",
|
|
117
|
+
claimToken: "t1",
|
|
118
|
+
now: 1_000,
|
|
119
|
+
leaseMs: 30_000,
|
|
120
|
+
ttlMs: 5_000,
|
|
121
|
+
});
|
|
122
|
+
expect(first.kind).toBe("claimed");
|
|
123
|
+
await idem.complete(scope, "t1", { status: 200, body: '{"data":1}' });
|
|
124
|
+
const again = await idem.claim({
|
|
125
|
+
scope,
|
|
126
|
+
fingerprint: "aaa",
|
|
127
|
+
claimToken: "t2",
|
|
128
|
+
now: 1_100,
|
|
129
|
+
leaseMs: 30_000,
|
|
130
|
+
ttlMs: 5_000,
|
|
131
|
+
});
|
|
132
|
+
expect(again.kind).toBe("replay");
|
|
133
|
+
const reused = await idem.claim({
|
|
134
|
+
scope,
|
|
135
|
+
fingerprint: "bbb",
|
|
136
|
+
claimToken: "t3",
|
|
137
|
+
now: 1_100,
|
|
138
|
+
leaseMs: 30_000,
|
|
139
|
+
ttlMs: 5_000,
|
|
140
|
+
});
|
|
141
|
+
expect(reused.kind).toBe("mismatch");
|
|
142
|
+
}, 30_000);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("HTTP idempotency", () => {
|
|
146
|
+
test("the same key runs once and the second response is a replay", async () => {
|
|
147
|
+
let runs = 0;
|
|
148
|
+
const app = await start("idem-replay", [
|
|
149
|
+
chargeBinding(() => {
|
|
150
|
+
runs += 1;
|
|
151
|
+
return { n: runs };
|
|
152
|
+
}),
|
|
153
|
+
]);
|
|
154
|
+
const first = await app.fetch(post({ n: 1 }));
|
|
155
|
+
const second = await app.fetch(post({ n: 1 }));
|
|
156
|
+
expect(first.status).toBe(200);
|
|
157
|
+
expect(second.status).toBe(200);
|
|
158
|
+
expect(second.headers.get("idempotent-replayed")).toBe("true");
|
|
159
|
+
expect(await second.json()).toEqual(await first.json());
|
|
160
|
+
expect(runs).toBe(1);
|
|
161
|
+
}, 30_000);
|
|
162
|
+
|
|
163
|
+
test("two concurrent same-key requests: one runs, the other is 409", async () => {
|
|
164
|
+
let runs = 0;
|
|
165
|
+
let release!: () => void;
|
|
166
|
+
const hold = new Promise<void>((resolve) => {
|
|
167
|
+
release = resolve;
|
|
168
|
+
});
|
|
169
|
+
const app = await start("idem-race", [
|
|
170
|
+
chargeBinding(async () => {
|
|
171
|
+
runs += 1;
|
|
172
|
+
await hold;
|
|
173
|
+
return { n: 1 };
|
|
174
|
+
}),
|
|
175
|
+
]);
|
|
176
|
+
const pending = Promise.all([
|
|
177
|
+
app.fetch(post({ n: 1 })).then(async (res) => {
|
|
178
|
+
if (res.status === 409) release();
|
|
179
|
+
return res;
|
|
180
|
+
}),
|
|
181
|
+
app.fetch(post({ n: 1 })).then(async (res) => {
|
|
182
|
+
if (res.status === 409) release();
|
|
183
|
+
return res;
|
|
184
|
+
}),
|
|
185
|
+
]);
|
|
186
|
+
const [a, b] = await pending;
|
|
187
|
+
const statuses = [a.status, b.status].sort();
|
|
188
|
+
expect(statuses).toEqual([200, 409]);
|
|
189
|
+
const conflict = a.status === 409 ? a : b;
|
|
190
|
+
expect(((await conflict.json()) as { error: { code: string } }).error.code).toBe(
|
|
191
|
+
"IdempotencyInProgress",
|
|
192
|
+
);
|
|
193
|
+
expect(runs).toBe(1);
|
|
194
|
+
}, 30_000);
|
|
195
|
+
|
|
196
|
+
test("the same key with a different payload is 422", async () => {
|
|
197
|
+
const app = await start("idem-reuse", [chargeBinding(() => ({ n: 1 }))]);
|
|
198
|
+
expect((await app.fetch(post({ n: 1 }))).status).toBe(200);
|
|
199
|
+
const reused = await app.fetch(post({ n: 2 }));
|
|
200
|
+
expect(reused.status).toBe(422);
|
|
201
|
+
expect(((await reused.json()) as { error: { code: string } }).error.code).toBe(
|
|
202
|
+
"IdempotencyKeyReused",
|
|
203
|
+
);
|
|
204
|
+
}, 30_000);
|
|
205
|
+
|
|
206
|
+
test("a validation or gate rejection stores nothing; the same key then succeeds", async () => {
|
|
207
|
+
let runs = 0;
|
|
208
|
+
const member = gate.policy("member", ({ auth }) => auth.verified === true);
|
|
209
|
+
const charge = flow("pay.charge", {
|
|
210
|
+
effects: { writes: ["sql:payments"] },
|
|
211
|
+
do: () => {
|
|
212
|
+
runs += 1;
|
|
213
|
+
return { n: 1 };
|
|
214
|
+
},
|
|
215
|
+
}) as AnyFlowDef;
|
|
216
|
+
charge.in = z.object({ n: z.number() });
|
|
217
|
+
const binding: Binding = {
|
|
218
|
+
trigger: http.post("/charge").gate(member),
|
|
219
|
+
flow: charge,
|
|
220
|
+
};
|
|
221
|
+
const app = await start("idem-gate", [binding], undefined, undefined, [member]);
|
|
222
|
+
const invalid = await app.fetch(post({ n: "nope" }));
|
|
223
|
+
expect(invalid.status).toBe(422);
|
|
224
|
+
const denied = await app.fetch(post({ n: 1 }));
|
|
225
|
+
expect(denied.status).toBe(401);
|
|
226
|
+
const allowed = await app.execute(binding.flow, { n: 1 }, binding.trigger, {
|
|
227
|
+
request: post({ n: 1 }),
|
|
228
|
+
validated: true,
|
|
229
|
+
principal: { plane: "user", userId: "u1", scopes: new Set(), verified: true },
|
|
230
|
+
});
|
|
231
|
+
expect(allowed.failure).toBeUndefined();
|
|
232
|
+
expect(allowed.output).toEqual({ n: 1 });
|
|
233
|
+
expect(runs).toBe(1);
|
|
234
|
+
}, 30_000);
|
|
235
|
+
|
|
236
|
+
test("a throw before any mutation deletes the row; a throw after a write replays 500", async () => {
|
|
237
|
+
let early = 0;
|
|
238
|
+
const earlyApp = await start("idem-early", [
|
|
239
|
+
chargeBinding(() => {
|
|
240
|
+
early += 1;
|
|
241
|
+
throw new Error("before");
|
|
242
|
+
}),
|
|
243
|
+
]);
|
|
244
|
+
expect((await earlyApp.fetch(post({ n: 1 }))).status).toBe(500);
|
|
245
|
+
const earlyRetry = await earlyApp.fetch(post({ n: 1 }));
|
|
246
|
+
expect(earlyRetry.status).toBe(500);
|
|
247
|
+
expect(earlyRetry.headers.get("idempotent-replayed")).toBeNull();
|
|
248
|
+
expect(early).toBe(2);
|
|
249
|
+
|
|
250
|
+
const box = store.kv("box");
|
|
251
|
+
let writes = 0;
|
|
252
|
+
const lateApp = await start(
|
|
253
|
+
"idem-late",
|
|
254
|
+
[
|
|
255
|
+
{
|
|
256
|
+
trigger: http.post("/charge", { in: z.object({ n: z.number() }) }),
|
|
257
|
+
flow: flow("pay.charge", {
|
|
258
|
+
effects: { writes: ["kv:box"] },
|
|
259
|
+
do: async (_input, fx) => {
|
|
260
|
+
writes += 1;
|
|
261
|
+
await fx.store(box).set("k", "v");
|
|
262
|
+
throw new Error("after");
|
|
263
|
+
},
|
|
264
|
+
}) as AnyFlowDef,
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
undefined,
|
|
268
|
+
[box],
|
|
269
|
+
);
|
|
270
|
+
const first = await lateApp.fetch(post({ n: 1 }));
|
|
271
|
+
const second = await lateApp.fetch(post({ n: 1 }));
|
|
272
|
+
expect(first.status).toBe(500);
|
|
273
|
+
expect(second.status).toBe(500);
|
|
274
|
+
expect(second.headers.get("idempotent-replayed")).toBe("true");
|
|
275
|
+
expect(writes).toBe(1);
|
|
276
|
+
}, 30_000);
|
|
277
|
+
|
|
278
|
+
test("a forfeited lease re-executes a non-durable flow and resumes a durable one", async () => {
|
|
279
|
+
const memory = createMemoryJournalStore();
|
|
280
|
+
let runs = 0;
|
|
281
|
+
let release!: () => void;
|
|
282
|
+
const hold = new Promise<void>((resolve) => {
|
|
283
|
+
release = resolve;
|
|
284
|
+
});
|
|
285
|
+
const plain = await start(
|
|
286
|
+
"idem-reclaim",
|
|
287
|
+
[
|
|
288
|
+
chargeBinding(async () => {
|
|
289
|
+
runs += 1;
|
|
290
|
+
await hold;
|
|
291
|
+
return { n: runs };
|
|
292
|
+
}),
|
|
293
|
+
],
|
|
294
|
+
journalOf(memory, 80),
|
|
295
|
+
);
|
|
296
|
+
const first = plain.fetch(post({ n: 1 }));
|
|
297
|
+
expect(await waitFor(() => runs === 1)).toBe(true);
|
|
298
|
+
const row = await memory.idempotency!.read({
|
|
299
|
+
tenant: "",
|
|
300
|
+
principal: "anon",
|
|
301
|
+
flow: "pay.charge",
|
|
302
|
+
key: KEY,
|
|
303
|
+
});
|
|
304
|
+
expect(row?.status).toBe("in_progress");
|
|
305
|
+
expect(await memory.idempotency!.forfeit(row!, row!.claimToken)).toBe(true);
|
|
306
|
+
const second = plain.fetch(post({ n: 1 }));
|
|
307
|
+
expect(await waitFor(() => runs === 2)).toBe(true);
|
|
308
|
+
release();
|
|
309
|
+
expect((await first).status).toBe(200);
|
|
310
|
+
expect((await second).status).toBe(200);
|
|
311
|
+
|
|
312
|
+
const durableStore = createMemoryJournalStore();
|
|
313
|
+
const step1: string[] = [];
|
|
314
|
+
const blockers: Array<() => void> = [];
|
|
315
|
+
const durable = await start(
|
|
316
|
+
"idem-durable",
|
|
317
|
+
[
|
|
318
|
+
{
|
|
319
|
+
trigger: http.post("/charge", { in: z.object({ n: z.number() }) }),
|
|
320
|
+
flow: flow("pay.charge", {
|
|
321
|
+
durable: true,
|
|
322
|
+
effects: { writes: ["sql:payments"] },
|
|
323
|
+
do: async (_input, fx) => {
|
|
324
|
+
await fx.step("create-intent", () => {
|
|
325
|
+
step1.push("step1");
|
|
326
|
+
return { id: "pi_1" };
|
|
327
|
+
});
|
|
328
|
+
await fx.step("mid-flight", async () => {
|
|
329
|
+
await new Promise<void>((resolve) => {
|
|
330
|
+
blockers.push(resolve);
|
|
331
|
+
});
|
|
332
|
+
return "done";
|
|
333
|
+
});
|
|
334
|
+
return { n: 1 };
|
|
335
|
+
},
|
|
336
|
+
}) as AnyFlowDef,
|
|
337
|
+
},
|
|
338
|
+
],
|
|
339
|
+
journalOf(durableStore, 80),
|
|
340
|
+
);
|
|
341
|
+
const hung = durable.fetch(post({ n: 1 }));
|
|
342
|
+
expect(await waitFor(() => blockers.length === 1)).toBe(true);
|
|
343
|
+
const held = await durableStore.idempotency!.read({
|
|
344
|
+
tenant: "",
|
|
345
|
+
principal: "anon",
|
|
346
|
+
flow: "pay.charge",
|
|
347
|
+
key: KEY,
|
|
348
|
+
});
|
|
349
|
+
expect(await durableStore.idempotency!.forfeit(held!, held!.claimToken)).toBe(true);
|
|
350
|
+
await Bun.sleep(120);
|
|
351
|
+
const resumed = durable.fetch(post({ n: 1 }));
|
|
352
|
+
expect(await waitFor(() => blockers.length === 2)).toBe(true);
|
|
353
|
+
expect(step1).toEqual(["step1"]);
|
|
354
|
+
blockers[0]!();
|
|
355
|
+
blockers[1]!();
|
|
356
|
+
expect((await hung).status).toBe(200);
|
|
357
|
+
expect((await resumed).status).toBe(200);
|
|
358
|
+
expect(step1).toEqual(["step1"]);
|
|
359
|
+
}, 30_000);
|
|
360
|
+
|
|
361
|
+
test("an expired ttl runs again; streams and reads ignore the key; required demands it", async () => {
|
|
362
|
+
let runs = 0;
|
|
363
|
+
const ttlApp = await start("idem-ttl", [
|
|
364
|
+
{
|
|
365
|
+
trigger: http.post("/charge", { in: z.object({ n: z.number() }) }),
|
|
366
|
+
flow: flow("pay.charge", {
|
|
367
|
+
effects: { writes: ["sql:payments"] },
|
|
368
|
+
idempotency: { ttl: "1ms" },
|
|
369
|
+
do: () => {
|
|
370
|
+
runs += 1;
|
|
371
|
+
return { n: runs };
|
|
372
|
+
},
|
|
373
|
+
}) as AnyFlowDef,
|
|
374
|
+
},
|
|
375
|
+
]);
|
|
376
|
+
expect((await ttlApp.fetch(post({ n: 1 }))).status).toBe(200);
|
|
377
|
+
await Bun.sleep(20);
|
|
378
|
+
expect((await ttlApp.fetch(post({ n: 1 }))).status).toBe(200);
|
|
379
|
+
expect(runs).toBe(2);
|
|
380
|
+
|
|
381
|
+
let reads = 0;
|
|
382
|
+
const readApp = await start("idem-read", [
|
|
383
|
+
{
|
|
384
|
+
trigger: http.get("/charge"),
|
|
385
|
+
flow: flow("pay.read", {
|
|
386
|
+
effects: { reads: ["sql:payments"] },
|
|
387
|
+
cache: false,
|
|
388
|
+
do: () => {
|
|
389
|
+
reads += 1;
|
|
390
|
+
return { n: reads };
|
|
391
|
+
},
|
|
392
|
+
}) as AnyFlowDef,
|
|
393
|
+
},
|
|
394
|
+
]);
|
|
395
|
+
const get = () =>
|
|
396
|
+
new Request("http://localhost/charge", { headers: { "idempotency-key": KEY } });
|
|
397
|
+
await readApp.fetch(get());
|
|
398
|
+
await readApp.fetch(get());
|
|
399
|
+
expect(reads).toBe(2);
|
|
400
|
+
|
|
401
|
+
let streamed = 0;
|
|
402
|
+
const streamApp = await start("idem-stream", [
|
|
403
|
+
{
|
|
404
|
+
trigger: http.post("/charge", { in: z.object({ n: z.number() }) }),
|
|
405
|
+
flow: flow("pay.charge", {
|
|
406
|
+
stream: true,
|
|
407
|
+
effects: { writes: ["sql:payments"] },
|
|
408
|
+
do: () => {
|
|
409
|
+
streamed += 1;
|
|
410
|
+
return { n: streamed };
|
|
411
|
+
},
|
|
412
|
+
}) as AnyFlowDef,
|
|
413
|
+
},
|
|
414
|
+
]);
|
|
415
|
+
await streamApp.fetch(post({ n: 1 }));
|
|
416
|
+
await streamApp.fetch(post({ n: 1 }));
|
|
417
|
+
expect(streamed).toBe(2);
|
|
418
|
+
|
|
419
|
+
let requiredRuns = 0;
|
|
420
|
+
const requiredApp = await start("idem-required", [
|
|
421
|
+
{
|
|
422
|
+
trigger: http.post("/charge", { in: z.object({ n: z.number() }) }),
|
|
423
|
+
flow: flow("pay.charge", {
|
|
424
|
+
effects: { writes: ["sql:payments"] },
|
|
425
|
+
idempotency: "required",
|
|
426
|
+
do: () => {
|
|
427
|
+
requiredRuns += 1;
|
|
428
|
+
return { n: 1 };
|
|
429
|
+
},
|
|
430
|
+
}) as AnyFlowDef,
|
|
431
|
+
},
|
|
432
|
+
]);
|
|
433
|
+
const missing = await requiredApp.fetch(post({ n: 1 }, false));
|
|
434
|
+
expect(missing.status).toBe(400);
|
|
435
|
+
expect(((await missing.json()) as { error: { code: string } }).error.code).toBe(
|
|
436
|
+
"IdempotencyKeyMissing",
|
|
437
|
+
);
|
|
438
|
+
expect(requiredRuns).toBe(0);
|
|
439
|
+
const bad = await requiredApp.fetch(post({ n: 1 }, "short"));
|
|
440
|
+
expect(bad.status).toBe(400);
|
|
441
|
+
expect(((await bad.json()) as { error: { code: string } }).error.code).toBe(
|
|
442
|
+
"IdempotencyKeyInvalid",
|
|
443
|
+
);
|
|
444
|
+
}, 30_000);
|
|
445
|
+
|
|
446
|
+
test("two principals with the same key are two records", async () => {
|
|
447
|
+
let runs = 0;
|
|
448
|
+
const binding = chargeBinding(() => {
|
|
449
|
+
runs += 1;
|
|
450
|
+
return { n: runs };
|
|
451
|
+
});
|
|
452
|
+
const memory = createMemoryJournalStore();
|
|
453
|
+
const app = await start("idem-principals", [binding], journalOf(memory));
|
|
454
|
+
const call = (userId: string) =>
|
|
455
|
+
app.execute(binding.flow, { n: 1 }, binding.trigger, {
|
|
456
|
+
request: post({ n: 1 }),
|
|
457
|
+
validated: true,
|
|
458
|
+
principal: { plane: "user", userId, scopes: new Set(), verified: true },
|
|
459
|
+
});
|
|
460
|
+
expect((await call("a")).output).toEqual({ n: 1 });
|
|
461
|
+
expect((await call("b")).output).toEqual({ n: 2 });
|
|
462
|
+
expect(runs).toBe(2);
|
|
463
|
+
expect(
|
|
464
|
+
await memory.idempotency!.read({
|
|
465
|
+
tenant: "",
|
|
466
|
+
principal: "user:a",
|
|
467
|
+
flow: "pay.charge",
|
|
468
|
+
key: KEY,
|
|
469
|
+
}),
|
|
470
|
+
).toBeDefined();
|
|
471
|
+
expect(
|
|
472
|
+
await memory.idempotency!.read({
|
|
473
|
+
tenant: "",
|
|
474
|
+
principal: "user:b",
|
|
475
|
+
flow: "pay.charge",
|
|
476
|
+
key: KEY,
|
|
477
|
+
}),
|
|
478
|
+
).toBeDefined();
|
|
479
|
+
}, 30_000);
|
|
480
|
+
|
|
481
|
+
test("a client timeout leaves do running, then a 409 waits and the replay arrives", async () => {
|
|
482
|
+
let runs = 0;
|
|
483
|
+
let aborted = 0;
|
|
484
|
+
let release!: () => void;
|
|
485
|
+
const hold = new Promise<void>((resolve) => {
|
|
486
|
+
release = resolve;
|
|
487
|
+
});
|
|
488
|
+
const memory = createMemoryJournalStore();
|
|
489
|
+
const app = await start(
|
|
490
|
+
"idem-timeout",
|
|
491
|
+
[
|
|
492
|
+
{
|
|
493
|
+
trigger: http.post("/charge", { in: z.object({ n: z.number() }) }),
|
|
494
|
+
flow: flow("pay.charge", {
|
|
495
|
+
effects: { writes: ["sql:payments"] },
|
|
496
|
+
do: async (_input, fx) => {
|
|
497
|
+
runs += 1;
|
|
498
|
+
if (fx.signal.aborted) aborted += 1;
|
|
499
|
+
fx.signal.addEventListener("abort", () => {
|
|
500
|
+
aborted += 1;
|
|
501
|
+
});
|
|
502
|
+
await hold;
|
|
503
|
+
return { ok: true as const };
|
|
504
|
+
},
|
|
505
|
+
}) as AnyFlowDef,
|
|
506
|
+
},
|
|
507
|
+
],
|
|
508
|
+
journalOf(memory, 400),
|
|
509
|
+
);
|
|
510
|
+
type PayApp = AppOf<{
|
|
511
|
+
pay: {
|
|
512
|
+
charge: { in: { n: number }; out: { ok: true }; errors: Record<string, never> };
|
|
513
|
+
};
|
|
514
|
+
}>;
|
|
515
|
+
const api = createClient<PayApp>("http://localhost", {
|
|
516
|
+
retry: { retries: 2, delay: 1, backoff: 1 },
|
|
517
|
+
timeout: 300,
|
|
518
|
+
routes: { "pay.charge": { method: "POST", path: "/charge" } },
|
|
519
|
+
fetch: (input, init) => {
|
|
520
|
+
const href =
|
|
521
|
+
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
522
|
+
const pending = app.fetch(new Request(href, init));
|
|
523
|
+
const signal = init?.signal;
|
|
524
|
+
if (signal == null) return pending;
|
|
525
|
+
return new Promise((resolve, reject) => {
|
|
526
|
+
pending.then((res) => {
|
|
527
|
+
if (res.status === 409) release();
|
|
528
|
+
resolve(res);
|
|
529
|
+
}, reject);
|
|
530
|
+
if (signal.aborted) {
|
|
531
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
signal.addEventListener("abort", () => {
|
|
535
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
const result = await api.pay.charge({ n: 1 });
|
|
541
|
+
expect(result.error).toBeNull();
|
|
542
|
+
expect(result.data).toEqual({ ok: true });
|
|
543
|
+
expect(result.meta?.idempotentReplayed).toBe(true);
|
|
544
|
+
expect(runs).toBe(1);
|
|
545
|
+
expect(aborted).toBe(0);
|
|
546
|
+
}, 30_000);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
async function waitFor(cond: () => boolean, timeoutMs = 2_000): Promise<boolean> {
|
|
550
|
+
const deadline = Date.now() + timeoutMs;
|
|
551
|
+
while (Date.now() < deadline) {
|
|
552
|
+
if (cond()) return true;
|
|
553
|
+
await Bun.sleep(10);
|
|
554
|
+
}
|
|
555
|
+
return cond();
|
|
556
|
+
}
|