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.
Files changed (89) hide show
  1. package/AGENTS.md +2 -0
  2. package/package.json +12 -12
  3. package/site/content/docs/ai/skills.mdx +5 -3
  4. package/site/content/docs/elements/ai.mdx +2 -0
  5. package/site/content/docs/elements/channel.mdx +2 -0
  6. package/site/content/docs/elements/clock.mdx +1 -4
  7. package/site/content/docs/elements/flow.mdx +4 -10
  8. package/site/content/docs/elements/gate.mdx +2 -0
  9. package/site/content/docs/elements/signal.mdx +1 -5
  10. package/site/content/docs/elements/store.mdx +27 -6
  11. package/site/content/docs/elements/vault.mdx +10 -11
  12. package/site/content/docs/get-started/basic-usage.mdx +76 -41
  13. package/site/content/docs/get-started/installation.mdx +95 -43
  14. package/site/content/docs/get-started/introduction.mdx +128 -75
  15. package/site/content/docs/get-started/meta.json +1 -1
  16. package/site/content/docs/get-started/why.mdx +141 -0
  17. package/site/content/docs/plugins/ip-allowlist.mdx +1 -2
  18. package/site/content/docs/plugins/security-headers.mdx +1 -1
  19. package/site/content/docs/reference/configuration.mdx +1 -1
  20. package/site/content/docs/reference/environment-variables.mdx +5 -3
  21. package/site/content/docs/reference/fx.mdx +16 -3
  22. package/src/cli/competitor-mention-removal.test.ts +117 -0
  23. package/src/cli/dev.ts +20 -0
  24. package/src/cli/meilisearch-local.test.ts +69 -0
  25. package/src/cli/meilisearch-local.ts +188 -0
  26. package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +5 -1
  27. package/src/console/server/store.test.ts +1 -1
  28. package/src/console/server/store.ts +11 -1
  29. package/src/console/ui/dist/assets/index-CjxwRGVv.js +10 -0
  30. package/src/console/ui/dist/assets/panel-access-BGv45snf.js +64 -0
  31. package/src/console/ui/dist/assets/{panel-ai-D_m6WQI8.js → panel-ai-B2S7LEii.js} +1 -1
  32. package/src/console/ui/dist/assets/{panel-architecture-CKnXFyUx.js → panel-architecture-D7UJh91v.js} +1 -1
  33. package/src/console/ui/dist/assets/{panel-channels-DCDd4WAC.js → panel-channels-9T3ybqRu.js} +1 -1
  34. package/src/console/ui/dist/assets/panel-clock-Cb1UXGRQ.js +1 -0
  35. package/src/console/ui/dist/assets/{panel-diff-cdonmH8c.js → panel-diff-DmYbKWmN.js} +1 -1
  36. package/src/console/ui/dist/assets/panel-flows-PiHwT55z.js +48 -0
  37. package/src/console/ui/dist/assets/{panel-gates-B5eTE8XH.js → panel-gates-BQGYXvjT.js} +1 -1
  38. package/src/console/ui/dist/assets/panel-overview-BBnRO18l.js +1 -0
  39. package/src/console/ui/dist/assets/{panel-plugins-Cj7DK1er.js → panel-plugins-D0PsmVw2.js} +1 -1
  40. package/src/console/ui/dist/assets/panel-runs-CWuRDe0r.js +1 -0
  41. package/src/console/ui/dist/assets/{panel-signals-whmDXIg3.js → panel-signals-Bbg4ewpP.js} +1 -1
  42. package/src/console/ui/dist/assets/{panel-store-CEMHLvaw.js → panel-store-CPCbsDRa.js} +1 -1
  43. package/src/console/ui/dist/assets/panel-traces-DVAzuA_S.js +1 -0
  44. package/src/console/ui/dist/assets/{panel-vault-C9wjbki8.js → panel-vault-D1_MvOmo.js} +1 -1
  45. package/src/console/ui/dist/assets/{rolldown-runtime-CNC7AqOf.js → rolldown-runtime-B0Z9INg1.js} +1 -1
  46. package/src/console/ui/dist/index.html +2 -2
  47. package/src/docker/compose.ts +5 -0
  48. package/src/docker/docker.test.ts +41 -0
  49. package/src/docker/recipes/index.ts +10 -2
  50. package/src/docker/recipes/meilisearch.ts +31 -0
  51. package/src/drivers/conformance.test.ts +16 -1
  52. package/src/drivers/conformance.ts +40 -3
  53. package/src/drivers/index.ts +14 -2
  54. package/src/drivers/libsql.ts +4 -4
  55. package/src/drivers/meilisearch.integration.test.ts +77 -0
  56. package/src/drivers/meilisearch.test.ts +181 -0
  57. package/src/drivers/meilisearch.ts +208 -0
  58. package/src/drivers/memory.ts +4 -4
  59. package/src/drivers/pgvector.ts +6 -6
  60. package/src/drivers/types.ts +93 -12
  61. package/src/drivers/vault-driver-removal.test.ts +6 -0
  62. package/src/drivers/vault-types.ts +4 -4
  63. package/src/elements/ai/runtime.ts +6 -0
  64. package/src/elements/ai.test.ts +22 -0
  65. package/src/elements/store/index-boot.test.ts +49 -7
  66. package/src/elements/store/runtime.ts +50 -15
  67. package/src/elements/store.ts +2 -0
  68. package/src/elements/vault.test.ts +27 -4
  69. package/src/elements/vault.ts +1 -1
  70. package/src/index.ts +4 -0
  71. package/src/kernel/boot-bind/store.test.ts +9 -0
  72. package/src/kernel/boot-bind/store.ts +30 -2
  73. package/src/kernel/concurrency.test.ts +58 -0
  74. package/src/kernel/concurrency.ts +48 -0
  75. package/src/kernel/fx.test.ts +11 -2
  76. package/src/kernel/fx.ts +37 -5
  77. package/src/kernel/index.ts +10 -1
  78. package/src/kernel/redacted.ts +74 -0
  79. package/src/kernel/router.ts +3 -3
  80. package/src/test/provisions.integration.test.ts +1 -1
  81. package/site/content/docs/get-started/comparison.mdx +0 -65
  82. package/src/console/ui/dist/assets/index-CrKMmO__.js +0 -10
  83. package/src/console/ui/dist/assets/panel-access-C0J2D-a2.js +0 -64
  84. package/src/console/ui/dist/assets/panel-clock-DjGGFPzr.js +0 -1
  85. package/src/console/ui/dist/assets/panel-flows-DlCU5zjA.js +0 -45
  86. package/src/console/ui/dist/assets/panel-overview-BsFvDdts.js +0 -1
  87. package/src/console/ui/dist/assets/panel-runs-C0gmnoYL.js +0 -1
  88. package/src/console/ui/dist/assets/panel-traces-BDiAuVSK.js +0 -1
  89. 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<string, { readonly dims?: number; readonly url?: string; readonly sql?: SqlConnection }>
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
- /** Index handle on `fx.store`. */
119
- export interface IndexStoreFxHandle {
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<Array<{ id: string; score: number; meta?: Record<string, unknown> }>>;
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
- /** Index drivers backed by a SQL engine borrow the sql facet's connection. */
287
+ /** SQL-backed index drivers borrow the sql facet's connection. */
263
288
  async function sqlConnForIndex(
264
- driver: IndexDriver,
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
- sql,
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: idx.driverId,
356
- upsert: (id, vector, meta) => idx!.upsert(id, vector, meta),
357
- search: (vector, topK) => idx!.search(vector, topK),
358
- delete: (id) => idx!.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
 
@@ -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 value = fx.vault("STRIPE_KEY");
204
- expect(value).toBe(secret);
204
+ const wrapped = fx.vault("STRIPE_KEY");
205
+ expect(wrapped.reveal()).toBe(secret);
205
206
 
206
- fx.log.info(`charging with ${value}`, { key: value, nested: { k: value } });
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 () => {
@@ -2,7 +2,7 @@
2
2
  * Vault element — protected knowledge.
3
3
  *
4
4
  * Physics: secrets · config · environment.
5
- * Drivers: `env` · `openbao` · `infisical` · `managed`.
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
@@ -34,6 +34,10 @@ export {
34
34
  journey,
35
35
  unit,
36
36
  rateLimit,
37
+ Redacted,
38
+ isRedacted,
39
+ maskRedactedDeep,
40
+ REDACTED_PLACEHOLDER,
37
41
  type OkeApp,
38
42
  type OkeOptions,
39
43
  type FlowDef,
@@ -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
- indexBindings[decl.name] = indexId === "memory" ? {} : { url: sqlUrl };
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
  *
@@ -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 { fxAll, fxRace, fxRetry, type FxRetryOptions, type FxThunk } from "./concurrency.ts";
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
- if (!vault) return { message, data };
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 = data ? (vault.redact(data) as Record<string, unknown>) : undefined;
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 };
@@ -137,7 +137,16 @@ export {
137
137
  withAbortSignal,
138
138
  } from "./abort-scope.ts";
139
139
 
140
- export { defaultRetryWhen, fxAll, fxRace, fxRetry, resolveRetryDelayMs } from "./concurrency.ts";
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
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * HTTP router — compiled RegExp matching with a Trie fallback
3
- * (Hono's SmartRouter idea), plus a linear preset for cold-start edge builds.
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 (Hono RegExpRouter idea).
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");