okengine 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/AGENTS.md +4 -4
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +15 -9
  5. package/manifest.v1.schema.json +10 -0
  6. package/package.json +22 -20
  7. package/site/content/docs/ai/skills.mdx +1 -1
  8. package/site/content/docs/client/calling.mdx +27 -19
  9. package/site/content/docs/client/index.mdx +1 -1
  10. package/site/content/docs/elements/channel/email.mdx +1 -1
  11. package/site/content/docs/elements/channel/index.mdx +1 -1
  12. package/site/content/docs/elements/flow/http.mdx +4 -4
  13. package/site/content/docs/elements/flow/index.mdx +3 -3
  14. package/site/content/docs/elements/flow/meta.json +1 -1
  15. package/site/content/docs/elements/gate/tenancy.mdx +1 -1
  16. package/site/content/docs/elements/vault/rotation.mdx +15 -0
  17. package/site/content/docs/plugins/otp.mdx +31 -31
  18. package/site/content/docs/recipes/mailpit.mdx +1 -1
  19. package/site/content/docs/recipes/meilisearch.mdx +1 -1
  20. package/site/content/docs/recipes/pgdog.mdx +1 -1
  21. package/site/content/docs/reference/configuration.mdx +2 -2
  22. package/site/content/docs/reference/errors.mdx +12 -2
  23. package/site/content/docs/reference/fx.mdx +28 -15
  24. package/site/content/docs/reference/idempotency.mdx +192 -0
  25. package/site/content/docs/reference/index.mdx +6 -1
  26. package/site/content/docs/reference/meta.json +2 -1
  27. package/site/content/docs/understand/meta.json +1 -1
  28. package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
  29. package/site/content/docs/understand/the-architecture.mdx +14 -12
  30. package/site/content/docs/understand/try-it.mdx +3 -3
  31. package/src/cli/competitor-mention-removal.test.ts +3 -3
  32. package/src/cli/docker-cli.test.ts +1 -1
  33. package/src/cli/index.ts +9 -1
  34. package/src/cli/load-config.images.test.ts +10 -10
  35. package/src/cli/load-config.ts +1 -1
  36. package/src/cli/vault-cmd.test.ts +23 -0
  37. package/src/cli/vault-cmd.ts +7 -0
  38. package/src/client/create.ts +18 -1
  39. package/src/client/transport.test.ts +225 -0
  40. package/src/client/transport.ts +132 -19
  41. package/src/client/types.ts +29 -3
  42. package/src/compiler/effects-infer.ts +726 -16
  43. package/src/compiler/extract.test.ts +37 -0
  44. package/src/compiler/extract.ts +113 -12
  45. package/src/compiler/fixtures/skyport.expected.json +24 -0
  46. package/src/compiler/fx-follow.test.ts +164 -0
  47. package/src/compiler/fx-index.ts +399 -0
  48. package/src/console/server/flows-invoke.test.ts +2 -2
  49. package/src/console/ui-next/dist/assets/{access-page-Bgt9bq2r.js → access-page-Ze6ckjdd.js} +2 -2
  50. package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
  51. package/src/console/ui-next/dist/assets/{cache-glyph-92uM5MO7.js → cache-glyph-rL0lHxat.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{call-pii-button-DtGVPtcs.js → call-pii-button-Deo4PP18.js} +1 -1
  53. package/src/console/ui-next/dist/assets/{collapsible-DN7l6zmC.js → collapsible-CoJ6amHf.js} +1 -1
  54. package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
  55. package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
  56. package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
  57. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
  58. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
  59. package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
  60. package/src/console/ui-next/dist/assets/{flows-page-BMHs-IzK.js → flows-page-D2E7BtKK.js} +1 -1
  61. package/src/console/ui-next/dist/assets/{highlighted-json-BlAEVgNW.js → highlighted-json-CPYBzDh0.js} +1 -1
  62. package/src/console/ui-next/dist/assets/{http-method-BDf7OAHv.js → http-method-DTPevKmM.js} +1 -1
  63. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
  64. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
  65. package/src/console/ui-next/dist/assets/{replica-lag-B3GLNVfF.js → replica-lag-BM27lXld.js} +3 -3
  66. package/src/console/ui-next/dist/assets/{request-meta-DMbnAe3f.js → request-meta-BjFT7DNl.js} +1 -1
  67. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
  68. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
  69. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Htk8Cm9t.js → trace-detail-sheet-Cmitq3da.js} +2 -2
  70. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CFMPWX4f.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
  71. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
  72. package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
  73. package/src/console/ui-next/dist/assets/{vault-page-CBYl0LW_.js → vault-page-BOMr0go9.js} +2 -2
  74. package/src/console/ui-next/dist/index.html +2 -2
  75. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
  76. package/src/docker/docker.test.ts +13 -13
  77. package/src/docker/images-config.test.ts +12 -12
  78. package/src/docker/stack-id.test.ts +1 -1
  79. package/src/drivers/journal-postgres.ts +168 -3
  80. package/src/drivers/meilisearch.ts +2 -0
  81. package/src/drivers/vault-builtin.test.ts +10 -0
  82. package/src/drivers/vault-builtin.ts +13 -1
  83. package/src/drivers/vault-remote-bag.ts +5 -1
  84. package/src/drivers/vault-types.ts +6 -1
  85. package/src/elements/store/emit-drizzle.ts +2 -2
  86. package/src/elements/store/live-default.test.ts +2 -0
  87. package/src/elements/store/live-http.test.ts +1 -0
  88. package/src/elements/store/resource-list-docs.test.ts +2 -2
  89. package/src/elements/store/resource.test.ts +3 -3
  90. package/src/elements/store/schema-decl.test.ts +1 -0
  91. package/src/elements/store/sql-condition.ts +24 -7
  92. package/src/elements/store/upsert-app.test.ts +1 -1
  93. package/src/elements/vault/audit.test.ts +137 -0
  94. package/src/elements/vault/audit.ts +106 -13
  95. package/src/elements/vault/boot-chain.ts +9 -1
  96. package/src/elements/vault/builtin-adapter.ts +33 -3
  97. package/src/i18n/catalogs/ar.ts +4 -0
  98. package/src/i18n/catalogs/en.ts +4 -0
  99. package/src/kernel/abort-scope.ts +33 -2
  100. package/src/kernel/app.ts +191 -18
  101. package/src/kernel/auto-cache.test.ts +6 -6
  102. package/src/kernel/boot-bind/vault.ts +1 -0
  103. package/src/kernel/boot.ts +19 -1
  104. package/src/kernel/builtin-errors.ts +8 -0
  105. package/src/kernel/client-descriptor.ts +1 -0
  106. package/src/kernel/concurrency.test.ts +33 -0
  107. package/src/kernel/effects-stamping.test.ts +2 -2
  108. package/src/kernel/errors-compiler.ts +20 -0
  109. package/src/kernel/external-effects.test.ts +36 -0
  110. package/src/kernel/flow.ts +24 -0
  111. package/src/kernel/fx-fetch.ts +5 -1
  112. package/src/kernel/fx.ts +5 -1
  113. package/src/kernel/idempotency-store.ts +578 -0
  114. package/src/kernel/idempotency.test.ts +556 -0
  115. package/src/kernel/idempotency.ts +304 -0
  116. package/src/kernel/index.ts +1 -0
  117. package/src/kernel/journal.ts +56 -0
  118. package/src/manifest/types.ts +8 -0
  119. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  120. package/src/plugins/otp.test.ts +65 -1
  121. package/src/plugins/otp.ts +59 -7
  122. package/src/release/absolute-regression.test.ts +63 -0
  123. package/src/release/http-graph.test.ts +25 -0
  124. package/src/release/http-graph.ts +90 -0
  125. package/src/release/index.ts +3 -0
  126. package/src/release/limits.ts +17 -2
  127. package/src/release/measure.ts +105 -14
  128. package/src/test/create-test-app.test.ts +3 -1
  129. package/src/test/create-test-app.ts +40 -0
  130. package/src/test/live-signals.test.ts +1 -0
  131. package/src/test/provisions.integration.test.ts +1 -0
  132. package/src/test/tenant-isolation.test.ts +1 -0
  133. package/src/console/ui-next/dist/assets/agent-disclosure-B43CXZZR.js +0 -1
  134. package/src/console/ui-next/dist/assets/duration-tone-BmIR9FV8.js +0 -9
  135. package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
  136. package/src/console/ui-next/dist/assets/index-BKpaes3n.js +0 -63
  137. package/src/console/ui-next/dist/assets/observability-page-WnVLI-0j.js +0 -4
  138. package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
  139. package/src/console/ui-next/dist/assets/store-page-KvFDingJ.js +0 -41
  140. package/src/console/ui-next/dist/assets/units-page-C4NdNuxP.js +0 -1
