okengine 0.3.6 → 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 +2 -0
- package/package.json +12 -12
- package/site/content/docs/ai/skills.mdx +5 -3
- package/site/content/docs/elements/ai.mdx +2 -0
- package/site/content/docs/elements/channel.mdx +2 -0
- package/site/content/docs/elements/clock.mdx +1 -4
- package/site/content/docs/elements/flow.mdx +4 -10
- package/site/content/docs/elements/gate.mdx +2 -0
- package/site/content/docs/elements/signal.mdx +1 -5
- package/site/content/docs/elements/store.mdx +27 -6
- package/site/content/docs/elements/vault.mdx +10 -11
- 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 +1 -1
- package/site/content/docs/reference/environment-variables.mdx +5 -3
- package/site/content/docs/reference/fx.mdx +16 -3
- 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/fixtures/skyport/src/flows/payments/index.ts +5 -1
- package/src/console/server/store.test.ts +1 -1
- package/src/console/server/store.ts +11 -1
- 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-DCDd4WAC.js → panel-channels-9T3ybqRu.js} +1 -1
- 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-B5eTE8XH.js → panel-gates-BQGYXvjT.js} +1 -1
- package/src/console/ui/dist/assets/panel-overview-BBnRO18l.js +1 -0
- package/src/console/ui/dist/assets/{panel-plugins-Cj7DK1er.js → panel-plugins-D0PsmVw2.js} +1 -1
- package/src/console/ui/dist/assets/panel-runs-CWuRDe0r.js +1 -0
- package/src/console/ui/dist/assets/{panel-signals-whmDXIg3.js → panel-signals-Bbg4ewpP.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-CEMHLvaw.js → panel-store-CPCbsDRa.js} +1 -1
- package/src/console/ui/dist/assets/panel-traces-DVAzuA_S.js +1 -0
- package/src/console/ui/dist/assets/{panel-vault-C9wjbki8.js → panel-vault-D1_MvOmo.js} +1 -1
- 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/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 +16 -1
- package/src/drivers/conformance.ts +40 -3
- package/src/drivers/index.ts +14 -2
- package/src/drivers/libsql.ts +4 -4
- 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/pgvector.ts +6 -6
- package/src/drivers/types.ts +93 -12
- 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/store/index-boot.test.ts +49 -7
- package/src/elements/store/runtime.ts +50 -15
- 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/boot-bind/store.test.ts +9 -0
- package/src/kernel/boot-bind/store.ts +30 -2
- package/src/kernel/concurrency.test.ts +58 -0
- package/src/kernel/concurrency.ts +48 -0
- package/src/kernel/fx.test.ts +11 -2
- package/src/kernel/fx.ts +37 -5
- package/src/kernel/index.ts +10 -1
- package/src/kernel/redacted.ts +74 -0
- package/src/kernel/router.ts +3 -3
- 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-CrKMmO__.js +0 -10
- package/src/console/ui/dist/assets/panel-access-C0J2D-a2.js +0 -64
- package/src/console/ui/dist/assets/panel-clock-DjGGFPzr.js +0 -1
- package/src/console/ui/dist/assets/panel-flows-DlCU5zjA.js +0 -45
- package/src/console/ui/dist/assets/panel-overview-BsFvDdts.js +0 -1
- package/src/console/ui/dist/assets/panel-runs-C0gmnoYL.js +0 -1
- package/src/console/ui/dist/assets/panel-traces-BDiAuVSK.js +0 -1
- package/src/drivers/vault-infisical.ts +0 -57
|
@@ -18,7 +18,12 @@ import type {
|
|
|
18
18
|
SqlDriver,
|
|
19
19
|
SqlRole,
|
|
20
20
|
SqlRow,
|
|
21
|
+
TextIndexSearchOptions,
|
|
22
|
+
TextIndexSearchResult,
|
|
23
|
+
VectorIndexDriver,
|
|
24
|
+
VectorIndexStore,
|
|
21
25
|
} from "../../drivers/types.ts";
|
|
26
|
+
import { indexDriverNeedsSql } from "../../drivers/types.ts";
|
|
22
27
|
import { buildClassificationMap, type MaskRowsOptions } from "./classify.ts";
|
|
23
28
|
import {
|
|
24
29
|
createStoreCache,
|
|
@@ -64,7 +69,15 @@ export interface CreateStoreRuntimeOptions {
|
|
|
64
69
|
readonly files?: Readonly<Record<string, { readonly root?: string; readonly client?: unknown }>>;
|
|
65
70
|
/** Index open options keyed by store name. */
|
|
66
71
|
readonly index?: Readonly<
|
|
67
|
-
Record<
|
|
72
|
+
Record<
|
|
73
|
+
string,
|
|
74
|
+
{
|
|
75
|
+
readonly dims?: number;
|
|
76
|
+
readonly url?: string;
|
|
77
|
+
readonly apiKey?: string;
|
|
78
|
+
readonly sql?: SqlConnection;
|
|
79
|
+
}
|
|
80
|
+
>
|
|
68
81
|
>;
|
|
69
82
|
/** Clock for cache TTLs. */
|
|
70
83
|
readonly now?: () => number;
|
|
@@ -115,18 +128,30 @@ export interface FilesStoreFxHandle {
|
|
|
115
128
|
list(prefix?: string): Promise<string[]>;
|
|
116
129
|
}
|
|
117
130
|
|
|
118
|
-
/**
|
|
119
|
-
export interface
|
|
131
|
+
/** Vector index handle on `fx.store`. */
|
|
132
|
+
export interface VectorIndexStoreFxHandle {
|
|
120
133
|
readonly ref: `index:${string}`;
|
|
121
134
|
readonly driverId: "memory" | "pgvector" | "libsql";
|
|
122
135
|
upsert(id: string, vector: readonly number[], meta?: Record<string, unknown>): Promise<void>;
|
|
123
136
|
search(
|
|
124
137
|
vector: readonly number[],
|
|
125
138
|
topK?: number,
|
|
126
|
-
): Promise<
|
|
139
|
+
): Promise<ReadonlyArray<{ id: string; score: number; meta?: Record<string, unknown> }>>;
|
|
127
140
|
delete(id: string): Promise<boolean>;
|
|
128
141
|
}
|
|
129
142
|
|
|
143
|
+
/** Full-text index handle on `fx.store`. */
|
|
144
|
+
export interface TextIndexStoreFxHandle {
|
|
145
|
+
readonly ref: `index:${string}`;
|
|
146
|
+
readonly driverId: "meilisearch";
|
|
147
|
+
upsert(id: string, document: Record<string, unknown>): Promise<void>;
|
|
148
|
+
search(q: string, opts?: TextIndexSearchOptions): Promise<TextIndexSearchResult>;
|
|
149
|
+
delete(id: string): Promise<boolean>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Index handle on `fx.store` — discriminated by `driverId`. */
|
|
153
|
+
export type IndexStoreFxHandle = VectorIndexStoreFxHandle | TextIndexStoreFxHandle;
|
|
154
|
+
|
|
130
155
|
/** Store runtime. */
|
|
131
156
|
export interface StoreRuntime {
|
|
132
157
|
/** Shared multi-tier cache. */
|
|
@@ -259,9 +284,9 @@ export function createStoreRuntime(options: CreateStoreRuntimeOptions): StoreRun
|
|
|
259
284
|
return conn;
|
|
260
285
|
}
|
|
261
286
|
|
|
262
|
-
/**
|
|
287
|
+
/** SQL-backed index drivers borrow the sql facet's connection. */
|
|
263
288
|
async function sqlConnForIndex(
|
|
264
|
-
driver:
|
|
289
|
+
driver: VectorIndexDriver & { id: "pgvector" | "libsql" },
|
|
265
290
|
url: string | undefined,
|
|
266
291
|
): Promise<SqlConnection> {
|
|
267
292
|
const sqlDriver = options.drivers.sql;
|
|
@@ -338,24 +363,34 @@ export function createStoreRuntime(options: CreateStoreRuntimeOptions): StoreRun
|
|
|
338
363
|
let idx = indexes.get(decl.name);
|
|
339
364
|
if (!idx) {
|
|
340
365
|
const binding = options.index?.[decl.name] ?? {};
|
|
341
|
-
let sql = binding.sql;
|
|
342
|
-
if (!sql && driver.id !== "memory") {
|
|
343
|
-
sql = await sqlConnForIndex(driver, binding.url);
|
|
344
|
-
}
|
|
345
366
|
idx = await driver.open({
|
|
346
367
|
name: decl.name,
|
|
347
368
|
dims: binding.dims ?? decl.dims ?? 3,
|
|
348
369
|
url: binding.url,
|
|
349
|
-
|
|
370
|
+
apiKey: binding.apiKey,
|
|
371
|
+
sql: indexDriverNeedsSql(driver)
|
|
372
|
+
? (binding.sql ?? (await sqlConnForIndex(driver, binding.url)))
|
|
373
|
+
: undefined,
|
|
350
374
|
});
|
|
351
375
|
indexes.set(decl.name, idx);
|
|
352
376
|
}
|
|
377
|
+
if (idx.driverId === "meilisearch") {
|
|
378
|
+
const text = idx;
|
|
379
|
+
return {
|
|
380
|
+
ref: `index:${decl.name}`,
|
|
381
|
+
driverId: text.driverId,
|
|
382
|
+
upsert: (id, document) => text.upsert(id, document),
|
|
383
|
+
search: (q, opts) => text.search(q, opts),
|
|
384
|
+
delete: (id) => text.delete(id),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
const vector = idx as VectorIndexStore;
|
|
353
388
|
return {
|
|
354
389
|
ref: `index:${decl.name}`,
|
|
355
|
-
driverId:
|
|
356
|
-
upsert: (id,
|
|
357
|
-
search: (
|
|
358
|
-
delete: (id) =>
|
|
390
|
+
driverId: vector.driverId,
|
|
391
|
+
upsert: (id, vec, meta) => vector.upsert(id, vec, meta),
|
|
392
|
+
search: (vec, topK) => vector.search(vec, topK),
|
|
393
|
+
delete: (id) => vector.delete(id),
|
|
359
394
|
};
|
|
360
395
|
}
|
|
361
396
|
|
package/src/elements/store.ts
CHANGED
|
@@ -116,6 +116,8 @@ export type {
|
|
|
116
116
|
KvStoreFxHandle,
|
|
117
117
|
FilesStoreFxHandle,
|
|
118
118
|
IndexStoreFxHandle,
|
|
119
|
+
VectorIndexStoreFxHandle,
|
|
120
|
+
TextIndexStoreFxHandle,
|
|
119
121
|
} from "./store/runtime.ts";
|
|
120
122
|
|
|
121
123
|
export { fileKeyWarnings, contentAddressedKey, projectFileKeys } from "./store/files-policy.ts";
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { describe, expect, test } from "bun:test";
|
|
10
10
|
import { envVaultDriver, memoryVaultDriver } from "../drivers/index.ts";
|
|
11
11
|
import { createFx } from "../kernel/fx.ts";
|
|
12
|
+
import { REDACTED_PLACEHOLDER, Redacted } from "../kernel/redacted.ts";
|
|
12
13
|
import {
|
|
13
14
|
createVaultRuntime,
|
|
14
15
|
fingerprintSecretSync,
|
|
@@ -200,17 +201,39 @@ describe("redaction + fingerprints", () => {
|
|
|
200
201
|
const lines: Array<{ message: string; data?: Record<string, unknown> }> = [];
|
|
201
202
|
const { fx } = createFxContextWithVault(runtime, lines);
|
|
202
203
|
|
|
203
|
-
const
|
|
204
|
-
expect(
|
|
204
|
+
const wrapped = fx.vault("STRIPE_KEY");
|
|
205
|
+
expect(wrapped.reveal()).toBe(secret);
|
|
205
206
|
|
|
206
|
-
fx.log.info(`charging with ${
|
|
207
|
+
fx.log.info(`charging with ${wrapped}`, { key: wrapped, nested: { k: wrapped } });
|
|
208
|
+
// Revealed cleartext still hits the known-value substring scrub.
|
|
207
209
|
fx.log.error(secret, { stripe: secret });
|
|
208
210
|
|
|
209
211
|
for (const line of lines) {
|
|
210
212
|
expect(line.message).not.toContain(secret);
|
|
211
213
|
expect(JSON.stringify(line.data ?? {})).not.toContain(secret);
|
|
212
|
-
expect(line.message).toContain(SECRET_MASK);
|
|
213
214
|
}
|
|
215
|
+
expect(lines[0]!.message).toContain(REDACTED_PLACEHOLDER);
|
|
216
|
+
expect(lines[1]!.message).toContain(SECRET_MASK);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("Redacted from fx.vault is masked in fx.log without a vault runtime", () => {
|
|
220
|
+
const secret = "sk_no_vault_runtime";
|
|
221
|
+
const lines: Array<{ message: string; data?: Record<string, unknown> }> = [];
|
|
222
|
+
const fx = createFx({
|
|
223
|
+
flow: "test.redacted",
|
|
224
|
+
secrets: { STRIPE_KEY: secret },
|
|
225
|
+
onLog: (_level, message, data) => lines.push({ message, data }),
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const wrapped = fx.vault("STRIPE_KEY");
|
|
229
|
+
expect(wrapped).toBeInstanceOf(Redacted);
|
|
230
|
+
expect(String(wrapped)).toBe(REDACTED_PLACEHOLDER);
|
|
231
|
+
expect(`${wrapped}`).toBe(REDACTED_PLACEHOLDER);
|
|
232
|
+
expect(JSON.stringify({ v: wrapped })).toBe(`{"v":"${REDACTED_PLACEHOLDER}"}`);
|
|
233
|
+
|
|
234
|
+
fx.log.info("paying", { key: wrapped, nested: { k: [wrapped] } });
|
|
235
|
+
expect(JSON.stringify(lines[0]!.data)).not.toContain(secret);
|
|
236
|
+
expect(lines[0]!.data?.key).toBe(REDACTED_PLACEHOLDER);
|
|
214
237
|
});
|
|
215
238
|
|
|
216
239
|
test("fingerprint is stable and not the value", async () => {
|
package/src/elements/vault.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Vault element — protected knowledge.
|
|
3
3
|
*
|
|
4
4
|
* Physics: secrets · config · environment.
|
|
5
|
-
* Drivers: `env` · `openbao` · `
|
|
5
|
+
* Drivers: `env` · `openbao` · `managed`.
|
|
6
6
|
*
|
|
7
7
|
* A declaration is a contract, never a value. Boot validates every contract
|
|
8
8
|
* and lists all gaps at once. Logs/traces receive fingerprints and redaction
|
package/src/index.ts
CHANGED
|
@@ -114,6 +114,15 @@ describe("bindStore index driver resolution", () => {
|
|
|
114
114
|
expect(indexDriverFor("libsql").id).toBe("libsql");
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
test("meilisearch resolves from config as a fourth id", () => {
|
|
118
|
+
const options = {
|
|
119
|
+
config: { drivers: { store: { index: { local: "meilisearch", docker: "meilisearch" } } } },
|
|
120
|
+
};
|
|
121
|
+
expect(resolveIndexDriverId(options, "local", false)).toBe("meilisearch");
|
|
122
|
+
expect(resolveIndexDriverId(options, "docker", true)).toBe("meilisearch");
|
|
123
|
+
expect(indexDriverFor("meilisearch").id).toBe("meilisearch");
|
|
124
|
+
});
|
|
125
|
+
|
|
117
126
|
test("docker mode honours OKE_INDEX_DRIVER override", () => {
|
|
118
127
|
process.env.OKE_INDEX_DRIVER = "pgvector";
|
|
119
128
|
expect(resolveIndexDriverId({}, "docker", true)).toBe("pgvector");
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { resolveDomainDdlMode, resolveDriverId, type ConfigEnv } from "../../config/index.ts";
|
|
6
6
|
import { fsDriver } from "../../drivers/fs.ts";
|
|
7
7
|
import { libsqlDriver, libsqlIndexDriver } from "../../drivers/libsql.ts";
|
|
8
|
+
import { meilisearchDriver } from "../../drivers/meilisearch.ts";
|
|
8
9
|
import { memoryDrivers } from "../../drivers/memory.ts";
|
|
9
10
|
import { pgliteDriver } from "../../drivers/pglite.ts";
|
|
10
11
|
import { pgvectorDriver } from "../../drivers/pgvector.ts";
|
|
@@ -45,7 +46,7 @@ export function bindStore(
|
|
|
45
46
|
const sqlBindings: Record<string, { name: string; primary: { url: string } }> = {};
|
|
46
47
|
const kvBindings: Record<string, { url?: string }> = {};
|
|
47
48
|
const filesBindings: Record<string, { root?: string }> = {};
|
|
48
|
-
const indexBindings: Record<string, { url?: string }> = {};
|
|
49
|
+
const indexBindings: Record<string, { url?: string; apiKey?: string }> = {};
|
|
49
50
|
|
|
50
51
|
for (const decl of options.stores ?? []) {
|
|
51
52
|
if (isSqlDecl(decl)) {
|
|
@@ -60,7 +61,14 @@ export function bindStore(
|
|
|
60
61
|
} else if (isIndexDecl(decl)) {
|
|
61
62
|
// SQL-backed index drivers share the sql facet's URL — the runtime opens
|
|
62
63
|
// one connection and hands it to both (never a second, redundant one).
|
|
63
|
-
|
|
64
|
+
// Meilisearch is a standalone HTTP service: its own URL + key, never sqlUrl.
|
|
65
|
+
if (indexId === "memory") {
|
|
66
|
+
indexBindings[decl.name] = {};
|
|
67
|
+
} else if (indexId === "meilisearch") {
|
|
68
|
+
indexBindings[decl.name] = { url: indexUrlFor(docker), apiKey: indexApiKeyFor() };
|
|
69
|
+
} else {
|
|
70
|
+
indexBindings[decl.name] = { url: sqlUrl };
|
|
71
|
+
}
|
|
64
72
|
}
|
|
65
73
|
}
|
|
66
74
|
|
|
@@ -216,6 +224,8 @@ export function indexDriverFor(id: string): IndexDriver {
|
|
|
216
224
|
return pgvectorDriver;
|
|
217
225
|
case "libsql":
|
|
218
226
|
return libsqlIndexDriver;
|
|
227
|
+
case "meilisearch":
|
|
228
|
+
return meilisearchDriver;
|
|
219
229
|
default:
|
|
220
230
|
throw new Error(`oke boot: unknown index driver "${id}"`);
|
|
221
231
|
}
|
|
@@ -263,6 +273,24 @@ function filesRootFor(filesId: string): string | undefined {
|
|
|
263
273
|
return process.env.S3_BUCKET ?? process.env.OKE_STORE_FILES_DB ?? undefined;
|
|
264
274
|
}
|
|
265
275
|
|
|
276
|
+
/** Meilisearch base URL — standalone HTTP service, fail loud when missing. */
|
|
277
|
+
function indexUrlFor(docker: boolean): string {
|
|
278
|
+
const url = process.env.OKE_STORE_INDEX_URL ?? undefined;
|
|
279
|
+
if (!url) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
docker
|
|
282
|
+
? "oke boot: meilisearch index needs OKE_STORE_INDEX_URL (did `oke dev -d` write docker/.env.docker?)"
|
|
283
|
+
: "oke boot: meilisearch index needs OKE_STORE_INDEX_URL",
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return url;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Meilisearch API / master key. */
|
|
290
|
+
function indexApiKeyFor(): string | undefined {
|
|
291
|
+
return process.env.OKE_STORE_INDEX_KEY ?? process.env.MEILI_MASTER_KEY ?? undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
266
294
|
function isSqlDecl(decl: StoreDecl): decl is Extract<StoreDecl, { facet: "sql" }> {
|
|
267
295
|
return decl.facet === "sql";
|
|
268
296
|
}
|
|
@@ -92,6 +92,64 @@ describe("fx.race — structured concurrency", () => {
|
|
|
92
92
|
});
|
|
93
93
|
});
|
|
94
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
|
+
|
|
95
153
|
describe("fx.retry", () => {
|
|
96
154
|
test("retries then succeeds", async () => {
|
|
97
155
|
const fx = createFx({ flow: "t", effects: {} });
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { parseDurationMs } from "../elements/clock/duration.ts";
|
|
9
9
|
import {
|
|
10
10
|
abortableSleep,
|
|
11
|
+
abortError,
|
|
11
12
|
currentAbortSignal,
|
|
12
13
|
isAbortError,
|
|
13
14
|
linkAbort,
|
|
@@ -144,6 +145,53 @@ export async function fxRace<T>(thunks: ReadonlyArray<FxThunk<T>>): Promise<T> {
|
|
|
144
145
|
});
|
|
145
146
|
}
|
|
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
|
+
|
|
147
195
|
/**
|
|
148
196
|
* Retry a thunk with exponential backoff and optional full jitter.
|
|
149
197
|
*
|
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,
|
|
@@ -193,6 +194,14 @@ describe("fx — wholesale swap", () => {
|
|
|
193
194
|
retry(fn) {
|
|
194
195
|
return Promise.resolve().then(fn);
|
|
195
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
|
+
},
|
|
196
205
|
};
|
|
197
206
|
|
|
198
207
|
// Any code that accepts `Fx` can run against a total replacement.
|
package/src/kernel/fx.ts
CHANGED
|
@@ -33,7 +33,15 @@ import {
|
|
|
33
33
|
} from "./dry-run.ts";
|
|
34
34
|
import { fail, type FailOptions, type FlowFailure } from "./errors.ts";
|
|
35
35
|
import { currentAbortSignal } from "./abort-scope.ts";
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
fxAll,
|
|
38
|
+
fxRace,
|
|
39
|
+
fxRetry,
|
|
40
|
+
fxUsing,
|
|
41
|
+
type FxRetryOptions,
|
|
42
|
+
type FxThunk,
|
|
43
|
+
} from "./concurrency.ts";
|
|
44
|
+
import { maskRedactedDeep, Redacted } from "./redacted.ts";
|
|
37
45
|
import type { JournalSession } from "./journal.ts";
|
|
38
46
|
import type { RunTelemetry } from "./run-telemetry.ts";
|
|
39
47
|
|
|
@@ -262,9 +270,13 @@ export interface Fx {
|
|
|
262
270
|
/**
|
|
263
271
|
* Read a vault secret (records `secret`).
|
|
264
272
|
*
|
|
273
|
+
* Returns a {@link Redacted} — printing / logging / serializing it yields a
|
|
274
|
+
* placeholder, never the value. Call `.reveal()` at the one boundary that
|
|
275
|
+
* needs the real value (e.g. passing a credential to a driver).
|
|
276
|
+
*
|
|
265
277
|
* @param secret - Secret name or handle
|
|
266
278
|
*/
|
|
267
|
-
vault(secret: NamedRef): string
|
|
279
|
+
vault(secret: NamedRef): Redacted<string>;
|
|
268
280
|
/** Cache surface. */
|
|
269
281
|
readonly cache: FxCache;
|
|
270
282
|
/**
|
|
@@ -369,6 +381,20 @@ export interface Fx {
|
|
|
369
381
|
* @param opts - Retry policy
|
|
370
382
|
*/
|
|
371
383
|
retry<T>(fn: FxThunk<T>, opts?: FxRetryOptions): Promise<T>;
|
|
384
|
+
/**
|
|
385
|
+
* Scope a resource to `use` — `release` runs exactly once when `use`
|
|
386
|
+
* settles or the ambient abort signal fires (e.g. a sibling `fx.race`
|
|
387
|
+
* winner). Same-attempt cleanup; not journaled.
|
|
388
|
+
*
|
|
389
|
+
* @param acquire - Open the resource
|
|
390
|
+
* @param release - Cleanup, always run
|
|
391
|
+
* @param use - Work with the resource
|
|
392
|
+
*/
|
|
393
|
+
using<A, T>(
|
|
394
|
+
acquire: () => A | Promise<A>,
|
|
395
|
+
release: (resource: A) => void | Promise<void>,
|
|
396
|
+
use: (resource: A) => T | Promise<T>,
|
|
397
|
+
): Promise<T>;
|
|
372
398
|
}
|
|
373
399
|
|
|
374
400
|
/**
|
|
@@ -850,9 +876,12 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
850
876
|
data?: Record<string, unknown>,
|
|
851
877
|
): { message: string; data?: Record<string, unknown> } {
|
|
852
878
|
const vault = options.vaultRuntime;
|
|
853
|
-
|
|
879
|
+
// Redacted<T> never yields the real value, but replace instances with a
|
|
880
|
+
// placeholder so payloads stay plain JSON (and never re-wrap on replay).
|
|
881
|
+
const maskedData = data ? maskRedactedDeep(data) : undefined;
|
|
882
|
+
if (!vault) return { message, data: maskedData };
|
|
854
883
|
const safeMessage = vault.redactString(message);
|
|
855
|
-
const safeData =
|
|
884
|
+
const safeData = maskedData ? (vault.redact(maskedData) as Record<string, unknown>) : undefined;
|
|
856
885
|
return { message: safeMessage, data: safeData };
|
|
857
886
|
}
|
|
858
887
|
|
|
@@ -925,7 +954,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
925
954
|
duration: Math.max(0, now() - timestamp),
|
|
926
955
|
reversibility: reversibilityOf("secret"),
|
|
927
956
|
});
|
|
928
|
-
return value;
|
|
957
|
+
return new Redacted(value);
|
|
929
958
|
},
|
|
930
959
|
cache,
|
|
931
960
|
send(template, opts) {
|
|
@@ -1079,6 +1108,9 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1079
1108
|
retry(fn, opts) {
|
|
1080
1109
|
return fxRetry(fn, opts);
|
|
1081
1110
|
},
|
|
1111
|
+
using(acquire, release, use) {
|
|
1112
|
+
return fxUsing(acquire, release, use);
|
|
1113
|
+
},
|
|
1082
1114
|
};
|
|
1083
1115
|
|
|
1084
1116
|
return { fx, ledger, capability };
|
package/src/kernel/index.ts
CHANGED
|
@@ -137,7 +137,16 @@ export {
|
|
|
137
137
|
withAbortSignal,
|
|
138
138
|
} from "./abort-scope.ts";
|
|
139
139
|
|
|
140
|
-
export {
|
|
140
|
+
export {
|
|
141
|
+
defaultRetryWhen,
|
|
142
|
+
fxAll,
|
|
143
|
+
fxRace,
|
|
144
|
+
fxRetry,
|
|
145
|
+
fxUsing,
|
|
146
|
+
resolveRetryDelayMs,
|
|
147
|
+
} from "./concurrency.ts";
|
|
148
|
+
|
|
149
|
+
export { isRedacted, maskRedactedDeep, REDACTED_PLACEHOLDER, Redacted } from "./redacted.ts";
|
|
141
150
|
|
|
142
151
|
export {
|
|
143
152
|
DryRunWriteIsolationError,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Redacted<T>` — a plain value wrapper that keeps a secret value out of
|
|
3
|
+
* logs, traces, and accidental serialization.
|
|
4
|
+
*
|
|
5
|
+
* Not an effect type: effect tracking only records which secret *names* a
|
|
6
|
+
* flow touches. This is value hygiene inside a flow body.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Placeholder used for any representation of a {@link Redacted}. */
|
|
10
|
+
export const REDACTED_PLACEHOLDER = "[redacted]";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Wrap a value so printing / logging / JSON serialization never yields the
|
|
14
|
+
* real value. The only way to read the wrapped value is {@link reveal}.
|
|
15
|
+
*/
|
|
16
|
+
export class Redacted<T> {
|
|
17
|
+
readonly #value: T;
|
|
18
|
+
|
|
19
|
+
constructor(value: T) {
|
|
20
|
+
this.#value = value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The one explicit way to get the real value. */
|
|
24
|
+
reveal(): T {
|
|
25
|
+
return this.#value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
toString(): string {
|
|
29
|
+
return REDACTED_PLACEHOLDER;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
toJSON(): string {
|
|
33
|
+
return REDACTED_PLACEHOLDER;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
[Symbol.toPrimitive](): string {
|
|
37
|
+
return REDACTED_PLACEHOLDER;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `util.inspect` / `console.log` — never the real value. */
|
|
41
|
+
[Symbol.for("nodejs.util.inspect.custom")](): string {
|
|
42
|
+
return REDACTED_PLACEHOLDER;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Type guard for {@link Redacted}. */
|
|
47
|
+
export function isRedacted(value: unknown): value is Redacted<unknown> {
|
|
48
|
+
return value instanceof Redacted;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Deep-walk a log/trace payload: replace every {@link Redacted} with the
|
|
53
|
+
* placeholder (never the wrapped value); leave everything else untouched.
|
|
54
|
+
*
|
|
55
|
+
* @param value - Arbitrary structured data
|
|
56
|
+
*/
|
|
57
|
+
export function maskRedactedDeep<T>(value: T): T {
|
|
58
|
+
return maskRedactedValue(value) as T;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function maskRedactedValue(value: unknown): unknown {
|
|
62
|
+
if (isRedacted(value)) return REDACTED_PLACEHOLDER;
|
|
63
|
+
if (value === null || value === undefined) return value;
|
|
64
|
+
if (Array.isArray(value)) return value.map(maskRedactedValue);
|
|
65
|
+
if (typeof value === "object") {
|
|
66
|
+
const obj = value as Record<string, unknown>;
|
|
67
|
+
const out: Record<string, unknown> = {};
|
|
68
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
69
|
+
out[k] = maskRedactedValue(v);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
package/src/kernel/router.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTTP router — compiled RegExp matching with a Trie fallback
|
|
3
|
-
*
|
|
2
|
+
* HTTP router — compiled RegExp matching with a Trie fallback, plus a linear
|
|
3
|
+
* preset for cold-start edge builds.
|
|
4
4
|
*
|
|
5
5
|
* Chosen at build/startup: try RegExp first; on unsupported patterns fall
|
|
6
6
|
* back to Trie. The `edge` preset uses LinearRouter for fast registration.
|
|
@@ -141,7 +141,7 @@ function matchCompiled<T>(dyn: CompiledDynamic<T>, path: string): RouteMatch<T>
|
|
|
141
141
|
|
|
142
142
|
/**
|
|
143
143
|
* Compiled RegExp router — O(1) static map, plus per-bucket compiled
|
|
144
|
-
* regular expressions for parametric routes
|
|
144
|
+
* regular expressions for parametric routes.
|
|
145
145
|
*/
|
|
146
146
|
export class RegExpRouter<T> implements Router<T> {
|
|
147
147
|
readonly name = "RegExpRouter";
|
|
@@ -56,7 +56,7 @@ describe("Provisions integration", () => {
|
|
|
56
56
|
effects: { secrets: ["STRIPE_KEY"], emits: ["order-news"] },
|
|
57
57
|
do: async ({ orderId }, fx) => {
|
|
58
58
|
const key = fx.vault(stripeKey);
|
|
59
|
-
expect(key.startsWith("sk_")).toBe(true);
|
|
59
|
+
expect(key.reveal().startsWith("sk_")).toBe(true);
|
|
60
60
|
|
|
61
61
|
await fx.step("create-intent", async () => ({ id: `pi_${orderId}` }));
|
|
62
62
|
await fx.clock.sleep("verify-window", "2m");
|