okengine 0.21.0 → 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 (160) 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 -25
  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/dev.test.ts +105 -2
  33. package/src/cli/dev.ts +37 -2
  34. package/src/cli/docker-cli.test.ts +1 -1
  35. package/src/cli/index.ts +9 -1
  36. package/src/cli/load-config.images.test.ts +10 -10
  37. package/src/cli/load-config.ts +1 -1
  38. package/src/cli/vault-cmd.test.ts +23 -0
  39. package/src/cli/vault-cmd.ts +7 -0
  40. package/src/client/create.ts +31 -27
  41. package/src/client/live.ts +44 -101
  42. package/src/client/sse.ts +20 -66
  43. package/src/client/stream.ts +25 -67
  44. package/src/client/transport.test.ts +225 -0
  45. package/src/client/transport.ts +158 -90
  46. package/src/client/types.ts +29 -3
  47. package/src/client/wire.ts +119 -0
  48. package/src/compiler/aot.ts +3 -32
  49. package/src/compiler/dynamic.ts +13 -11
  50. package/src/compiler/effects-infer.ts +726 -16
  51. package/src/compiler/extract.test.ts +37 -0
  52. package/src/compiler/extract.ts +113 -12
  53. package/src/compiler/fixtures/skyport.expected.json +24 -0
  54. package/src/compiler/fx-follow.test.ts +164 -0
  55. package/src/compiler/fx-index.ts +399 -0
  56. package/src/compiler/interpret.ts +45 -0
  57. package/src/console/server/flows-invoke.test.ts +2 -2
  58. package/src/console/ui-next/dist/assets/{access-page-tIbsiphz.js → access-page-Ze6ckjdd.js} +2 -2
  59. package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
  60. package/src/console/ui-next/dist/assets/{cache-glyph-BCC-DxKT.js → cache-glyph-rL0lHxat.js} +1 -1
  61. package/src/console/ui-next/dist/assets/{call-pii-button-jOmYISdc.js → call-pii-button-Deo4PP18.js} +1 -1
  62. package/src/console/ui-next/dist/assets/{collapsible-DC2xNaAb.js → collapsible-CoJ6amHf.js} +1 -1
  63. package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
  64. package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
  65. package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
  66. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
  67. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
  68. package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
  69. package/src/console/ui-next/dist/assets/{flows-page-gT1lsWQK.js → flows-page-D2E7BtKK.js} +1 -1
  70. package/src/console/ui-next/dist/assets/{highlighted-json-C2GZEJNI.js → highlighted-json-CPYBzDh0.js} +1 -1
  71. package/src/console/ui-next/dist/assets/{http-method-BdYjcIrD.js → http-method-DTPevKmM.js} +1 -1
  72. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
  73. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
  74. package/src/console/ui-next/dist/assets/{replica-lag-C4QdAF7J.js → replica-lag-BM27lXld.js} +3 -3
  75. package/src/console/ui-next/dist/assets/{request-meta-C43DHyld.js → request-meta-BjFT7DNl.js} +1 -1
  76. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
  77. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
  78. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Dcpo4Us_.js → trace-detail-sheet-Cmitq3da.js} +2 -2
  79. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CKJTuv43.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
  80. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
  81. package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
  82. package/src/console/ui-next/dist/assets/{vault-page-BwZ9YjTW.js → vault-page-BOMr0go9.js} +2 -2
  83. package/src/console/ui-next/dist/index.html +2 -2
  84. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
  85. package/src/docker/docker.test.ts +13 -13
  86. package/src/docker/images-config.test.ts +12 -12
  87. package/src/docker/stack-id.test.ts +1 -1
  88. package/src/drivers/journal-postgres.ts +168 -3
  89. package/src/drivers/meilisearch.ts +2 -0
  90. package/src/drivers/vault-builtin.test.ts +10 -0
  91. package/src/drivers/vault-builtin.ts +13 -1
  92. package/src/drivers/vault-remote-bag.ts +5 -1
  93. package/src/drivers/vault-types.ts +6 -1
  94. package/src/elements/store/emit-drizzle.ts +2 -2
  95. package/src/elements/store/live-default.test.ts +2 -0
  96. package/src/elements/store/live-http.test.ts +1 -0
  97. package/src/elements/store/resource-list-docs.test.ts +2 -2
  98. package/src/elements/store/resource.test.ts +3 -3
  99. package/src/elements/store/schema-decl.test.ts +1 -0
  100. package/src/elements/store/sql-condition.ts +24 -7
  101. package/src/elements/store/sql-session.ts +20 -4
  102. package/src/elements/store/upsert-app.test.ts +1 -1
  103. package/src/elements/vault/audit.test.ts +137 -0
  104. package/src/elements/vault/audit.ts +106 -13
  105. package/src/elements/vault/boot-chain.ts +9 -1
  106. package/src/elements/vault/builtin-adapter.ts +33 -3
  107. package/src/i18n/catalogs/ar.ts +4 -0
  108. package/src/i18n/catalogs/en.ts +4 -0
  109. package/src/kernel/abort-scope.ts +33 -2
  110. package/src/kernel/app.ts +200 -20
  111. package/src/kernel/auto-cache.test.ts +6 -6
  112. package/src/kernel/boot-bind/vault.ts +1 -0
  113. package/src/kernel/boot.ts +19 -1
  114. package/src/kernel/builtin-errors.ts +8 -0
  115. package/src/kernel/client-descriptor.test.ts +78 -0
  116. package/src/kernel/client-descriptor.ts +24 -0
  117. package/src/kernel/concurrency.test.ts +33 -0
  118. package/src/kernel/effects-stamping.test.ts +2 -2
  119. package/src/kernel/errors-compiler.ts +20 -0
  120. package/src/kernel/errors-text.ts +99 -0
  121. package/src/kernel/errors.ts +90 -138
  122. package/src/kernel/external-effects.test.ts +36 -0
  123. package/src/kernel/flow.ts +24 -0
  124. package/src/kernel/fx-fetch.ts +5 -1
  125. package/src/kernel/fx-sql-handle.ts +305 -0
  126. package/src/kernel/fx.ts +42 -330
  127. package/src/kernel/idempotency-store.ts +578 -0
  128. package/src/kernel/idempotency.test.ts +556 -0
  129. package/src/kernel/idempotency.ts +304 -0
  130. package/src/kernel/index.ts +1 -0
  131. package/src/kernel/journal.ts +56 -0
  132. package/src/kernel/json-result.ts +59 -0
  133. package/src/kernel/project-out.ts +6 -1
  134. package/src/manifest/types.ts +8 -0
  135. package/src/okid-extended.ts +175 -0
  136. package/src/okid-shared.ts +103 -0
  137. package/src/okid.ts +30 -213
  138. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  139. package/src/plugins/otp.test.ts +65 -1
  140. package/src/plugins/otp.ts +59 -7
  141. package/src/release/absolute-regression.test.ts +63 -0
  142. package/src/release/build-lib.ts +9 -0
  143. package/src/release/http-graph.test.ts +25 -0
  144. package/src/release/http-graph.ts +90 -0
  145. package/src/release/index.ts +3 -0
  146. package/src/release/limits.ts +17 -2
  147. package/src/release/measure.ts +105 -14
  148. package/src/test/create-test-app.test.ts +3 -1
  149. package/src/test/create-test-app.ts +40 -0
  150. package/src/test/live-signals.test.ts +1 -0
  151. package/src/test/provisions.integration.test.ts +1 -0
  152. package/src/test/tenant-isolation.test.ts +1 -0
  153. package/src/console/ui-next/dist/assets/agent-disclosure-CSKumwS2.js +0 -1
  154. package/src/console/ui-next/dist/assets/duration-tone-CwoV56jn.js +0 -9
  155. package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
  156. package/src/console/ui-next/dist/assets/index-Cul17AcV.js +0 -63
  157. package/src/console/ui-next/dist/assets/observability-page-oY9vdYBk.js +0 -4
  158. package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
  159. package/src/console/ui-next/dist/assets/store-page-CL0D9dOq.js +0 -41
  160. package/src/console/ui-next/dist/assets/units-page-1PlT19ft.js +0 -1