@@ -51,6 +51,7 @@ export async function bindVault(options: BootOptions, env: ConfigEnv): Promise<V
51
51
  env,
52
52
  cwd: process.cwd(),
53
53
  seed: {},
54
+ ...(options.config?.vault?.audit === undefined ? {} : { audit: options.config.vault.audit }),
54
55
  });
55
56
  const vault = createVaultRuntime({
56
57
  secrets: vaultSecrets,
@@ -601,6 +601,15 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
601
601
  docker,
602
602
  });
603
603
 
604
+ const needsIdempotencyJournal = (options.flows ?? []).some((f) => {
605
+ const mode = f.resolvedIdempotency?.mode;
606
+ return mode === "auto" || mode === "required";
607
+ });
608
+ if (!journal && needsIdempotencyJournal) {
609
+ journalBind ??= await loadBind<JournalBind>("journal");
610
+ journal = (await journalBind.bindJournal(options, env)).journal;
611
+ }
612
+
604
613
  // 9. `.adopt()` barrel freshness — opt-in only (same `rootDir` gate as
605
614
  // capability minting above; never a filesystem read the kernel performs
606
615
  // on its own).
@@ -712,7 +721,12 @@ export async function mintCapabilities(
712
721
 
713
722
  let manifest = options.manifest;
714
723
  let extractError: string | undefined;
715
- const needsManifest = manifest === undefined && flows.some((f) => f.effects === undefined);
724
+ const needsManifest =
725
+ manifest === undefined &&
726
+ flows.some(
727
+ (f) =>
728
+ f.effects === undefined || (f.idempotency !== false && f.resolvedIdempotency === undefined),
729
+ );
716
730
  if (needsManifest) {
717
731
  const rootDir = options.rootDir ?? process.env["OKE_ROOT_DIR"];
718
732
  if (rootDir) {
@@ -723,6 +737,10 @@ export async function mintCapabilities(
723
737
  }
724
738
 
725
739
  for (const f of flows) {
740
+ const manifestFlow = manifest?.flows?.[f.name];
741
+ if (manifestFlow?.idempotency) f.resolvedIdempotency = manifestFlow.idempotency;
742
+ if (manifestFlow?.usesRaw === true) f.usesRaw = true;
743
+
726
744
  if (f.effects !== undefined) {
727
745
  map.set(f.name, createCapabilityToken(f.name, f.effects));
728
746
  continue;
@@ -21,6 +21,10 @@ export const BUILTIN_ERROR_STATUS = {
21
21
  AuthFailed: 400,
22
22
  InternalError: 500,
23
23
  ServiceUnavailable: 503,
24
+ IdempotencyKeyMissing: 400,
25
+ IdempotencyKeyInvalid: 400,
26
+ IdempotencyKeyReused: 422,
27
+ IdempotencyInProgress: 409,
24
28
  } as const;
25
29
 
26
30
  /** Built-in error code (including `DatabaseError`). */
@@ -86,6 +90,10 @@ export type BuiltinErrorMap = {
86
90
  };
87
91
  readonly InternalError: Record<string, never>;
88
92
  readonly ServiceUnavailable: { readonly retryAfter?: number };
93
+ readonly IdempotencyKeyMissing: Record<string, never>;
94
+ readonly IdempotencyKeyInvalid: Record<string, never>;
95
+ readonly IdempotencyKeyReused: Record<string, never>;
96
+ readonly IdempotencyInProgress: { readonly retryAfter?: number };
89
97
  };
90
98
 
91
99
  /**
@@ -56,6 +56,7 @@ export function buildClientDescriptor(
56
56
  ...(stamp.stream === true ? { stream: true as const } : {}),
57
57
  ...(stamp.matchKey !== undefined ? { matchKey: stamp.matchKey } : {}),
58
58
  ...(stamp.gates !== undefined && stamp.gates.length > 0 ? { gates: stamp.gates } : {}),
59
+ ...(def?.resolvedIdempotency !== undefined ? { idempotency: def.resolvedIdempotency } : {}),
59
60
  };
60
61
  }
61
62
  out[unit] = unitOut;
@@ -90,6 +90,39 @@ describe("fx.race — structured concurrency", () => {
90
90
  test("empty race throws", async () => {
91
91
  await expect(fxRace([])).rejects.toThrow(/empty/);
92
92
  });
93
+
94
+ test("a gated effect does not start after the branch is aborted", async () => {
95
+ const { fx } = createFxContext({
96
+ flow: "t",
97
+ effects: { reads: ["sql:notes"] },
98
+ });
99
+ const store = fx.store("sql:notes") as { get(key: string): Promise<unknown> };
100
+ let sawAbort = false;
101
+
102
+ const winner = await fx.race([
103
+ async () => {
104
+ await new Promise<void>((resolve) => {
105
+ const signal = fx.signal;
106
+ if (signal.aborted) {
107
+ resolve();
108
+ return;
109
+ }
110
+ signal.addEventListener("abort", () => resolve(), { once: true });
111
+ });
112
+ try {
113
+ await store.get("n1");
114
+ } catch (err) {
115
+ sawAbort = isAbortError(err);
116
+ }
117
+ return "slow";
118
+ },
119
+ async () => "fast",
120
+ ]);
121
+
122
+ expect(winner).toBe("fast");
123
+ await new Promise((r) => setTimeout(r, 20));
124
+ expect(sawAbort).toBe(true);
125
+ });
93
126
  });
94
127
 
95
128
  describe("fx.using — scoped cleanup", () => {
@@ -71,7 +71,7 @@ describe("boot-level: undeclared-effects flow, no manifest / rootDir", () => {
71
71
  const create = buildUnannotatedCreateFlow(db);
72
72
  const app = oke({ name: "stamp-open", gate: { policies: [gate.public] } }).adopt({ create });
73
73
  Object.assign(app.$options, { stores: [db] });
74
- await createTestApp(app); // createTestApp always boots env: "test"
74
+ await createTestApp(app, { capability: "open" });
75
75
 
76
76
  const res = await app.fetch(
77
77
  new Request("http://localhost/notes", {
@@ -157,7 +157,7 @@ describe("boot-level: table-ref resolution stays backward compatible", () => {
157
157
  create,
158
158
  });
159
159
  Object.assign(app.$options, { stores: [db] });
160
- await createTestApp(app);
160
+ await createTestApp(app, { capability: "open" });
161
161
 
162
162
  const res = await app.fetch(
163
163
  new Request("http://localhost/notes", {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * OKE1900 — kept off the kernel edge profile.
3
+ *
4
+ * Effect inference throws a plain `Error` (`OKE1900` in the message) from
5
+ * `okengine/compiler`. This definition is registry-only so the compiler
6
+ * chunk does not import the kernel error module.
7
+ */
8
+
9
+ import type { OkeErrorDefinition } from "./errors.ts";
10
+
11
+ /**
12
+ * A Flow hides `fx` from effect inference, or an explicit `effects` block
13
+ * omits an effect the walk can see.
14
+ */
15
+ export const FX_INFERENCE_OPAQUE: OkeErrorDefinition = {
16
+ code: 1900,
17
+ domain: "compiler",
18
+ cause: 'Flow "{flow}" hides fx from effect inference ({detail}).',
19
+ fix: "Name the parameter fx, keep chain aliases in the same function, and include every effect inference can see. An effects block may add keys; it cannot omit keys inference can see.",
20
+ };
@@ -85,6 +85,42 @@ describe("fx.fetch", () => {
85
85
  globalThis.fetch = originalFetch;
86
86
  }
87
87
  });
88
+
89
+ test("fx.race aborts an in-flight fx.fetch", async () => {
90
+ const originalFetch = globalThis.fetch;
91
+ let signal: AbortSignal | undefined;
92
+ globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
93
+ signal = init?.signal ?? undefined;
94
+ return new Promise<Response>((_resolve, reject) => {
95
+ const onAbort = (): void => {
96
+ reject(new DOMException("aborted", "AbortError"));
97
+ };
98
+ if (init?.signal?.aborted) {
99
+ onAbort();
100
+ return;
101
+ }
102
+ init?.signal?.addEventListener("abort", onAbort, { once: true });
103
+ });
104
+ }) as typeof fetch;
105
+ try {
106
+ const { fx } = createFxContext({
107
+ flow: "stripe.pull",
108
+ effects: { fetches: ["api.stripe.com"] },
109
+ });
110
+ const winner = await fx.race([
111
+ () => fx.fetch("https://api.stripe.com/v1/charges").then(() => "slow"),
112
+ async () => {
113
+ await new Promise((r) => setTimeout(r, 10));
114
+ return "fast" as const;
115
+ },
116
+ ]);
117
+ expect(winner).toBe("fast");
118
+ await new Promise((r) => setTimeout(r, 15));
119
+ expect(signal?.aborted).toBe(true);
120
+ } finally {
121
+ globalThis.fetch = originalFetch;
122
+ }
123
+ });
88
124
  });
