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.
- package/AGENTS.md +4 -4
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +15 -9
- package/manifest.v1.schema.json +10 -0
- package/package.json +22 -25
- package/site/content/docs/ai/skills.mdx +1 -1
- package/site/content/docs/client/calling.mdx +27 -19
- package/site/content/docs/client/index.mdx +1 -1
- package/site/content/docs/elements/channel/email.mdx +1 -1
- package/site/content/docs/elements/channel/index.mdx +1 -1
- package/site/content/docs/elements/flow/http.mdx +4 -4
- package/site/content/docs/elements/flow/index.mdx +3 -3
- package/site/content/docs/elements/flow/meta.json +1 -1
- package/site/content/docs/elements/gate/tenancy.mdx +1 -1
- package/site/content/docs/elements/vault/rotation.mdx +15 -0
- package/site/content/docs/plugins/otp.mdx +31 -31
- package/site/content/docs/recipes/mailpit.mdx +1 -1
- package/site/content/docs/recipes/meilisearch.mdx +1 -1
- package/site/content/docs/recipes/pgdog.mdx +1 -1
- package/site/content/docs/reference/configuration.mdx +2 -2
- package/site/content/docs/reference/errors.mdx +12 -2
- package/site/content/docs/reference/fx.mdx +28 -15
- package/site/content/docs/reference/idempotency.mdx +192 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +2 -1
- package/site/content/docs/understand/meta.json +1 -1
- package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
- package/site/content/docs/understand/the-architecture.mdx +14 -12
- package/site/content/docs/understand/try-it.mdx +3 -3
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/cli/dev.test.ts +105 -2
- package/src/cli/dev.ts +37 -2
- package/src/cli/docker-cli.test.ts +1 -1
- package/src/cli/index.ts +9 -1
- package/src/cli/load-config.images.test.ts +10 -10
- package/src/cli/load-config.ts +1 -1
- package/src/cli/vault-cmd.test.ts +23 -0
- package/src/cli/vault-cmd.ts +7 -0
- package/src/client/create.ts +31 -27
- package/src/client/live.ts +44 -101
- package/src/client/sse.ts +20 -66
- package/src/client/stream.ts +25 -67
- package/src/client/transport.test.ts +225 -0
- package/src/client/transport.ts +158 -90
- package/src/client/types.ts +29 -3
- package/src/client/wire.ts +119 -0
- package/src/compiler/aot.ts +3 -32
- package/src/compiler/dynamic.ts +13 -11
- package/src/compiler/effects-infer.ts +726 -16
- package/src/compiler/extract.test.ts +37 -0
- package/src/compiler/extract.ts +113 -12
- package/src/compiler/fixtures/skyport.expected.json +24 -0
- package/src/compiler/fx-follow.test.ts +164 -0
- package/src/compiler/fx-index.ts +399 -0
- package/src/compiler/interpret.ts +45 -0
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/ui-next/dist/assets/{access-page-tIbsiphz.js → access-page-Ze6ckjdd.js} +2 -2
- package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
- package/src/console/ui-next/dist/assets/{cache-glyph-BCC-DxKT.js → cache-glyph-rL0lHxat.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-jOmYISdc.js → call-pii-button-Deo4PP18.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-DC2xNaAb.js → collapsible-CoJ6amHf.js} +1 -1
- package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
- package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
- package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
- package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
- package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
- package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-gT1lsWQK.js → flows-page-D2E7BtKK.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-C2GZEJNI.js → highlighted-json-CPYBzDh0.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-BdYjcIrD.js → http-method-DTPevKmM.js} +1 -1
- package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
- package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
- package/src/console/ui-next/dist/assets/{replica-lag-C4QdAF7J.js → replica-lag-BM27lXld.js} +3 -3
- package/src/console/ui-next/dist/assets/{request-meta-C43DHyld.js → request-meta-BjFT7DNl.js} +1 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-Dcpo4Us_.js → trace-detail-sheet-Cmitq3da.js} +2 -2
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CKJTuv43.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
- package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-BwZ9YjTW.js → vault-page-BOMr0go9.js} +2 -2
- package/src/console/ui-next/dist/index.html +2 -2
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
- package/src/docker/docker.test.ts +13 -13
- package/src/docker/images-config.test.ts +12 -12
- package/src/docker/stack-id.test.ts +1 -1
- package/src/drivers/journal-postgres.ts +168 -3
- package/src/drivers/meilisearch.ts +2 -0
- package/src/drivers/vault-builtin.test.ts +10 -0
- package/src/drivers/vault-builtin.ts +13 -1
- package/src/drivers/vault-remote-bag.ts +5 -1
- package/src/drivers/vault-types.ts +6 -1
- package/src/elements/store/emit-drizzle.ts +2 -2
- package/src/elements/store/live-default.test.ts +2 -0
- package/src/elements/store/live-http.test.ts +1 -0
- package/src/elements/store/resource-list-docs.test.ts +2 -2
- package/src/elements/store/resource.test.ts +3 -3
- package/src/elements/store/schema-decl.test.ts +1 -0
- package/src/elements/store/sql-condition.ts +24 -7
- package/src/elements/store/sql-session.ts +20 -4
- package/src/elements/store/upsert-app.test.ts +1 -1
- package/src/elements/vault/audit.test.ts +137 -0
- package/src/elements/vault/audit.ts +106 -13
- package/src/elements/vault/boot-chain.ts +9 -1
- package/src/elements/vault/builtin-adapter.ts +33 -3
- package/src/i18n/catalogs/ar.ts +4 -0
- package/src/i18n/catalogs/en.ts +4 -0
- package/src/kernel/abort-scope.ts +33 -2
- package/src/kernel/app.ts +200 -20
- package/src/kernel/auto-cache.test.ts +6 -6
- package/src/kernel/boot-bind/vault.ts +1 -0
- package/src/kernel/boot.ts +19 -1
- package/src/kernel/builtin-errors.ts +8 -0
- package/src/kernel/client-descriptor.test.ts +78 -0
- package/src/kernel/client-descriptor.ts +24 -0
- package/src/kernel/concurrency.test.ts +33 -0
- package/src/kernel/effects-stamping.test.ts +2 -2
- package/src/kernel/errors-compiler.ts +20 -0
- package/src/kernel/errors-text.ts +99 -0
- package/src/kernel/errors.ts +90 -138
- package/src/kernel/external-effects.test.ts +36 -0
- package/src/kernel/flow.ts +24 -0
- package/src/kernel/fx-fetch.ts +5 -1
- package/src/kernel/fx-sql-handle.ts +305 -0
- package/src/kernel/fx.ts +42 -330
- package/src/kernel/idempotency-store.ts +578 -0
- package/src/kernel/idempotency.test.ts +556 -0
- package/src/kernel/idempotency.ts +304 -0
- package/src/kernel/index.ts +1 -0
- package/src/kernel/journal.ts +56 -0
- package/src/kernel/json-result.ts +59 -0
- package/src/kernel/project-out.ts +6 -1
- package/src/manifest/types.ts +8 -0
- package/src/okid-extended.ts +175 -0
- package/src/okid-shared.ts +103 -0
- package/src/okid.ts +30 -213
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
- package/src/plugins/otp.test.ts +65 -1
- package/src/plugins/otp.ts +59 -7
- package/src/release/absolute-regression.test.ts +63 -0
- package/src/release/build-lib.ts +9 -0
- package/src/release/http-graph.test.ts +25 -0
- package/src/release/http-graph.ts +90 -0
- package/src/release/index.ts +3 -0
- package/src/release/limits.ts +17 -2
- package/src/release/measure.ts +105 -14
- package/src/test/create-test-app.test.ts +3 -1
- package/src/test/create-test-app.ts +40 -0
- package/src/test/live-signals.test.ts +1 -0
- package/src/test/provisions.integration.test.ts +1 -0
- package/src/test/tenant-isolation.test.ts +1 -0
- package/src/console/ui-next/dist/assets/agent-disclosure-CSKumwS2.js +0 -1
- package/src/console/ui-next/dist/assets/duration-tone-CwoV56jn.js +0 -9
- package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
- package/src/console/ui-next/dist/assets/index-Cul17AcV.js +0 -63
- package/src/console/ui-next/dist/assets/observability-page-oY9vdYBk.js +0 -4
- package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-CL0D9dOq.js +0 -41
- 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
|
-
...(
|
|
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
|
|
2218
|
+
return await runClaimed(parsed.value);
|
|
2039
2219
|
}
|
|
2040
|
-
return await
|
|
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,
|
package/src/kernel/boot.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
+
};
|