package/src/kernel/app.ts CHANGED
@@ -17,7 +17,6 @@ import {
17
17
  type RoutesFromNamespace,
18
18
  type RuntimeRouteMap,
19
19
  } from "./adopt-routes.ts";
20
- import { buildClientDescriptor } from "./client-descriptor.ts";
21
20
  import { liveExposureKey, liveGatesKey, liveMatchKeyFromPath } from "./live-http.ts";
22
21
  // `./boot.ts` pulls in every element + driver module (vault, store, signal,
23
22
  // clock, gate, channel, ai, runs) — a type-only import here keeps that whole
@@ -44,7 +43,7 @@ import { parseAcceptLanguage } from "../elements/channel/locale.ts";
44
43
  import { runWithLocale } from "../i18n/locale-context.ts";
45
44
  import { isFlow, type AnyFlowDef } from "./flow.ts";
46
45
  import { failureFromUnknown, runCompensationPhase } from "./compensate.ts";
47
- import { withAbortSignal } from "./abort-scope.ts";
46
+ import { runDetached, withAbortSignal } from "./abort-scope.ts";
48
47
  import { withCdcMutationId } from "../elements/store/sql-session.ts";
49
48
  import { MUTATION_ID_HEADER } from "./mutation-id.ts";
50
49
  import { fxRetry } from "./concurrency.ts";