89
125
 
90
126
  describe("Channel failover — winning provider on EffectEntry", () => {
@@ -54,6 +54,16 @@ export interface FlowOptions<I = unknown, O = unknown> {
54
54
  * (`"30s"`) adds a TTL on top of write invalidation.
55
55
  */
56
56
  readonly cache?: boolean | string;
57
+ /**
58
+ * HTTP `Idempotency-Key` handling.
59
+ * Omitted is auto: the header is honored on an eligible mutating flow.
60
+ * `"required"` rejects a missing header. `false` ignores the header.
61
+ * A TTL string (`"24h"`) overrides the 24 hour default.
62
+ */
63
+ readonly idempotency?:
64
+ | false
65
+ | "required"
66
+ | { readonly required?: boolean; readonly ttl?: string };
57
67
  /** Declared service-level objective. */
58
68
  readonly slo?: Slo;
59
69
  /**
@@ -168,6 +178,19 @@ export interface FlowDef<
168
178
  readonly liveCustomMatch: boolean;
169
179
  /** Cache option. */
170
180
  readonly cache: boolean | string | undefined;
181
+ /** Author idempotency option. */
182
+ readonly idempotency:
183
+ | false
184
+ | "required"
185
+ | { readonly required?: boolean; readonly ttl?: string }
186
+ | undefined;
187
+ /**
188
+ * Extracted idempotency stamp. Boot copies this from the Manifest.
189
+ * Runtime resolves it when extract has not run.
190
+ */
191
+ resolvedIdempotency?: { readonly mode: "off" | "auto" | "required"; readonly ttl: string };
192
+ /** True when extract saw `fx.raw`. A throw then completes the idempotency row. */
193
+ usesRaw?: boolean;
171
194
  /** SLO. */
172
195
  readonly slo: Slo | undefined;
173
196
  /** Plane (user vs operator). */
@@ -305,6 +328,7 @@ export function flow(
305
328
  live: undefined,
306
329
  liveCustomMatch: false,
307
330
  cache: options.cache,
331
+ idempotency: options.idempotency,
308
332
  slo: options.slo,
309
333
  plane: options.plane,
310
334
  breaking: false,
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { EffectExternal } from "./effects.ts";
7
7
  import { isDryRun, recordWouldHaveFired } from "./dry-run.ts";
8
+ import { requestSignal } from "./abort-scope.ts";
8
9
 
9
10
  /**
10
11
  * Hostname for `fx.fetch` capability / ledger resource.
@@ -48,7 +49,10 @@ export function runFxFetch(gated: Gated, url: string | URL, init?: RequestInit):
48
49
  recordWouldHaveFired("fetch", host);
49
50
  return new Response(null, { status: 204 });
50
51
  }
51
- return globalThis.fetch(url, init);
52
+ return globalThis.fetch(url, {
53
+ ...init,
54
+ signal: requestSignal(init?.signal),
55
+ });
52
56
  },
53
57
  { host, kind: "third-party" },
54
58
  );
package/src/kernel/fx.ts CHANGED
@@ -62,7 +62,7 @@ import {
62
62
  touchDryRunStore,
63
63
  } from "./dry-run.ts";
64
64
  import type { FailFn } from "./errors.ts";
65
- import { currentAbortSignal, linkAbort } from "./abort-scope.ts";
65
+ import { abortError, currentAbortSignal, linkAbort } from "./abort-scope.ts";
66
66
  import type { FxRetryOptions, FxThunk } from "./concurrency.ts";
67
67
  import { maskRedactedDeep, Redacted } from "./redacted.ts";
68
68
  import type { JournalSession, JournalStepOptions } from "./journal.ts";
@@ -1201,6 +1201,8 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1201
1201
  | EffectExternal
1202
1202
  | ((result: T | undefined, error: unknown) => EffectExternal | undefined),
1203
1203
  ): Promise<T> {
1204
+ const signal = currentAbortSignal();
1205
+ if (signal.aborted) throw abortError(signal.reason);
1204
1206
  capability.assert(kind, resource);
1205
1207
  const execute = () => recordEffect(ledger, kind, resource, now, body, externalOf);
1206
1208
  if (journal) {
@@ -1511,6 +1513,8 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1511
1513
  * @param body - Work to run under the gate
1512
1514
  */
1513
1515
  async function gatedSecret<T>(name: string, body: () => T | Promise<T>): Promise<T> {
1516
+ const signal = currentAbortSignal();
1517
+ if (signal.aborted) throw abortError(signal.reason);
1514
1518
  capability.assert("secret", name);
1515
1519
  const timestamp = now();
1516
1520
  const t0 = performance.now();