okengine 0.3.5 → 0.4.3
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 +6 -0
- package/manifest.v1.schema.json +43 -1
- package/package.json +27 -12
- package/site/content/docs/ai/skills.mdx +11 -7
- package/site/content/docs/console/vault.mdx +4 -0
- package/site/content/docs/elements/ai.mdx +2 -0
- package/site/content/docs/elements/channel.mdx +7 -4
- package/site/content/docs/elements/clock.mdx +2 -4
- package/site/content/docs/elements/flow.mdx +19 -10
- package/site/content/docs/elements/gate.mdx +18 -11
- package/site/content/docs/elements/signal.mdx +9 -12
- package/site/content/docs/elements/store.mdx +69 -8
- package/site/content/docs/elements/vault.mdx +11 -12
- package/site/content/docs/get-started/basic-usage.mdx +76 -41
- package/site/content/docs/get-started/installation.mdx +95 -43
- package/site/content/docs/get-started/introduction.mdx +128 -75
- package/site/content/docs/get-started/meta.json +1 -1
- package/site/content/docs/get-started/why.mdx +141 -0
- package/site/content/docs/plugins/ip-allowlist.mdx +1 -2
- package/site/content/docs/plugins/security-headers.mdx +1 -1
- package/site/content/docs/reference/configuration.mdx +2 -2
- package/site/content/docs/reference/environment-variables.mdx +10 -0
- package/site/content/docs/reference/fx.mdx +56 -3
- package/site/content/docs/reference/plugins.mdx +18 -18
- package/src/cli/competitor-mention-removal.test.ts +117 -0
- package/src/cli/dev.ts +20 -0
- package/src/cli/meilisearch-local.test.ts +69 -0
- package/src/cli/meilisearch-local.ts +188 -0
- package/src/compiler/extract.test.ts +63 -0
- package/src/compiler/extract.ts +36 -15
- package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +5 -1
- package/src/console/server/channels.ts +2 -0
- package/src/console/server/clock.ts +3 -0
- package/src/console/server/flows.ts +13 -0
- package/src/console/server/gates.ts +2 -0
- package/src/console/server/plugins.ts +20 -1
- package/src/console/server/signals.ts +5 -0
- package/src/console/server/store.test.ts +17 -0
- package/src/console/server/store.ts +35 -1
- package/src/console/ui/channels/types.ts +1 -0
- package/src/console/ui/clock/types.ts +1 -0
- package/src/console/ui/display.test.ts +14 -0
- package/src/console/ui/display.ts +9 -0
- package/src/console/ui/dist/assets/index-CjxwRGVv.js +10 -0
- package/src/console/ui/dist/assets/panel-access-BGv45snf.js +64 -0
- package/src/console/ui/dist/assets/{panel-ai-D_m6WQI8.js → panel-ai-B2S7LEii.js} +1 -1
- package/src/console/ui/dist/assets/{panel-architecture-CKnXFyUx.js → panel-architecture-D7UJh91v.js} +1 -1
- package/src/console/ui/dist/assets/panel-channels-9T3ybqRu.js +1 -0
- package/src/console/ui/dist/assets/panel-clock-Cb1UXGRQ.js +1 -0
- package/src/console/ui/dist/assets/{panel-diff-cdonmH8c.js → panel-diff-DmYbKWmN.js} +1 -1
- package/src/console/ui/dist/assets/panel-flows-PiHwT55z.js +48 -0
- package/src/console/ui/dist/assets/panel-gates-BQGYXvjT.js +1 -0
- package/src/console/ui/dist/assets/panel-overview-BBnRO18l.js +1 -0
- package/src/console/ui/dist/assets/panel-plugins-D0PsmVw2.js +1 -0
- package/src/console/ui/dist/assets/panel-runs-CWuRDe0r.js +1 -0
- package/src/console/ui/dist/assets/panel-signals-Bbg4ewpP.js +1 -0
- package/src/console/ui/dist/assets/panel-store-CPCbsDRa.js +1 -0
- package/src/console/ui/dist/assets/panel-traces-DVAzuA_S.js +1 -0
- package/src/console/ui/dist/assets/panel-vault-D1_MvOmo.js +1 -0
- package/src/console/ui/dist/assets/{rolldown-runtime-CNC7AqOf.js → rolldown-runtime-B0Z9INg1.js} +1 -1
- package/src/console/ui/dist/index.html +2 -2
- package/src/console/ui/gates/types.ts +1 -0
- package/src/console/ui/plugins/fixture.ts +7 -0
- package/src/console/ui/plugins/types.ts +3 -0
- package/src/console/ui/shell/client.ts +3 -0
- package/src/console/ui/shell/panels/channels/ChannelsPanel.tsx +7 -2
- package/src/console/ui/shell/panels/clock/ClockPanel.tsx +9 -2
- package/src/console/ui/shell/panels/gates/GatesPanel.tsx +12 -5
- package/src/console/ui/shell/panels/plugins/PluginsPanel.tsx +18 -4
- package/src/console/ui/shell/panels/signals/SignalsPanel.tsx +13 -2
- package/src/console/ui/shell/panels/store/StorePanel.tsx +11 -4
- package/src/console/ui/shell/panels/vault/VaultPanel.tsx +9 -3
- package/src/console/ui/signals/types.ts +1 -0
- package/src/console/ui/store/fixture.ts +5 -0
- package/src/console/ui/store/types.ts +2 -0
- package/src/docker/compose.ts +5 -0
- package/src/docker/docker.test.ts +41 -0
- package/src/docker/recipes/index.ts +10 -2
- package/src/docker/recipes/meilisearch.ts +31 -0
- package/src/drivers/conformance.test.ts +26 -0
- package/src/drivers/conformance.ts +40 -3
- package/src/drivers/drizzle-dialect.test.ts +4 -0
- package/src/drivers/drizzle-dialect.ts +8 -4
- package/src/drivers/index.ts +18 -2
- package/src/drivers/libsql.ts +179 -0
- package/src/drivers/meilisearch.integration.test.ts +77 -0
- package/src/drivers/meilisearch.test.ts +181 -0
- package/src/drivers/meilisearch.ts +208 -0
- package/src/drivers/memory.ts +4 -4
- package/src/drivers/pglite.ts +79 -0
- package/src/drivers/pgvector.ts +60 -25
- package/src/drivers/types.ts +106 -16
- package/src/drivers/vault-driver-removal.test.ts +6 -0
- package/src/drivers/vault-types.ts +4 -4
- package/src/elements/ai/runtime.ts +6 -0
- package/src/elements/ai.test.ts +22 -0
- package/src/elements/channel/declare.ts +5 -0
- package/src/elements/clock/declare.ts +5 -0
- package/src/elements/clock/durable.ts +7 -1
- package/src/elements/gate/declare.ts +28 -5
- package/src/elements/gate.ts +1 -0
- package/src/elements/signal/declare.ts +5 -0
- package/src/elements/store/declare.ts +10 -2
- package/src/elements/store/index-boot.test.ts +299 -0
- package/src/elements/store/runtime.ts +110 -17
- package/src/elements/store/schema-decl.ts +7 -0
- package/src/elements/store.ts +2 -0
- package/src/elements/vault.test.ts +27 -4
- package/src/elements/vault.ts +1 -1
- package/src/index.ts +4 -0
- package/src/kernel/abort-scope.ts +116 -0
- package/src/kernel/app.ts +12 -2
- package/src/kernel/boot-bind/store.test.ts +68 -1
- package/src/kernel/boot-bind/store.ts +92 -2
- package/src/kernel/concurrency.test.ts +294 -0
- package/src/kernel/concurrency.ts +220 -0
- package/src/kernel/flow.ts +9 -0
- package/src/kernel/fx.test.ts +23 -2
- package/src/kernel/fx.ts +80 -4
- package/src/kernel/index.ts +23 -0
- package/src/kernel/journal.ts +9 -0
- package/src/kernel/plugin/capabilities.test.ts +18 -0
- package/src/kernel/plugin.ts +11 -3
- package/src/kernel/redacted.ts +74 -0
- package/src/kernel/registry.ts +29 -6
- package/src/kernel/router.ts +3 -3
- package/src/manifest/types.ts +21 -0
- package/src/release/measure.ts +4 -0
- package/src/test/provisions.integration.test.ts +1 -1
- package/site/content/docs/get-started/comparison.mdx +0 -65
- package/src/console/ui/dist/assets/index-BWo8R7NR.js +0 -10
- package/src/console/ui/dist/assets/panel-access-C0J2D-a2.js +0 -64
- package/src/console/ui/dist/assets/panel-channels-BOmQ-onL.js +0 -1
- package/src/console/ui/dist/assets/panel-clock-giAq0Ccv.js +0 -1
- package/src/console/ui/dist/assets/panel-flows-DlCU5zjA.js +0 -45
- package/src/console/ui/dist/assets/panel-gates-XclZxWD5.js +0 -1
- package/src/console/ui/dist/assets/panel-overview-BznEOTnb.js +0 -1
- package/src/console/ui/dist/assets/panel-plugins-CcGM1g64.js +0 -1
- package/src/console/ui/dist/assets/panel-runs-CGWNHLR4.js +0 -1
- package/src/console/ui/dist/assets/panel-signals-CNywkdak.js +0 -1
- package/src/console/ui/dist/assets/panel-store-KmTbFHMH.js +0 -1
- package/src/console/ui/dist/assets/panel-traces-DBLx2ilD.js +0 -1
- package/src/console/ui/dist/assets/panel-vault-CEnFc0dk.js +0 -1
- package/src/drivers/vault-infisical.ts +0 -57
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { currentAbortSignal, isAbortError, withAbortSignal } from "./abort-scope.ts";
|
|
4
|
+
import { createMemoryJournalStore } from "./journal.ts";
|
|
5
|
+
import { createFx, createFxContext } from "./fx.ts";
|
|
6
|
+
import { flow } from "./flow.ts";
|
|
7
|
+
import { runDurable } from "../elements/clock/durable.ts";
|
|
8
|
+
import { fxAll, fxRace } from "./concurrency.ts";
|
|
9
|
+
|
|
10
|
+
describe("fx.all — structured concurrency", () => {
|
|
11
|
+
test("resolves tuple results in order", async () => {
|
|
12
|
+
const fx = createFx({ flow: "t", effects: {} });
|
|
13
|
+
const out = await fx.all([async () => 1, async () => "a", () => true]);
|
|
14
|
+
expect(out).toEqual([1, "a", true]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("on failure aborts sibling branches via ambient signal", async () => {
|
|
18
|
+
const fx = createFx({ flow: "t", effects: {} });
|
|
19
|
+
let siblingAborted = false;
|
|
20
|
+
let loserSawAbort = false;
|
|
21
|
+
|
|
22
|
+
await expect(
|
|
23
|
+
fx.all([
|
|
24
|
+
async () => {
|
|
25
|
+
await new Promise<void>((_resolve, reject) => {
|
|
26
|
+
const signal = fx.signal;
|
|
27
|
+
if (signal.aborted) {
|
|
28
|
+
loserSawAbort = true;
|
|
29
|
+
reject(signal.reason ?? new Error("aborted"));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
signal.addEventListener(
|
|
33
|
+
"abort",
|
|
34
|
+
() => {
|
|
35
|
+
siblingAborted = true;
|
|
36
|
+
loserSawAbort = true;
|
|
37
|
+
reject(new DOMException("aborted", "AbortError"));
|
|
38
|
+
},
|
|
39
|
+
{ once: true },
|
|
40
|
+
);
|
|
41
|
+
// Stay pending until aborted.
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
async () => {
|
|
45
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
46
|
+
throw new Error("boom");
|
|
47
|
+
},
|
|
48
|
+
]),
|
|
49
|
+
).rejects.toThrow("boom");
|
|
50
|
+
|
|
51
|
+
expect(siblingAborted || loserSawAbort).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("empty all resolves to []", async () => {
|
|
55
|
+
await expect(fxAll([])).resolves.toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("fx.race — structured concurrency", () => {
|
|
60
|
+
test("winner wins and loser sees abort", async () => {
|
|
61
|
+
const fx = createFx({ flow: "t", effects: {} });
|
|
62
|
+
let loserAborted = false;
|
|
63
|
+
|
|
64
|
+
const value = await fx.race([
|
|
65
|
+
async () => {
|
|
66
|
+
await new Promise<void>((_resolve, reject) => {
|
|
67
|
+
fx.signal.addEventListener(
|
|
68
|
+
"abort",
|
|
69
|
+
() => {
|
|
70
|
+
loserAborted = true;
|
|
71
|
+
reject(new DOMException("aborted", "AbortError"));
|
|
72
|
+
},
|
|
73
|
+
{ once: true },
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
return "slow";
|
|
77
|
+
},
|
|
78
|
+
async () => {
|
|
79
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
80
|
+
return "fast";
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
expect(value).toBe("fast");
|
|
85
|
+
// Give the abort listener a tick.
|
|
86
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
87
|
+
expect(loserAborted).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("empty race throws", async () => {
|
|
91
|
+
await expect(fxRace([])).rejects.toThrow(/empty/);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("fx.using — scoped cleanup", () => {
|
|
96
|
+
test("release runs on success and on thrown error", async () => {
|
|
97
|
+
const fx = createFx({ flow: "t", effects: {} });
|
|
98
|
+
const released: string[] = [];
|
|
99
|
+
|
|
100
|
+
const ok = await fx.using(
|
|
101
|
+
async () => "res-a",
|
|
102
|
+
(r) => {
|
|
103
|
+
released.push(`ok:${r}`);
|
|
104
|
+
},
|
|
105
|
+
async (r) => r.toUpperCase(),
|
|
106
|
+
);
|
|
107
|
+
expect(ok).toBe("RES-A");
|
|
108
|
+
expect(released).toEqual(["ok:res-a"]);
|
|
109
|
+
|
|
110
|
+
await expect(
|
|
111
|
+
fx.using(
|
|
112
|
+
async () => "res-b",
|
|
113
|
+
async (r) => {
|
|
114
|
+
released.push(`err:${r}`);
|
|
115
|
+
},
|
|
116
|
+
async () => {
|
|
117
|
+
throw new Error("boom");
|
|
118
|
+
},
|
|
119
|
+
),
|
|
120
|
+
).rejects.toThrow("boom");
|
|
121
|
+
expect(released).toEqual(["ok:res-a", "err:res-b"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("release runs when a sibling fx.race winner aborts mid-use", async () => {
|
|
125
|
+
const fx = createFx({ flow: "t", effects: {} });
|
|
126
|
+
let released = false;
|
|
127
|
+
|
|
128
|
+
const winner = await fx.race([
|
|
129
|
+
() =>
|
|
130
|
+
fx.using(
|
|
131
|
+
async () => "lock",
|
|
132
|
+
() => {
|
|
133
|
+
released = true;
|
|
134
|
+
},
|
|
135
|
+
() =>
|
|
136
|
+
new Promise<never>(() => {
|
|
137
|
+
// Stay pending until ambient abort releases us.
|
|
138
|
+
}),
|
|
139
|
+
),
|
|
140
|
+
async () => {
|
|
141
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
142
|
+
return "fast";
|
|
143
|
+
},
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
expect(winner).toBe("fast");
|
|
147
|
+
// Let the abort listener settle.
|
|
148
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
149
|
+
expect(released).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("fx.retry", () => {
|
|
154
|
+
test("retries then succeeds", async () => {
|
|
155
|
+
const fx = createFx({ flow: "t", effects: {} });
|
|
156
|
+
let n = 0;
|
|
157
|
+
const value = await fx.retry(
|
|
158
|
+
async () => {
|
|
159
|
+
n += 1;
|
|
160
|
+
if (n < 3) throw new Error("transient");
|
|
161
|
+
return "ok";
|
|
162
|
+
},
|
|
163
|
+
{ retries: 3, delay: 0, jitter: false },
|
|
164
|
+
);
|
|
165
|
+
expect(value).toBe("ok");
|
|
166
|
+
expect(n).toBe(3);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("does not retry AbortError", async () => {
|
|
170
|
+
const fx = createFx({ flow: "t", effects: {} });
|
|
171
|
+
let n = 0;
|
|
172
|
+
await expect(
|
|
173
|
+
fx.retry(
|
|
174
|
+
async () => {
|
|
175
|
+
n += 1;
|
|
176
|
+
throw new DOMException("aborted", "AbortError");
|
|
177
|
+
},
|
|
178
|
+
{ retries: 3, delay: 0 },
|
|
179
|
+
),
|
|
180
|
+
).rejects.toMatchObject({ name: "AbortError" });
|
|
181
|
+
expect(n).toBe(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("honors when predicate", async () => {
|
|
185
|
+
const fx = createFx({ flow: "t", effects: {} });
|
|
186
|
+
let n = 0;
|
|
187
|
+
await expect(
|
|
188
|
+
fx.retry(
|
|
189
|
+
async () => {
|
|
190
|
+
n += 1;
|
|
191
|
+
throw new Error("nope");
|
|
192
|
+
},
|
|
193
|
+
{ retries: 5, delay: 0, when: () => false },
|
|
194
|
+
),
|
|
195
|
+
).rejects.toThrow("nope");
|
|
196
|
+
expect(n).toBe(1);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("ambient abort signal", () => {
|
|
201
|
+
test("outside scope is never aborted", () => {
|
|
202
|
+
const fx = createFx({ flow: "t", effects: {} });
|
|
203
|
+
expect(fx.signal.aborted).toBe(false);
|
|
204
|
+
expect(isAbortError(new DOMException("x", "AbortError"))).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("withAbortSignal installs ambient signal", async () => {
|
|
208
|
+
const ctrl = new AbortController();
|
|
209
|
+
await withAbortSignal(ctrl.signal, async () => {
|
|
210
|
+
expect(currentAbortSignal()).toBe(ctrl.signal);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("retry + durable journal", () => {
|
|
216
|
+
test("fx.retry inside fx.step does not re-run completed step on resume", async () => {
|
|
217
|
+
const journalStore = createMemoryJournalStore();
|
|
218
|
+
let attempts = 0;
|
|
219
|
+
let crash = true;
|
|
220
|
+
|
|
221
|
+
const f = flow({
|
|
222
|
+
name: "pay.retry",
|
|
223
|
+
durable: true,
|
|
224
|
+
do: async (_input, fx) => {
|
|
225
|
+
const charged = await fx.step("charge", () =>
|
|
226
|
+
fx.retry(
|
|
227
|
+
async () => {
|
|
228
|
+
attempts += 1;
|
|
229
|
+
return { id: "ch_1" };
|
|
230
|
+
},
|
|
231
|
+
{ retries: 2, delay: 0 },
|
|
232
|
+
),
|
|
233
|
+
);
|
|
234
|
+
if (crash) throw new Error("KILLED");
|
|
235
|
+
return charged;
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const first = await runDurable({ flow: f, journalStore });
|
|
240
|
+
expect(first.status).toBe("failed");
|
|
241
|
+
expect(attempts).toBe(1);
|
|
242
|
+
|
|
243
|
+
crash = false;
|
|
244
|
+
const second = await runDurable({
|
|
245
|
+
flow: f,
|
|
246
|
+
journalStore,
|
|
247
|
+
runId: first.status === "failed" ? first.runId : "",
|
|
248
|
+
});
|
|
249
|
+
expect(second.status).toBe("completed");
|
|
250
|
+
expect(attempts).toBe(1);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("flow({ retry }) reuses journal — completed steps do not re-run", async () => {
|
|
254
|
+
const journalStore = createMemoryJournalStore();
|
|
255
|
+
const calls: string[] = [];
|
|
256
|
+
let failOnce = true;
|
|
257
|
+
|
|
258
|
+
const f = flow({
|
|
259
|
+
name: "flow.retry",
|
|
260
|
+
durable: true,
|
|
261
|
+
retry: { retries: 2, delay: 0, jitter: false },
|
|
262
|
+
do: async (_input, fx) => {
|
|
263
|
+
await fx.step("a", () => {
|
|
264
|
+
calls.push("a");
|
|
265
|
+
return 1;
|
|
266
|
+
});
|
|
267
|
+
if (failOnce) {
|
|
268
|
+
failOnce = false;
|
|
269
|
+
throw new Error("transient");
|
|
270
|
+
}
|
|
271
|
+
return fx.step("b", () => {
|
|
272
|
+
calls.push("b");
|
|
273
|
+
return 2;
|
|
274
|
+
});
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const result = await runDurable({ flow: f, journalStore });
|
|
279
|
+
expect(result.status).toBe("completed");
|
|
280
|
+
// step a ran once; flow-level retry re-entered do but journal replayed a.
|
|
281
|
+
expect(calls).toEqual(["a", "b"]);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe("capability + concurrency compose", () => {
|
|
286
|
+
test("fx.all records effects from successful branches", async () => {
|
|
287
|
+
const { fx, ledger } = createFxContext({
|
|
288
|
+
flow: "parallel",
|
|
289
|
+
effects: { emits: ["a", "b"] },
|
|
290
|
+
});
|
|
291
|
+
await fx.all([() => fx.emit("a"), () => fx.emit("b")]);
|
|
292
|
+
expect(ledger.entries.map((e) => e.resource)).toEqual(["a", "b"]);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured concurrency and retry — plain Promise helpers for `fx`.
|
|
3
|
+
*
|
|
4
|
+
* No generators, no monadic wrapper. Branches are thunks so an AbortController
|
|
5
|
+
* can be installed before work starts. Retry backoff uses non-journaled sleep.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { parseDurationMs } from "../elements/clock/duration.ts";
|
|
9
|
+
import {
|
|
10
|
+
abortableSleep,
|
|
11
|
+
abortError,
|
|
12
|
+
currentAbortSignal,
|
|
13
|
+
isAbortError,
|
|
14
|
+
linkAbort,
|
|
15
|
+
withAbortSignal,
|
|
16
|
+
} from "./abort-scope.ts";
|
|
17
|
+
import { isJournalSuspend } from "./journal.ts";
|
|
18
|
+
|
|
19
|
+
/** A unit of work started under an abort scope. */
|
|
20
|
+
export type FxThunk<T> = () => T | Promise<T>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Retry policy — same shape as the client transport, with duration strings
|
|
24
|
+
* and optional full jitter (no Schedule DSL).
|
|
25
|
+
*/
|
|
26
|
+
export interface FxRetryOptions {
|
|
27
|
+
/** Extra attempts after the first (default 0). */
|
|
28
|
+
readonly retries?: number;
|
|
29
|
+
/** Initial delay: ms number or duration string (default 50). */
|
|
30
|
+
readonly delay?: number | string;
|
|
31
|
+
/** Multiplier applied after each retry (default 2). */
|
|
32
|
+
readonly backoff?: number;
|
|
33
|
+
/** Full jitter on delay (default true). */
|
|
34
|
+
readonly jitter?: boolean;
|
|
35
|
+
/** Predicate; default retries thrown errors except abort / journal suspend. */
|
|
36
|
+
readonly when?: (err: unknown) => boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Default retry filter — skip abort and durable-sleep park.
|
|
41
|
+
*
|
|
42
|
+
* @param err - Thrown value
|
|
43
|
+
*/
|
|
44
|
+
export function defaultRetryWhen(err: unknown): boolean {
|
|
45
|
+
if (isAbortError(err)) return false;
|
|
46
|
+
if (isJournalSuspend(err)) return false;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve a delay option to milliseconds.
|
|
52
|
+
*
|
|
53
|
+
* @param delay - Number or duration string
|
|
54
|
+
* @param fallback - Default ms
|
|
55
|
+
*/
|
|
56
|
+
export function resolveRetryDelayMs(delay: number | string | undefined, fallback = 50): number {
|
|
57
|
+
if (delay === undefined) return fallback;
|
|
58
|
+
if (typeof delay === "number") return Math.max(0, delay);
|
|
59
|
+
const parsed = parseDurationMs(delay);
|
|
60
|
+
return parsed > 0 ? parsed : fallback;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Run thunks in parallel; on first rejection abort siblings and rethrow.
|
|
65
|
+
*
|
|
66
|
+
* @param thunks - Work units (not already-started Promises)
|
|
67
|
+
*/
|
|
68
|
+
export async function fxAll<const T extends readonly unknown[]>(thunks: {
|
|
69
|
+
readonly [K in keyof T]: FxThunk<T[K]>;
|
|
70
|
+
}): Promise<{ -readonly [K in keyof T]: Awaited<T[K]> }> {
|
|
71
|
+
const list = thunks as readonly FxThunk<unknown>[];
|
|
72
|
+
if (list.length === 0) {
|
|
73
|
+
return [] as { -readonly [K in keyof T]: Awaited<T[K]> };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const parent = currentAbortSignal();
|
|
77
|
+
const controllers = list.map(() => new AbortController());
|
|
78
|
+
const unsubs = controllers.map((c) => linkAbort(parent, c));
|
|
79
|
+
|
|
80
|
+
let firstError: unknown;
|
|
81
|
+
const results = await Promise.allSettled(
|
|
82
|
+
list.map((thunk, i) =>
|
|
83
|
+
withAbortSignal(controllers[i]!.signal, async () => {
|
|
84
|
+
try {
|
|
85
|
+
return await thunk();
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (firstError === undefined) {
|
|
88
|
+
firstError = err;
|
|
89
|
+
for (let j = 0; j < controllers.length; j++) {
|
|
90
|
+
if (j !== i) controllers[j]!.abort(err);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
}),
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
for (const unsub of unsubs) unsub();
|
|
100
|
+
|
|
101
|
+
if (firstError !== undefined) throw firstError;
|
|
102
|
+
const values: unknown[] = [];
|
|
103
|
+
for (const r of results) {
|
|
104
|
+
if (r.status === "rejected") throw r.reason;
|
|
105
|
+
values.push(r.value);
|
|
106
|
+
}
|
|
107
|
+
return values as { -readonly [K in keyof T]: Awaited<T[K]> };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Race thunks; the first settle wins and siblings are aborted.
|
|
112
|
+
*
|
|
113
|
+
* @param thunks - Work units
|
|
114
|
+
*/
|
|
115
|
+
export async function fxRace<T>(thunks: ReadonlyArray<FxThunk<T>>): Promise<T> {
|
|
116
|
+
if (thunks.length === 0) {
|
|
117
|
+
throw new TypeError("fx.race: empty thunk list");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const parent = currentAbortSignal();
|
|
121
|
+
const controllers = thunks.map(() => new AbortController());
|
|
122
|
+
const unsubs = controllers.map((c) => linkAbort(parent, c));
|
|
123
|
+
|
|
124
|
+
return new Promise<T>((resolve, reject) => {
|
|
125
|
+
let done = false;
|
|
126
|
+
const settle = (ok: boolean, value: unknown): void => {
|
|
127
|
+
if (done) return;
|
|
128
|
+
done = true;
|
|
129
|
+
for (const c of controllers) {
|
|
130
|
+
if (!c.signal.aborted) c.abort();
|
|
131
|
+
}
|
|
132
|
+
for (const unsub of unsubs) unsub();
|
|
133
|
+
if (ok) resolve(value as T);
|
|
134
|
+
else reject(value);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < thunks.length; i++) {
|
|
138
|
+
const thunk = thunks[i]!;
|
|
139
|
+
const ctrl = controllers[i]!;
|
|
140
|
+
void withAbortSignal(ctrl.signal, () => Promise.resolve().then(thunk)).then(
|
|
141
|
+
(value) => settle(true, value),
|
|
142
|
+
(err: unknown) => settle(false, err),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Scope a resource to a unit of work — `release` runs exactly once when
|
|
150
|
+
* `use` settles or when the ambient abort signal fires, whichever comes
|
|
151
|
+
* first. Uses the same AbortSignal as {@link Fx.signal}; no second
|
|
152
|
+
* cancellation channel.
|
|
153
|
+
*
|
|
154
|
+
* Process-local only: do not journal acquire/release, and do not hold
|
|
155
|
+
* handles across durable park/resume — journal replay returns values and
|
|
156
|
+
* never re-enters step bodies.
|
|
157
|
+
*
|
|
158
|
+
* @param acquire - Open the resource
|
|
159
|
+
* @param release - Cleanup, always run
|
|
160
|
+
* @param use - Work with the resource
|
|
161
|
+
*/
|
|
162
|
+
export async function fxUsing<A, T>(
|
|
163
|
+
acquire: () => A | Promise<A>,
|
|
164
|
+
release: (resource: A) => void | Promise<void>,
|
|
165
|
+
use: (resource: A) => T | Promise<T>,
|
|
166
|
+
): Promise<T> {
|
|
167
|
+
const resource = await acquire();
|
|
168
|
+
const signal = currentAbortSignal();
|
|
169
|
+
let released = false;
|
|
170
|
+
|
|
171
|
+
const releaseOnce = async (): Promise<void> => {
|
|
172
|
+
if (released) return;
|
|
173
|
+
released = true;
|
|
174
|
+
await release(resource);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const onAbort = (): void => {
|
|
178
|
+
void releaseOnce();
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
if (signal.aborted) {
|
|
182
|
+
await releaseOnce();
|
|
183
|
+
throw abortError(signal.reason);
|
|
184
|
+
}
|
|
185
|
+
signal.addEventListener("abort", onAbort);
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
return await use(resource);
|
|
189
|
+
} finally {
|
|
190
|
+
signal.removeEventListener("abort", onAbort);
|
|
191
|
+
await releaseOnce();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Retry a thunk with exponential backoff and optional full jitter.
|
|
197
|
+
*
|
|
198
|
+
* @param fn - Operation
|
|
199
|
+
* @param opts - Retry policy
|
|
200
|
+
*/
|
|
201
|
+
export async function fxRetry<T>(fn: FxThunk<T>, opts: FxRetryOptions = {}): Promise<T> {
|
|
202
|
+
const retries = Math.max(0, opts.retries ?? 0);
|
|
203
|
+
const backoff = opts.backoff ?? 2;
|
|
204
|
+
const jitter = opts.jitter !== false;
|
|
205
|
+
const when = opts.when ?? defaultRetryWhen;
|
|
206
|
+
let delayMs = resolveRetryDelayMs(opts.delay, 50);
|
|
207
|
+
let attempt = 0;
|
|
208
|
+
|
|
209
|
+
for (;;) {
|
|
210
|
+
try {
|
|
211
|
+
return await fn();
|
|
212
|
+
} catch (err) {
|
|
213
|
+
if (attempt >= retries || !when(err)) throw err;
|
|
214
|
+
const wait = jitter ? Math.random() * delayMs : delayMs;
|
|
215
|
+
await abortableSleep(wait, currentAbortSignal());
|
|
216
|
+
delayMs *= backoff;
|
|
217
|
+
attempt += 1;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/kernel/flow.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Effects, FlowPlane, Slo } from "../manifest/types.ts";
|
|
10
10
|
import type { InferSchemaOutput, SchemaInput } from "../validation/standard-schema.ts";
|
|
11
|
+
import type { FxRetryOptions } from "./concurrency.ts";
|
|
11
12
|
import type { FlowFailure } from "./errors.ts";
|
|
12
13
|
import type { Fx } from "./fx.ts";
|
|
13
14
|
import type { HookFn, HookStage } from "./hooks.ts";
|
|
@@ -51,6 +52,11 @@ export interface FlowOptions<I = unknown, O = unknown, E extends FlowErrorMap =
|
|
|
51
52
|
readonly effects?: Effects;
|
|
52
53
|
/** Journal every effect call. */
|
|
53
54
|
readonly durable?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Retry the whole `do` body on thrown errors (same journal session when
|
|
57
|
+
* durable). Prefer {@link Fx.retry} inside {@link Fx.step} for fine control.
|
|
58
|
+
*/
|
|
59
|
+
readonly retry?: FxRetryOptions;
|
|
54
60
|
/** Live-query / push result. */
|
|
55
61
|
readonly live?: boolean;
|
|
56
62
|
/** Cache TTL (`true` = default policy, or a duration string). */
|
|
@@ -147,6 +153,8 @@ export interface FlowDef<
|
|
|
147
153
|
readonly effects: Effects | undefined;
|
|
148
154
|
/** Durability flag. */
|
|
149
155
|
readonly durable: boolean;
|
|
156
|
+
/** Whole-body retry policy (runtime). */
|
|
157
|
+
readonly retry: FxRetryOptions | undefined;
|
|
150
158
|
/** Live flag. */
|
|
151
159
|
readonly live: boolean;
|
|
152
160
|
/** Cache option. */
|
|
@@ -222,6 +230,7 @@ export function flow<Opts extends FlowOptions<any, any, any>>(
|
|
|
222
230
|
errors: (options.errors ?? undefined) as InferFlowErrors<Opts> | undefined,
|
|
223
231
|
effects: options.effects,
|
|
224
232
|
durable: options.durable ?? false,
|
|
233
|
+
retry: options.retry,
|
|
225
234
|
live: options.live ?? false,
|
|
226
235
|
cache: options.cache,
|
|
227
236
|
slo: options.slo,
|
package/src/kernel/fx.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type Fx,
|
|
10
10
|
type FxStubStoreHandle,
|
|
11
11
|
} from "./fx.ts";
|
|
12
|
+
import { Redacted } from "./redacted.ts";
|
|
12
13
|
|
|
13
14
|
/** Narrow stub handle for tests that exercise the in-memory store. */
|
|
14
15
|
function stub(fx: Fx, ref: string): FxStubStoreHandle {
|
|
@@ -99,7 +100,7 @@ describe("fx — effect ledger", () => {
|
|
|
99
100
|
await fx.emit("order-placed", {});
|
|
100
101
|
await fx.send("order-confirmed", { to: "u1" });
|
|
101
102
|
await fx.ask("triage@1", {});
|
|
102
|
-
expect(fx.vault("STRIPE_KEY")).toBe("sk_test");
|
|
103
|
+
expect(fx.vault("STRIPE_KEY").reveal()).toBe("sk_test");
|
|
103
104
|
await fx.call("payments.charge", {});
|
|
104
105
|
|
|
105
106
|
expect(ledger.entries).toHaveLength(7);
|
|
@@ -130,7 +131,7 @@ describe("fx — wholesale swap", () => {
|
|
|
130
131
|
sleep: async () => undefined,
|
|
131
132
|
},
|
|
132
133
|
vault() {
|
|
133
|
-
return "";
|
|
134
|
+
return new Redacted("");
|
|
134
135
|
},
|
|
135
136
|
cache: {
|
|
136
137
|
get: async () => undefined,
|
|
@@ -181,6 +182,26 @@ describe("fx — wholesale swap", () => {
|
|
|
181
182
|
async step(_name, fn) {
|
|
182
183
|
return fn();
|
|
183
184
|
},
|
|
185
|
+
get signal() {
|
|
186
|
+
return new AbortController().signal;
|
|
187
|
+
},
|
|
188
|
+
all(thunks) {
|
|
189
|
+
return Promise.all(thunks.map((t) => t())) as never;
|
|
190
|
+
},
|
|
191
|
+
race(thunks) {
|
|
192
|
+
return Promise.race(thunks.map((t) => t()));
|
|
193
|
+
},
|
|
194
|
+
retry(fn) {
|
|
195
|
+
return Promise.resolve().then(fn);
|
|
196
|
+
},
|
|
197
|
+
async using(acquire, release, use) {
|
|
198
|
+
const resource = await acquire();
|
|
199
|
+
try {
|
|
200
|
+
return await use(resource);
|
|
201
|
+
} finally {
|
|
202
|
+
await release(resource);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
184
205
|
};
|
|
185
206
|
|
|
186
207
|
// Any code that accepts `Fx` can run against a total replacement.
|