okengine 0.3.5 → 0.3.6
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 -0
- package/manifest.v1.schema.json +43 -1
- package/package.json +16 -1
- package/site/content/docs/ai/skills.mdx +9 -7
- package/site/content/docs/console/vault.mdx +4 -0
- package/site/content/docs/elements/channel.mdx +5 -4
- package/site/content/docs/elements/clock.mdx +1 -0
- package/site/content/docs/elements/flow.mdx +15 -0
- package/site/content/docs/elements/gate.mdx +16 -11
- package/site/content/docs/elements/signal.mdx +8 -7
- package/site/content/docs/elements/store.mdx +48 -8
- package/site/content/docs/elements/vault.mdx +1 -1
- package/site/content/docs/plugins/ip-allowlist.mdx +2 -2
- package/site/content/docs/reference/configuration.mdx +2 -2
- package/site/content/docs/reference/environment-variables.mdx +8 -0
- package/site/content/docs/reference/fx.mdx +40 -0
- package/site/content/docs/reference/plugins.mdx +18 -18
- package/src/compiler/extract.test.ts +63 -0
- package/src/compiler/extract.ts +36 -15
- 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 +24 -0
- 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-BWo8R7NR.js → index-CrKMmO__.js} +2 -2
- package/src/console/ui/dist/assets/panel-channels-DCDd4WAC.js +1 -0
- package/src/console/ui/dist/assets/panel-clock-DjGGFPzr.js +1 -0
- package/src/console/ui/dist/assets/panel-gates-B5eTE8XH.js +1 -0
- package/src/console/ui/dist/assets/{panel-overview-BznEOTnb.js → panel-overview-BsFvDdts.js} +1 -1
- package/src/console/ui/dist/assets/panel-plugins-Cj7DK1er.js +1 -0
- package/src/console/ui/dist/assets/{panel-runs-CGWNHLR4.js → panel-runs-C0gmnoYL.js} +1 -1
- package/src/console/ui/dist/assets/panel-signals-whmDXIg3.js +1 -0
- package/src/console/ui/dist/assets/panel-store-CEMHLvaw.js +1 -0
- package/src/console/ui/dist/assets/{panel-traces-DBLx2ilD.js → panel-traces-BDiAuVSK.js} +1 -1
- package/src/console/ui/dist/assets/panel-vault-C9wjbki8.js +1 -0
- package/src/console/ui/dist/index.html +1 -1
- 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/drivers/conformance.test.ts +11 -0
- package/src/drivers/drizzle-dialect.test.ts +4 -0
- package/src/drivers/drizzle-dialect.ts +8 -4
- package/src/drivers/index.ts +4 -0
- package/src/drivers/libsql.ts +179 -0
- package/src/drivers/pglite.ts +79 -0
- package/src/drivers/pgvector.ts +54 -19
- package/src/drivers/types.ts +16 -7
- 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 +257 -0
- package/src/elements/store/runtime.ts +67 -9
- package/src/elements/store/schema-decl.ts +7 -0
- package/src/kernel/abort-scope.ts +116 -0
- package/src/kernel/app.ts +12 -2
- package/src/kernel/boot-bind/store.test.ts +59 -1
- package/src/kernel/boot-bind/store.ts +64 -2
- package/src/kernel/concurrency.test.ts +236 -0
- package/src/kernel/concurrency.ts +172 -0
- package/src/kernel/flow.ts +9 -0
- package/src/kernel/fx.test.ts +12 -0
- package/src/kernel/fx.ts +44 -0
- package/src/kernel/index.ts +14 -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/registry.ts +29 -6
- package/src/manifest/types.ts +21 -0
- package/src/release/measure.ts +4 -0
- 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-gates-XclZxWD5.js +0 -1
- package/src/console/ui/dist/assets/panel-plugins-CcGM1g64.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-vault-CEnFc0dk.js +0 -1
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
indexDriverFor,
|
|
8
|
+
resolveFilesDriverId,
|
|
9
|
+
resolveIndexDriverId,
|
|
10
|
+
resolveKvDriverId,
|
|
11
|
+
resolveSqlDriverId,
|
|
12
|
+
} from "./store.ts";
|
|
7
13
|
|
|
8
14
|
describe("bindStore driver resolution", () => {
|
|
9
15
|
const prev = {
|
|
@@ -11,6 +17,7 @@ describe("bindStore driver resolution", () => {
|
|
|
11
17
|
sql: process.env.OKE_SQL_DRIVER,
|
|
12
18
|
kv: process.env.OKE_KV_DRIVER,
|
|
13
19
|
files: process.env.OKE_FILES_DRIVER,
|
|
20
|
+
index: process.env.OKE_INDEX_DRIVER,
|
|
14
21
|
};
|
|
15
22
|
|
|
16
23
|
afterEach(() => {
|
|
@@ -22,6 +29,8 @@ describe("bindStore driver resolution", () => {
|
|
|
22
29
|
else process.env.OKE_KV_DRIVER = prev.kv;
|
|
23
30
|
if (prev.files === undefined) delete process.env.OKE_FILES_DRIVER;
|
|
24
31
|
else process.env.OKE_FILES_DRIVER = prev.files;
|
|
32
|
+
if (prev.index === undefined) delete process.env.OKE_INDEX_DRIVER;
|
|
33
|
+
else process.env.OKE_INDEX_DRIVER = prev.index;
|
|
25
34
|
});
|
|
26
35
|
|
|
27
36
|
test("local env keeps sqlite / memory from config", () => {
|
|
@@ -67,3 +76,52 @@ describe("bindStore driver resolution", () => {
|
|
|
67
76
|
expect(resolveFilesDriverId({}, "docker", true)).toBe("s3");
|
|
68
77
|
});
|
|
69
78
|
});
|
|
79
|
+
|
|
80
|
+
describe("bindStore index driver resolution", () => {
|
|
81
|
+
const prevIndex = process.env.OKE_INDEX_DRIVER;
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
if (prevIndex === undefined) delete process.env.OKE_INDEX_DRIVER;
|
|
85
|
+
else process.env.OKE_INDEX_DRIVER = prevIndex;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("defaults to memory when no index driver is configured", () => {
|
|
89
|
+
expect(resolveIndexDriverId({}, "local", false)).toBe("memory");
|
|
90
|
+
expect(resolveIndexDriverId({}, "docker", true)).toBe("memory");
|
|
91
|
+
expect(indexDriverFor("memory").id).toBe("memory");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("config drivers.store.index is honoured per env", () => {
|
|
95
|
+
const options = {
|
|
96
|
+
config: {
|
|
97
|
+
drivers: {
|
|
98
|
+
store: {
|
|
99
|
+
index: { local: "memory", docker: "pgvector", prod: "pgvector" },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
expect(resolveIndexDriverId(options, "local", false)).toBe("memory");
|
|
105
|
+
expect(resolveIndexDriverId(options, "docker", true)).toBe("pgvector");
|
|
106
|
+
expect(resolveIndexDriverId(options, "prod", false)).toBe("pgvector");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("libsql resolves from config", () => {
|
|
110
|
+
const options = {
|
|
111
|
+
config: { drivers: { store: { index: { local: "libsql" } } } },
|
|
112
|
+
};
|
|
113
|
+
expect(resolveIndexDriverId(options, "local", false)).toBe("libsql");
|
|
114
|
+
expect(indexDriverFor("libsql").id).toBe("libsql");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("docker mode honours OKE_INDEX_DRIVER override", () => {
|
|
118
|
+
process.env.OKE_INDEX_DRIVER = "pgvector";
|
|
119
|
+
expect(resolveIndexDriverId({}, "docker", true)).toBe("pgvector");
|
|
120
|
+
expect(resolveIndexDriverId({}, "local", false)).toBe("memory");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("indexDriverFor returns the configured driver and throws on unknown ids", () => {
|
|
124
|
+
expect(indexDriverFor("pgvector").id).toBe("pgvector");
|
|
125
|
+
expect(() => indexDriverFor("chroma")).toThrow(/unknown index driver "chroma"/);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
import { resolveDomainDdlMode, resolveDriverId, type ConfigEnv } from "../../config/index.ts";
|
|
6
6
|
import { fsDriver } from "../../drivers/fs.ts";
|
|
7
|
+
import { libsqlDriver, libsqlIndexDriver } from "../../drivers/libsql.ts";
|
|
7
8
|
import { memoryDrivers } from "../../drivers/memory.ts";
|
|
9
|
+
import { pgliteDriver } from "../../drivers/pglite.ts";
|
|
10
|
+
import { pgvectorDriver } from "../../drivers/pgvector.ts";
|
|
8
11
|
import { postgresDriver } from "../../drivers/postgres.ts";
|
|
9
12
|
import { redisDriver } from "../../drivers/redis.ts";
|
|
10
13
|
import { s3Driver } from "../../drivers/s3.ts";
|
|
11
14
|
import { sqliteDriver } from "../../drivers/sqlite.ts";
|
|
12
|
-
import type { FilesDriver, KvDriver, SqlDriver } from "../../drivers/types.ts";
|
|
15
|
+
import type { FilesDriver, IndexDriver, KvDriver, SqlDriver } from "../../drivers/types.ts";
|
|
13
16
|
import { createStoreRuntime, type StoreRuntime } from "../../elements/store.ts";
|
|
14
17
|
import type { StoreDecl } from "../../elements/store/declare.ts";
|
|
15
18
|
import type { BootOptions } from "../boot.ts"; // type-only — no cycle at runtime
|
|
@@ -34,6 +37,7 @@ export function bindStore(
|
|
|
34
37
|
const sqlId = resolveSqlDriverId(options, env, docker);
|
|
35
38
|
const kvId = resolveKvDriverId(options, env, docker);
|
|
36
39
|
const filesId = resolveFilesDriverId(options, env, docker);
|
|
40
|
+
const indexId = resolveIndexDriverId(options, env, docker);
|
|
37
41
|
const sqlUrl = sqlUrlFor(sqlId, docker);
|
|
38
42
|
const kvUrl = kvUrlFor(kvId, docker);
|
|
39
43
|
const filesRoot = filesRootFor(filesId);
|
|
@@ -41,6 +45,7 @@ export function bindStore(
|
|
|
41
45
|
const sqlBindings: Record<string, { name: string; primary: { url: string } }> = {};
|
|
42
46
|
const kvBindings: Record<string, { url?: string }> = {};
|
|
43
47
|
const filesBindings: Record<string, { root?: string }> = {};
|
|
48
|
+
const indexBindings: Record<string, { url?: string }> = {};
|
|
44
49
|
|
|
45
50
|
for (const decl of options.stores ?? []) {
|
|
46
51
|
if (isSqlDecl(decl)) {
|
|
@@ -52,6 +57,10 @@ export function bindStore(
|
|
|
52
57
|
kvBindings[decl.name] = kvUrl !== undefined ? { url: kvUrl } : {};
|
|
53
58
|
} else if (isFilesDecl(decl)) {
|
|
54
59
|
filesBindings[decl.name] = filesRoot !== undefined ? { root: filesRoot } : {};
|
|
60
|
+
} else if (isIndexDecl(decl)) {
|
|
61
|
+
// SQL-backed index drivers share the sql facet's URL — the runtime opens
|
|
62
|
+
// one connection and hands it to both (never a second, redundant one).
|
|
63
|
+
indexBindings[decl.name] = indexId === "memory" ? {} : { url: sqlUrl };
|
|
55
64
|
}
|
|
56
65
|
}
|
|
57
66
|
|
|
@@ -63,11 +72,12 @@ export function bindStore(
|
|
|
63
72
|
sql: sqlDriverFor(sqlId),
|
|
64
73
|
kv: kvDriverFor(kvId),
|
|
65
74
|
files: filesDriverFor(filesId),
|
|
66
|
-
index:
|
|
75
|
+
index: indexDriverFor(indexId),
|
|
67
76
|
},
|
|
68
77
|
sql: sqlBindings,
|
|
69
78
|
kv: kvBindings,
|
|
70
79
|
files: filesBindings,
|
|
80
|
+
index: indexBindings,
|
|
71
81
|
now,
|
|
72
82
|
domainDdl,
|
|
73
83
|
});
|
|
@@ -132,12 +142,37 @@ export function resolveFilesDriverId(
|
|
|
132
142
|
return docker ? "s3" : "memory";
|
|
133
143
|
}
|
|
134
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Index driver — same resolution chain as sql/kv/files. Defaults to `memory`;
|
|
147
|
+
* a configured `pgvector` / `libsql` is honoured at real boot (and fails loud
|
|
148
|
+
* if its SQL engine or peer is missing — never silently back to memory).
|
|
149
|
+
*
|
|
150
|
+
* @param options - Boot options
|
|
151
|
+
* @param env - Active env
|
|
152
|
+
* @param docker - Docker mode
|
|
153
|
+
*/
|
|
154
|
+
export function resolveIndexDriverId(
|
|
155
|
+
options: BootOptions,
|
|
156
|
+
env: ConfigEnv,
|
|
157
|
+
docker: boolean,
|
|
158
|
+
): string {
|
|
159
|
+
const fromEnv = process.env.OKE_INDEX_DRIVER?.trim();
|
|
160
|
+
if (docker && fromEnv) return fromEnv;
|
|
161
|
+
const resolved = resolveDriverId(options.config?.drivers?.store?.index, env);
|
|
162
|
+
if (resolved) return resolved;
|
|
163
|
+
return "memory";
|
|
164
|
+
}
|
|
165
|
+
|
|
135
166
|
function sqlDriverFor(id: string): SqlDriver {
|
|
136
167
|
switch (id) {
|
|
137
168
|
case "postgres":
|
|
138
169
|
return postgresDriver;
|
|
139
170
|
case "sqlite":
|
|
140
171
|
return sqliteDriver;
|
|
172
|
+
case "libsql":
|
|
173
|
+
return libsqlDriver;
|
|
174
|
+
case "pglite":
|
|
175
|
+
return pgliteDriver;
|
|
141
176
|
case "memory":
|
|
142
177
|
return memoryDrivers.sql;
|
|
143
178
|
default:
|
|
@@ -169,6 +204,23 @@ function filesDriverFor(id: string): FilesDriver {
|
|
|
169
204
|
}
|
|
170
205
|
}
|
|
171
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Single id → index driver switch — shared by `bindStore` and the Console's
|
|
209
|
+
* Manifest runtime so resolution can never drift into two maintained copies.
|
|
210
|
+
*/
|
|
211
|
+
export function indexDriverFor(id: string): IndexDriver {
|
|
212
|
+
switch (id) {
|
|
213
|
+
case "memory":
|
|
214
|
+
return memoryDrivers.index;
|
|
215
|
+
case "pgvector":
|
|
216
|
+
return pgvectorDriver;
|
|
217
|
+
case "libsql":
|
|
218
|
+
return libsqlIndexDriver;
|
|
219
|
+
default:
|
|
220
|
+
throw new Error(`oke boot: unknown index driver "${id}"`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
172
224
|
function sqlUrlFor(sqlId: string, docker: boolean): string {
|
|
173
225
|
if (sqlId === "postgres") {
|
|
174
226
|
const url = process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL ?? undefined;
|
|
@@ -184,6 +236,12 @@ function sqlUrlFor(sqlId: string, docker: boolean): string {
|
|
|
184
236
|
if (sqlId === "sqlite") {
|
|
185
237
|
return process.env.OKE_SQLITE_URL ?? ".oke/app.sqlite";
|
|
186
238
|
}
|
|
239
|
+
if (sqlId === "libsql") {
|
|
240
|
+
return process.env.OKE_LIBSQL_URL ?? ".oke/app.libsql";
|
|
241
|
+
}
|
|
242
|
+
if (sqlId === "pglite") {
|
|
243
|
+
return process.env.OKE_PGLITE_URL ?? ".oke/pgdata";
|
|
244
|
+
}
|
|
187
245
|
return ":memory:";
|
|
188
246
|
}
|
|
189
247
|
|
|
@@ -216,3 +274,7 @@ function isKvDecl(decl: StoreDecl): decl is Extract<StoreDecl, { facet: "kv" }>
|
|
|
216
274
|
function isFilesDecl(decl: StoreDecl): decl is Extract<StoreDecl, { facet: "files" }> {
|
|
217
275
|
return decl.facet === "files";
|
|
218
276
|
}
|
|
277
|
+
|
|
278
|
+
function isIndexDecl(decl: StoreDecl): decl is Extract<StoreDecl, { facet: "index" }> {
|
|
279
|
+
return decl.facet === "index";
|
|
280
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
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.retry", () => {
|
|
96
|
+
test("retries then succeeds", async () => {
|
|
97
|
+
const fx = createFx({ flow: "t", effects: {} });
|
|
98
|
+
let n = 0;
|
|
99
|
+
const value = await fx.retry(
|
|
100
|
+
async () => {
|
|
101
|
+
n += 1;
|
|
102
|
+
if (n < 3) throw new Error("transient");
|
|
103
|
+
return "ok";
|
|
104
|
+
},
|
|
105
|
+
{ retries: 3, delay: 0, jitter: false },
|
|
106
|
+
);
|
|
107
|
+
expect(value).toBe("ok");
|
|
108
|
+
expect(n).toBe(3);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("does not retry AbortError", async () => {
|
|
112
|
+
const fx = createFx({ flow: "t", effects: {} });
|
|
113
|
+
let n = 0;
|
|
114
|
+
await expect(
|
|
115
|
+
fx.retry(
|
|
116
|
+
async () => {
|
|
117
|
+
n += 1;
|
|
118
|
+
throw new DOMException("aborted", "AbortError");
|
|
119
|
+
},
|
|
120
|
+
{ retries: 3, delay: 0 },
|
|
121
|
+
),
|
|
122
|
+
).rejects.toMatchObject({ name: "AbortError" });
|
|
123
|
+
expect(n).toBe(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("honors when predicate", async () => {
|
|
127
|
+
const fx = createFx({ flow: "t", effects: {} });
|
|
128
|
+
let n = 0;
|
|
129
|
+
await expect(
|
|
130
|
+
fx.retry(
|
|
131
|
+
async () => {
|
|
132
|
+
n += 1;
|
|
133
|
+
throw new Error("nope");
|
|
134
|
+
},
|
|
135
|
+
{ retries: 5, delay: 0, when: () => false },
|
|
136
|
+
),
|
|
137
|
+
).rejects.toThrow("nope");
|
|
138
|
+
expect(n).toBe(1);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("ambient abort signal", () => {
|
|
143
|
+
test("outside scope is never aborted", () => {
|
|
144
|
+
const fx = createFx({ flow: "t", effects: {} });
|
|
145
|
+
expect(fx.signal.aborted).toBe(false);
|
|
146
|
+
expect(isAbortError(new DOMException("x", "AbortError"))).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("withAbortSignal installs ambient signal", async () => {
|
|
150
|
+
const ctrl = new AbortController();
|
|
151
|
+
await withAbortSignal(ctrl.signal, async () => {
|
|
152
|
+
expect(currentAbortSignal()).toBe(ctrl.signal);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("retry + durable journal", () => {
|
|
158
|
+
test("fx.retry inside fx.step does not re-run completed step on resume", async () => {
|
|
159
|
+
const journalStore = createMemoryJournalStore();
|
|
160
|
+
let attempts = 0;
|
|
161
|
+
let crash = true;
|
|
162
|
+
|
|
163
|
+
const f = flow({
|
|
164
|
+
name: "pay.retry",
|
|
165
|
+
durable: true,
|
|
166
|
+
do: async (_input, fx) => {
|
|
167
|
+
const charged = await fx.step("charge", () =>
|
|
168
|
+
fx.retry(
|
|
169
|
+
async () => {
|
|
170
|
+
attempts += 1;
|
|
171
|
+
return { id: "ch_1" };
|
|
172
|
+
},
|
|
173
|
+
{ retries: 2, delay: 0 },
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
if (crash) throw new Error("KILLED");
|
|
177
|
+
return charged;
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const first = await runDurable({ flow: f, journalStore });
|
|
182
|
+
expect(first.status).toBe("failed");
|
|
183
|
+
expect(attempts).toBe(1);
|
|
184
|
+
|
|
185
|
+
crash = false;
|
|
186
|
+
const second = await runDurable({
|
|
187
|
+
flow: f,
|
|
188
|
+
journalStore,
|
|
189
|
+
runId: first.status === "failed" ? first.runId : "",
|
|
190
|
+
});
|
|
191
|
+
expect(second.status).toBe("completed");
|
|
192
|
+
expect(attempts).toBe(1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("flow({ retry }) reuses journal — completed steps do not re-run", async () => {
|
|
196
|
+
const journalStore = createMemoryJournalStore();
|
|
197
|
+
const calls: string[] = [];
|
|
198
|
+
let failOnce = true;
|
|
199
|
+
|
|
200
|
+
const f = flow({
|
|
201
|
+
name: "flow.retry",
|
|
202
|
+
durable: true,
|
|
203
|
+
retry: { retries: 2, delay: 0, jitter: false },
|
|
204
|
+
do: async (_input, fx) => {
|
|
205
|
+
await fx.step("a", () => {
|
|
206
|
+
calls.push("a");
|
|
207
|
+
return 1;
|
|
208
|
+
});
|
|
209
|
+
if (failOnce) {
|
|
210
|
+
failOnce = false;
|
|
211
|
+
throw new Error("transient");
|
|
212
|
+
}
|
|
213
|
+
return fx.step("b", () => {
|
|
214
|
+
calls.push("b");
|
|
215
|
+
return 2;
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const result = await runDurable({ flow: f, journalStore });
|
|
221
|
+
expect(result.status).toBe("completed");
|
|
222
|
+
// step a ran once; flow-level retry re-entered do but journal replayed a.
|
|
223
|
+
expect(calls).toEqual(["a", "b"]);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("capability + concurrency compose", () => {
|
|
228
|
+
test("fx.all records effects from successful branches", async () => {
|
|
229
|
+
const { fx, ledger } = createFxContext({
|
|
230
|
+
flow: "parallel",
|
|
231
|
+
effects: { emits: ["a", "b"] },
|
|
232
|
+
});
|
|
233
|
+
await fx.all([() => fx.emit("a"), () => fx.emit("b")]);
|
|
234
|
+
expect(ledger.entries.map((e) => e.resource)).toEqual(["a", "b"]);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
currentAbortSignal,
|
|
12
|
+
isAbortError,
|
|
13
|
+
linkAbort,
|
|
14
|
+
withAbortSignal,
|
|
15
|
+
} from "./abort-scope.ts";
|
|
16
|
+
import { isJournalSuspend } from "./journal.ts";
|
|
17
|
+
|
|
18
|
+
/** A unit of work started under an abort scope. */
|
|
19
|
+
export type FxThunk<T> = () => T | Promise<T>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Retry policy — same shape as the client transport, with duration strings
|
|
23
|
+
* and optional full jitter (no Schedule DSL).
|
|
24
|
+
*/
|
|
25
|
+
export interface FxRetryOptions {
|
|
26
|
+
/** Extra attempts after the first (default 0). */
|
|
27
|
+
readonly retries?: number;
|
|
28
|
+
/** Initial delay: ms number or duration string (default 50). */
|
|
29
|
+
readonly delay?: number | string;
|
|
30
|
+
/** Multiplier applied after each retry (default 2). */
|
|
31
|
+
readonly backoff?: number;
|
|
32
|
+
/** Full jitter on delay (default true). */
|
|
33
|
+
readonly jitter?: boolean;
|
|
34
|
+
/** Predicate; default retries thrown errors except abort / journal suspend. */
|
|
35
|
+
readonly when?: (err: unknown) => boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Default retry filter — skip abort and durable-sleep park.
|
|
40
|
+
*
|
|
41
|
+
* @param err - Thrown value
|
|
42
|
+
*/
|
|
43
|
+
export function defaultRetryWhen(err: unknown): boolean {
|
|
44
|
+
if (isAbortError(err)) return false;
|
|
45
|
+
if (isJournalSuspend(err)) return false;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve a delay option to milliseconds.
|
|
51
|
+
*
|
|
52
|
+
* @param delay - Number or duration string
|
|
53
|
+
* @param fallback - Default ms
|
|
54
|
+
*/
|
|
55
|
+
export function resolveRetryDelayMs(delay: number | string | undefined, fallback = 50): number {
|
|
56
|
+
if (delay === undefined) return fallback;
|
|
57
|
+
if (typeof delay === "number") return Math.max(0, delay);
|
|
58
|
+
const parsed = parseDurationMs(delay);
|
|
59
|
+
return parsed > 0 ? parsed : fallback;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Run thunks in parallel; on first rejection abort siblings and rethrow.
|
|
64
|
+
*
|
|
65
|
+
* @param thunks - Work units (not already-started Promises)
|
|
66
|
+
*/
|
|
67
|
+
export async function fxAll<const T extends readonly unknown[]>(thunks: {
|
|
68
|
+
readonly [K in keyof T]: FxThunk<T[K]>;
|
|
69
|
+
}): Promise<{ -readonly [K in keyof T]: Awaited<T[K]> }> {
|
|
70
|
+
const list = thunks as readonly FxThunk<unknown>[];
|
|
71
|
+
if (list.length === 0) {
|
|
72
|
+
return [] as { -readonly [K in keyof T]: Awaited<T[K]> };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const parent = currentAbortSignal();
|
|
76
|
+
const controllers = list.map(() => new AbortController());
|
|
77
|
+
const unsubs = controllers.map((c) => linkAbort(parent, c));
|
|
78
|
+
|
|
79
|
+
let firstError: unknown;
|
|
80
|
+
const results = await Promise.allSettled(
|
|
81
|
+
list.map((thunk, i) =>
|
|
82
|
+
withAbortSignal(controllers[i]!.signal, async () => {
|
|
83
|
+
try {
|
|
84
|
+
return await thunk();
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (firstError === undefined) {
|
|
87
|
+
firstError = err;
|
|
88
|
+
for (let j = 0; j < controllers.length; j++) {
|
|
89
|
+
if (j !== i) controllers[j]!.abort(err);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
for (const unsub of unsubs) unsub();
|
|
99
|
+
|
|
100
|
+
if (firstError !== undefined) throw firstError;
|
|
101
|
+
const values: unknown[] = [];
|
|
102
|
+
for (const r of results) {
|
|
103
|
+
if (r.status === "rejected") throw r.reason;
|
|
104
|
+
values.push(r.value);
|
|
105
|
+
}
|
|
106
|
+
return values as { -readonly [K in keyof T]: Awaited<T[K]> };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Race thunks; the first settle wins and siblings are aborted.
|
|
111
|
+
*
|
|
112
|
+
* @param thunks - Work units
|
|
113
|
+
*/
|
|
114
|
+
export async function fxRace<T>(thunks: ReadonlyArray<FxThunk<T>>): Promise<T> {
|
|
115
|
+
if (thunks.length === 0) {
|
|
116
|
+
throw new TypeError("fx.race: empty thunk list");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const parent = currentAbortSignal();
|
|
120
|
+
const controllers = thunks.map(() => new AbortController());
|
|
121
|
+
const unsubs = controllers.map((c) => linkAbort(parent, c));
|
|
122
|
+
|
|
123
|
+
return new Promise<T>((resolve, reject) => {
|
|
124
|
+
let done = false;
|
|
125
|
+
const settle = (ok: boolean, value: unknown): void => {
|
|
126
|
+
if (done) return;
|
|
127
|
+
done = true;
|
|
128
|
+
for (const c of controllers) {
|
|
129
|
+
if (!c.signal.aborted) c.abort();
|
|
130
|
+
}
|
|
131
|
+
for (const unsub of unsubs) unsub();
|
|
132
|
+
if (ok) resolve(value as T);
|
|
133
|
+
else reject(value);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
for (let i = 0; i < thunks.length; i++) {
|
|
137
|
+
const thunk = thunks[i]!;
|
|
138
|
+
const ctrl = controllers[i]!;
|
|
139
|
+
void withAbortSignal(ctrl.signal, () => Promise.resolve().then(thunk)).then(
|
|
140
|
+
(value) => settle(true, value),
|
|
141
|
+
(err: unknown) => settle(false, err),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Retry a thunk with exponential backoff and optional full jitter.
|
|
149
|
+
*
|
|
150
|
+
* @param fn - Operation
|
|
151
|
+
* @param opts - Retry policy
|
|
152
|
+
*/
|
|
153
|
+
export async function fxRetry<T>(fn: FxThunk<T>, opts: FxRetryOptions = {}): Promise<T> {
|
|
154
|
+
const retries = Math.max(0, opts.retries ?? 0);
|
|
155
|
+
const backoff = opts.backoff ?? 2;
|
|
156
|
+
const jitter = opts.jitter !== false;
|
|
157
|
+
const when = opts.when ?? defaultRetryWhen;
|
|
158
|
+
let delayMs = resolveRetryDelayMs(opts.delay, 50);
|
|
159
|
+
let attempt = 0;
|
|
160
|
+
|
|
161
|
+
for (;;) {
|
|
162
|
+
try {
|
|
163
|
+
return await fn();
|
|
164
|
+
} catch (err) {
|
|
165
|
+
if (attempt >= retries || !when(err)) throw err;
|
|
166
|
+
const wait = jitter ? Math.random() * delayMs : delayMs;
|
|
167
|
+
await abortableSleep(wait, currentAbortSignal());
|
|
168
|
+
delayMs *= backoff;
|
|
169
|
+
attempt += 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
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
|
@@ -181,6 +181,18 @@ describe("fx — wholesale swap", () => {
|
|
|
181
181
|
async step(_name, fn) {
|
|
182
182
|
return fn();
|
|
183
183
|
},
|
|
184
|
+
get signal() {
|
|
185
|
+
return new AbortController().signal;
|
|
186
|
+
},
|
|
187
|
+
all(thunks) {
|
|
188
|
+
return Promise.all(thunks.map((t) => t())) as never;
|
|
189
|
+
},
|
|
190
|
+
race(thunks) {
|
|
191
|
+
return Promise.race(thunks.map((t) => t()));
|
|
192
|
+
},
|
|
193
|
+
retry(fn) {
|
|
194
|
+
return Promise.resolve().then(fn);
|
|
195
|
+
},
|
|
184
196
|
};
|
|
185
197
|
|
|
186
198
|
// Any code that accepts `Fx` can run against a total replacement.
|