@@ -84,6 +83,7 @@ import {
84
83
  } from "./hooks.ts";
85
84
  import {
86
85
  createJournal,
86
+ createJournalSlot,
87
87
  createMemoryJournalStore,
88
88
  hasJournalLease,
89
89
  isJournalLeaseBusy,
@@ -92,6 +92,7 @@ import {
92
92
  type JournalSession,
93
93
  type JournalStore,
94
94
  } from "./journal.ts";
95
+ import type { IdempotencyAttempt } from "./idempotency.ts";
95
96
  import { releaseInstanceLeases } from "./graceful-shutdown.ts";
96
97
  import { mintInstanceId } from "./instance-id.ts";
97
98
  import type { JournalRuntime } from "./boot-bind/journal.ts";
@@ -182,6 +183,11 @@ function loadFx(): {
182
183
  return lazyRequire(import.meta.dir, ["fx", "runtime"].join("-"));
183
184
  }
184
185
 
186
+ /** Idempotency policy — off the edge graph until a request actually claims. */
187
+ function loadIdempotency(): typeof import("./idempotency.ts") {
188
+ return lazyRequire(import.meta.dir, ["idem", "potency"].join(""));
189
+ }
190
+
185
191
  /** App-shell tenancy wiring — loaded only when `gate.auth.tenant` is on. */
186
192
  function loadAppTenant(): { w: (kind: number, ...args: unknown[]) => unknown } {
187
193
  return lazyRequire(import.meta.dir, ["app", "tenant"].join("-"));
@@ -203,6 +209,14 @@ function loadJsonCodeBlock(): typeof import("../runtime/json-code-block.ts") {
203
209
  return lazyRequire(`${import.meta.dir}/../runtime`, ["json", "code", "block"].join("-"));
204
210
  }
205
211
 
212
+ /**
213
+ * Route descriptor for `GET /_oke/client.json`.
214
+ * A static import evaluates Zod on every HTTP cold start; load it on first fetch.
215
+ */
216
+ function loadClientDescriptor(): typeof import("./client-descriptor.ts") {
217
+ return lazyRequire(import.meta.dir, ["client", "descriptor"].join("-"));
218
+ }
219
+
206
220
  /** Options for {@link oke}. */
207
221
  export interface OkeOptions {
208
222
  /** Application name (Manifest `app`). */
@@ -1830,23 +1844,61 @@ export function oke(options: OkeOptions): OkeApp {
1830
1844
  );
1831
1845
 
1832
1846
  capability = booted.capabilities.get(flowDef.name);
1833
-
1834
- if (flowDef.durable) {
1835
- const { store, instanceId, leaseMs } = activeJournal();
1836
- const journal = createJournal({
1837
- store,
1838
- now,
1839
- id: () => runId,
1840
- // Hold the run lease for the request's lifetime — a crash mid-run
1841
- // leaves an expired lease another instance can reclaim and resume.
1842
- ...(hasJournalLease(store) ? { lease: { instanceId, leaseMs } } : {}),
1843
- });
1844
- journalSession = await journal.start(flowDef.name, input);
1845
- inflightRuns.add(journalSession.runId);
1846
- }
1847
1847
  }
1848
1848
 
1849
1849
  const effects = flowDef.effects ?? capability?.declared;
1850
+ const requestForIdem = extras?.request;
1851
+ const rawIdemHeader = requestForIdem?.headers.get("idempotency-key") ?? null;
1852
+ const idemRequired =
1853
+ flowDef.idempotency === "required" ||
1854
+ (typeof flowDef.idempotency === "object" && flowDef.idempotency.required === true) ||
1855
+ flowDef.resolvedIdempotency?.mode === "required";
1856
+ let idemAttempt: IdempotencyAttempt | undefined;
1857
+ if (requestForIdem && (rawIdemHeader !== null || idemRequired)) {
1858
+ const method = trigger.kind === "http" ? trigger.method : requestForIdem.method;
1859
+ const live =
1860
+ flowDef.live !== undefined || (trigger.kind === "http" && trigger.liveSignal !== undefined);
1861
+ idemAttempt = loadIdempotency().prepareIdempotencyAttempt({
1862
+ hasRequest: true,
1863
+ method,
1864
+ stream: flowDef.stream === true,
1865
+ live,
1866
+ effects,
1867
+ usesRaw: flowDef.usesRaw === true,
1868
+ option: flowDef.idempotency,
1869
+ resolved: flowDef.resolvedIdempotency,
1870
+ header: rawIdemHeader,
1871
+ });
1872
+ }
1873
+ const claiming = idemAttempt?.claiming === true;
1874
+
1875
+ const openJournal = (): ReturnType<typeof createJournal> => {
1876
+ const { store, instanceId, leaseMs } = activeJournal();
1877
+ return createJournal({
1878
+ store,
1879
+ now,
1880
+ id: () => runId,
1881
+ // Hold the run lease for the request's lifetime — a crash mid-run
1882
+ // leaves an expired lease another instance can reclaim and resume.
1883
+ ...(hasJournalLease(store) ? { lease: { instanceId, leaseMs } } : {}),
1884
+ });
1885
+ };
1886
+
1887
+ // A claim starts the journal only after it wins, so a replay or a 409
1888
+ // does not open a run. Auto with no header keeps the start here.
1889
+ if (booted && flowDef.durable && !claiming) {
1890
+ journalSession = await openJournal().start(flowDef.name, input);
1891
+ inflightRuns.add(journalSession.runId);
1892
+ }
1893
+
1894
+ let journalSlot: { session?: JournalSession } | undefined;
1895
+ let journalFacade: JournalSession | undefined;
1896
+ if (booted && flowDef.durable && claiming) {
1897
+ const created = createJournalSlot();
1898
+ journalSlot = created.slot;
1899
+ journalFacade = created.facade;
1900
+ }
1901
+ const journalForFx = journalSession ?? journalFacade;
1850
1902
  const { fx, ledger } = loadFx().createFxContext({
1851
1903
  ...options.fx,
1852
1904
  flow: flowDef.name,
@@ -1899,7 +1951,7 @@ export function oke(options: OkeOptions): OkeApp {
1899
1951
  }
1900
1952
  : {}),
1901
1953
  runsRuntime: booted?.runs ?? (isRunsRuntimeLike(options.runs) ? options.runs : undefined),
1902
- ...(journalSession ? { journal: journalSession, durable: true } : {}),
1954
+ ...(journalForFx ? { journal: journalForFx, durable: true } : {}),
1903
1955
  callHandler: async (name, callInput) => {
1904
1956
  const target = flowsByName.get(name);
1905
1957
  if (!target) {
@@ -2031,13 +2083,141 @@ export function oke(options: OkeOptions): OkeApp {
2031
2083
  }
2032
2084
  return output;
2033
2085
  };
2086
+ const runClaimed = async (validated: unknown): Promise<unknown> => {
2087
+ if (!claiming || idemAttempt === undefined) return invoke(validated);
2088
+ const policy = loadIdempotency();
2089
+ const classified = policy.classifyIdempotencyKey(rawIdemHeader);
2090
+ if (classified === "missing") {
2091
+ return policy.idempotencyErrorResponse("IdempotencyKeyMissing");
2092
+ }
2093
+ if (classified === "invalid") {
2094
+ return policy.idempotencyErrorResponse("IdempotencyKeyInvalid");
2095
+ }
2096
+ const idemStore = activeJournal().store.idempotency;
2097
+ if (!idemStore) return invoke(validated);
2098
+ const scope = {
2099
+ tenant: fx.tenant.id ?? "",
2100
+ principal: policy.idempotencyPrincipal(fx.auth),
2101
+ flow: flowDef.name,
2102
+ key: classified,
2103
+ };
2104
+ const claimToken = crypto.randomUUID();
2105
+ const leaseMs = activeJournal().leaseMs;
2106
+ const claimed = await idemStore.claim({
2107
+ scope,
2108
+ fingerprint: policy.idempotencyFingerprint(flowDef.name, validated),
2109
+ claimToken,
2110
+ now: now(),
2111
+ leaseMs,
2112
+ ttlMs: idemAttempt.ttlMs,
2113
+ });
2114
+ if (claimed.kind === "mismatch") {
2115
+ return policy.idempotencyErrorResponse("IdempotencyKeyReused");
2116
+ }
2117
+ if (claimed.kind === "in_progress") {
2118
+ return policy.idempotencyErrorResponse(
2119
+ "IdempotencyInProgress",
2120
+ claimed.retryAfterSeconds,
2121
+ );
2122
+ }
2123
+ if (claimed.kind === "replay") return policy.replayResponse(claimed.row);
2124
+
2125
+ if (booted && flowDef.durable) {
2126
+ const journal = openJournal();
2127
+ const resumeId = claimed.kind === "reclaimed" ? claimed.row.runId : undefined;
2128
+ try {
2129
+ const session = resumeId
2130
+ ? await journal.resume(resumeId)
2131
+ : await journal.start(flowDef.name, validated);
2132
+ if (journalSlot) journalSlot.session = session;
2133
+ journalSession = session;
2134
+ inflightRuns.add(session.runId);
2135
+ const tenantId = principals?.tenant.id ?? fx.tenant.id;
2136
+ if (tenantId) await session.stampTenant(tenantId);
2137
+ await idemStore.attachRun(scope, claimToken, session.runId);
2138
+ } catch (err) {
2139
+ if (isJournalLeaseBusy(err)) {
2140
+ await idemStore.forfeit(scope, claimToken);
2141
+ return policy.idempotencyErrorResponse("IdempotencyInProgress", 1);
2142
+ }
2143
+ await idemStore.remove(scope, claimToken);
2144
+ throw err;
2145
+ }
2146
+ }
2147
+
2148
+ const timer = setInterval(
2149
+ () => {
2150
+ void idemStore.renew(scope, claimToken, now() + leaseMs);
2151
+ },
2152
+ Math.max(5, Math.floor(leaseMs / 3)),
2153
+ );
2154
+ if (typeof timer === "object" && timer !== null && "unref" in timer) timer.unref();
2155
+
2156
+ const storeEncoded = async (encoded: {
2157
+ readonly status: number;
2158
+ readonly headers: { get(name: string): string | null };
2159
+ text(): Promise<string>;
2160
+ }): Promise<void> => {
2161
+ const stored = await policy.storedFromResponse(encoded);
2162
+ if (!stored) {
2163
+ await idemStore.remove(scope, claimToken);
2164
+ return;
2165
+ }
2166
+ await idemStore.complete(scope, claimToken, stored, journalSession?.runId);
2167
+ };
2168
+ try {
2169
+ const output = await runDetached(() => invoke(validated));
2170
+ if (loadFx().isJsonStreamResult(output)) {
2171
+ await idemStore.remove(scope, claimToken);
2172
+ return output;
2173
+ }
2174
+ if (output instanceof Response) {
2175
+ const contentType = output.headers.get("content-type") ?? "";
2176
+ const buffered =
2177
+ output.status === 204 ||
2178
+ contentType.includes("json") ||
2179
+ contentType.startsWith("text/");
2180
+ if (!buffered || contentType.includes("text/event-stream")) {
2181
+ await idemStore.remove(scope, claimToken);
2182
+ return output;
2183
+ }
2184
+ await storeEncoded(output.clone());
2185
+ return output;
2186
+ }
2187
+ await storeEncoded(
2188
+ await encodeExecuteResult({
2189
+ output: isFlowFailure(output) ? undefined : output,
2190
+ failure: isFlowFailure(output) ? output : undefined,
2191
+ }),
2192
+ );
2193
+ return output;
2194
+ } catch (err) {
2195
+ if (flowDef.durable && isJournalSuspend(err)) {
2196
+ await idemStore.complete(
2197
+ scope,
2198
+ claimToken,
2199
+ { status: 204, body: "" },
2200
+ journalSession?.runId,
2201
+ );
2202
+ throw err;
2203
+ }
2204
+ if (policy.attemptedMutation(ledger.entries) || idemAttempt.usesRaw) {
2205
+ await storeEncoded(encodeFailure(fail("InternalError", {})));
2206
+ } else {
2207
+ await idemStore.remove(scope, claimToken);
2208
+ }
2209
+ throw err;
2210
+ } finally {
2211
+ clearInterval(timer);
2212
+ }
2213
+ };
2034
2214
  if (!alreadyValidated) {
2035
2215
  const parsed = await validate(flowDef.in, ctx.input);
2036
2216
  if (!parsed.ok) return parsed.failure;
2037
2217
  ctx.input = parsed.value;
2038
- return await invoke(parsed.value);
2218
+ return await runClaimed(parsed.value);
2039
2219
  }
2040
- return await invoke(ctx.input);
2220
+ return await runClaimed(ctx.input);
2041
2221
  } catch (err) {
2042
2222
  if (isFlowFailure(err)) return err;
2043
2223
  // A durable sleep that has not yet elapsed suspends the run — this
@@ -2381,7 +2561,7 @@ export function oke(options: OkeOptions): OkeApp {
2381
2561
  }
2382
2562
 
2383
2563
  if (method === "GET" && url.pathname === "/_oke/client.json") {
2384
- const descriptor = buildClientDescriptor(routes, flowsByName);
2564
+ const descriptor = loadClientDescriptor().buildClientDescriptor(routes, flowsByName);
2385
2565
  return respond(
2386
2566
  new Response(JSON.stringify(descriptor), {
2387
2567
  headers: { "content-type": "application/json" },
@@ -58,7 +58,7 @@ describe("automatic tier-1 cache from effects", () => {
58
58
  stores: [db],
59
59
  gate: { policies: [gate.public] },
60
60
  }).adopt({ list });
61
- const t = await createTestApp(app);
61
+ const t = await createTestApp(app, { capability: "open" });
62
62
  const notesApi = requireNotesApi(t);
63
63
 
64
64
  const first = await notesApi.list({});
@@ -90,7 +90,7 @@ describe("automatic tier-1 cache from effects", () => {
90
90
  stores: [db],
91
91
  gate: { policies: [gate.public] },
92
92
  }).adopt({ list });
93
- const t = await createTestApp(app);
93
+ const t = await createTestApp(app, { capability: "open" });
94
94
  const trigger = list.triggers[0];
95
95
  if (trigger === undefined) throw new Error("notes.list has no trigger");
96
96
 
@@ -128,7 +128,7 @@ describe("automatic tier-1 cache from effects", () => {
128
128
  stores: [db],
129
129
  gate: { policies: [gate.public] },
130
130
  }).adopt({ list });
131
- const t = await createTestApp(app);
131
+ const t = await createTestApp(app, { capability: "open" });
132
132
  const notesApi = requireNotesApi(t);
133
133
  await notesApi.list({});
134
134
  await notesApi.list({});
@@ -158,7 +158,7 @@ describe("automatic tier-1 cache from effects", () => {
158
158
  stores: [db],
159
159
  gate: { policies: [gate.public] },
160
160
  }).adopt({ list });
161
- const t = await createTestApp(app);
161
+ const t = await createTestApp(app, { capability: "open" });
162
162
  const notesApi = requireNotesApi(t);
163
163
 
164
164
  await notesApi.list({});
@@ -209,7 +209,7 @@ describe("automatic tier-1 cache from effects", () => {
209
209
  stores: [db],
210
210
  gate: { policies: [gate.public] },
211
211
  }).adopt({ list, create });
212
- const t = await createTestApp(app);
212
+ const t = await createTestApp(app, { capability: "open" });
213
213
  const notesApi = requireNotesApi(t);
214
214
 
215
215
  await notesApi.list({});
@@ -251,7 +251,7 @@ describe("automatic tier-1 cache from effects", () => {
251
251
  stores: [db],
252
252
  gate: { policies: [gate.public] },
253
253
  }).adopt({ list, create });
254
- const t = await createTestApp(app);
254
+ const t = await createTestApp(app, { capability: "open" });
255
255
  const notesApi = requireNotesApi(t);
256
256
 
257
257
  await notesApi.list({});
@@ -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
  /**
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Client descriptor printing — schema type strings are cached per object.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { z } from "zod";
7
+ import type { AnyFlowDef } from "./flow.ts";
8
+ import type { RuntimeRouteMap } from "./adopt-routes.ts";
9
+ import { buildClientDescriptor } from "./client-descriptor.ts";
10
+
11
+ const FLOW_COUNT = 64;
12
+
13
+ function fieldSchema() {
14
+ return z.object({
15
+ id: z.string().uuid(),
16
+ title: z.string().min(1).max(120),
17
+ tags: z.array(z.string()),
18
+ count: z.number().int().nonnegative(),
19
+ });
20
+ }
21
+
22
+ function fixture(): {
23
+ routes: RuntimeRouteMap;
24
+ flows: Map<string, AnyFlowDef>;
25
+ } {
26
+ const routes: RuntimeRouteMap = { app: {} };
27
+ const flows = new Map<string, AnyFlowDef>();
28
+ for (let i = 0; i < FLOW_COUNT; i++) {
29
+ const name = `flow${i}`;
30
+ routes.app![name] = { method: "GET", path: `/${name}` };
31
+ flows.set(`app.${name}`, {
32
+ in: fieldSchema(),
33
+ out: z.object({
34
+ id: z.string(),
35
+ title: z.string(),
36
+ createdAt: z.iso.datetime(),
37
+ }),
38
+ } as AnyFlowDef);
39
+ }
40
+ return { routes, flows };
41
+ }
42
+
43
+ describe("schemaToTsString cache", () => {
44
+ test("a second pass over the same schema objects skips reprinting them", () => {
45
+ const { routes, flows } = fixture();
46
+
47
+ const coldStart = performance.now();
48
+ const cold = buildClientDescriptor(routes, flows);
49
+ const coldMs = performance.now() - coldStart;
50
+
51
+ const warmStart = performance.now();
52
+ const warm = buildClientDescriptor(routes, flows);
53
+ const warmMs = performance.now() - warmStart;
54
+
55
+ expect(warm).toEqual(cold);
56
+ expect(cold.routes.app?.flow0?.in).toContain("title");
57
+ // Uncached toJSONSchema is the whole map. A cache hit is the map lookup.
58
+ expect(warmMs).toBeLessThan(coldMs / 4);
59
+ console.log(
60
+ `client descriptor ${FLOW_COUNT} flows cold=${coldMs.toFixed(1)}ms warm=${warmMs.toFixed(1)}ms`,
61
+ );
62
+
63
+ const previous = flows.get("app.flow0");
64
+ flows.set("app.flow0", {
65
+ in: z.object({ id: z.string(), extra: z.boolean() }),
66
+ out: previous?.out,
67
+ } as AnyFlowDef);
68
+
69
+ const touchedStart = performance.now();
70
+ const touched = buildClientDescriptor(routes, flows);
71
+ const touchedMs = performance.now() - touchedStart;
72
+
73
+ expect(touched.routes.app?.flow0?.in).toContain("extra");
74
+ expect(touched.routes.app?.flow1?.in).toBe(warm.routes.app?.flow1?.in);
75
+ expect(touchedMs).toBeLessThan(coldMs / 4);
76
+ console.log(`client descriptor one schema replaced=${touchedMs.toFixed(1)}ms`);
77
+ });
78
+ });
@@ -9,6 +9,15 @@ import type { AnyFlowDef } from "./flow.ts";
9
9
  import type { RuntimeRouteMap } from "./adopt-routes.ts";
10
10
  import { z } from "zod";
11
11
 
12
+ /**
13
+ * Printed type string per schema object.
14
+ *
15
+ * A flows-tree reload replaces the schema objects of the modules that
16
+ * actually changed. Every other flow keeps the same reference and hits
17
+ * this map instead of running `toJSONSchema` again.
18
+ */
19
+ const schemaTsCache = new WeakMap<object, string>();
20
+
12
21
  /**
13
22
  * Build a client descriptor from runtime routes + flow defs (type strings + stamps).
14
23
  *
@@ -47,6 +56,7 @@ export function buildClientDescriptor(
47
56
  ...(stamp.stream === true ? { stream: true as const } : {}),
48
57
  ...(stamp.matchKey !== undefined ? { matchKey: stamp.matchKey } : {}),
49
58
  ...(stamp.gates !== undefined && stamp.gates.length > 0 ? { gates: stamp.gates } : {}),
59
+ ...(def?.resolvedIdempotency !== undefined ? { idempotency: def.resolvedIdempotency } : {}),
50
60
  };
51
61
  }
52
62
  out[unit] = unitOut;
@@ -62,6 +72,20 @@ export function buildClientDescriptor(
62
72
  */
63
73
  export function schemaToTsString(schema: unknown): string {
64
74
  if (schema === undefined || schema === null) return "unknown";
75
+ if (typeof schema !== "object" && typeof schema !== "function") return "unknown";
76
+ const cached = schemaTsCache.get(schema);
77
+ if (cached !== undefined) return cached;
78
+ const printed = printSchemaToTs(schema);
79
+ schemaTsCache.set(schema, printed);
80
+ return printed;
81
+ }
82
+
83
+ /**
84
+ * Convert one schema object to a TypeScript type string. Uncached.
85
+ *
86
+ * @param schema - Object or function schema
87
+ */
88
+ function printSchemaToTs(schema: object): string {
65
89
  try {
66
90
  if (hasToJSONSchema(schema)) {
67
91
  return jsonSchemaToTs(schema.toJSONSchema());
@@ -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
+ };