okengine 0.19.9 → 0.21.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 +1 -1
- package/package.json +2 -1
- package/site/content/docs/client/auth.mdx +1 -2
- package/site/content/docs/client/calling.mdx +125 -37
- package/site/content/docs/client/index.mdx +7 -7
- package/site/content/docs/client/react.mdx +5 -0
- package/site/content/docs/elements/channel/email.mdx +25 -36
- package/site/content/docs/elements/channel/index.mdx +88 -46
- package/site/content/docs/elements/channel/push.mdx +7 -9
- package/site/content/docs/elements/channel/sms.mdx +6 -4
- package/site/content/docs/elements/channel/whatsapp.mdx +11 -9
- package/site/content/docs/elements/clock/index.mdx +14 -25
- package/site/content/docs/elements/flow/http.mdx +42 -23
- package/site/content/docs/elements/flow/index.mdx +41 -30
- package/site/content/docs/elements/flow/routing.mdx +166 -122
- package/site/content/docs/elements/gate/rls.mdx +2 -2
- package/site/content/docs/elements/gate/tenancy.mdx +1 -1
- package/site/content/docs/elements/store/files.mdx +11 -5
- package/site/content/docs/elements/store/index.mdx +13 -6
- package/site/content/docs/elements/store/kv.mdx +8 -2
- package/site/content/docs/elements/store/search.mdx +5 -5
- package/site/content/docs/elements/store/sql.mdx +100 -39
- package/site/content/docs/elements/vault/index.mdx +14 -13
- package/site/content/docs/elements/vault/secrets.mdx +6 -3
- package/site/content/docs/index.mdx +1 -1
- package/site/content/docs/plugins/magic-link.mdx +4 -3
- package/site/content/docs/plugins/otp.mdx +4 -3
- package/site/content/docs/plugins/two-factor.mdx +4 -0
- package/site/content/docs/providers/index.mdx +1 -1
- package/site/content/docs/recipes/index.mdx +1 -1
- package/site/content/docs/recipes/rustfs.mdx +1 -1
- package/site/content/docs/reference/cli.mdx +7 -4
- package/site/content/docs/reference/configuration.mdx +8 -8
- package/site/content/docs/reference/errors.mdx +229 -55
- package/site/content/docs/reference/fx.mdx +22 -9
- package/site/content/docs/reference/i18n.mdx +4 -4
- package/site/content/docs/reference/plugins.mdx +4 -4
- package/site/content/docs/understand/the-architecture.mdx +2 -2
- package/site/content/docs/understand/try-it.mdx +759 -24
- package/src/cli/ai-setup/ai-setup.test.ts +40 -0
- package/src/cli/ai-setup/apply.ts +28 -46
- package/src/cli/build.test.ts +3 -3
- package/src/cli/build.ts +5 -5
- package/src/cli/db-auto-push.test.ts +11 -0
- package/src/cli/db-auto-push.ts +6 -2
- package/src/cli/db.test.ts +1 -1
- package/src/cli/db.ts +6 -6
- package/src/cli/dev-app-runner.ts +2 -1
- package/src/cli/dev-db-push.test.ts +6 -2
- package/src/cli/dev-schema-sync.ts +1 -1
- package/src/cli/dev.test.ts +10 -7
- package/src/cli/dev.ts +13 -11
- package/src/cli/ensure-drizzle-config.ts +4 -3
- package/src/cli/start.ts +2 -1
- package/src/client/create.ts +3 -3
- package/src/client/explain.test.ts +252 -0
- package/src/client/explain.ts +272 -0
- package/src/client/live.test.ts +44 -0
- package/src/client/notes-contract.test.ts +10 -0
- package/src/client/sse.ts +6 -2
- package/src/client/transport.test.ts +67 -0
- package/src/client/transport.ts +24 -40
- package/src/client/types.ts +17 -6
- package/src/client-react/browser.test.ts +23 -0
- package/src/client-react/live-resource.ts +6 -2
- package/src/client-react/use-live-query.ts +1 -1
- package/src/compiler/flow-path.test.ts +1 -0
- package/src/compiler/flow-path.ts +1 -1
- package/src/compiler/generate-adopt.test.ts +55 -1
- package/src/compiler/generate-adopt.ts +111 -21
- package/src/compiler/response.ts +17 -27
- package/src/config/index.ts +6 -4
- package/src/console/server/invoke-user-flow.test.ts +8 -2
- package/src/console/server/invoke-user-flow.ts +12 -18
- package/src/console/server/security.gate.test.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-BoC83Ubl.js → access-page-tIbsiphz.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-CKjAEOqA.js → agent-disclosure-CSKumwS2.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-Ceaq9pYh.js → cache-glyph-BCC-DxKT.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-C4lmY7ck.js → call-pii-button-jOmYISdc.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-82y257sL.js → collapsible-DC2xNaAb.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-W63jaMZ8.js → duration-tone-CwoV56jn.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-KFTFj2rK.js → flows-page-gT1lsWQK.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-yE9zNTqC.js → highlighted-json-C2GZEJNI.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-CwFeFroN.js → http-method-BdYjcIrD.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-r7xXt_VV.js → index-Cul17AcV.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-BDiliMiC.js → observability-page-oY9vdYBk.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-DYDzWUFT.js → replica-lag-C4QdAF7J.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-CzrOfgiz.js → request-meta-C43DHyld.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CZC2cwaw.js → store-page-CL0D9dOq.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-DgeeejW7.js → trace-detail-sheet-Dcpo4Us_.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CzGIyOPY.js → tree-expand-toggle-CKJTuv43.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DBiDCLIB.js → units-page-1PlT19ft.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-CcHsthPe.js → vault-page-BwZ9YjTW.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/docker/docker.test.ts +3 -3
- package/src/docker/images-config.test.ts +4 -4
- package/src/docker/stack-id.test.ts +1 -1
- package/src/drivers/clock-postgres.test.ts +10 -2
- package/src/drivers/clock-postgres.ts +18 -2
- package/src/drivers/vault-driver-removal.test.ts +2 -2
- package/src/elements/channel/declare.ts +66 -3
- package/src/elements/channel/runtime.ts +9 -11
- package/src/elements/channel.test.ts +42 -0
- package/src/elements/channel.ts +4 -2
- package/src/elements/clock/reconcile.ts +45 -24
- package/src/elements/clock.test.ts +33 -0
- package/src/elements/store/emit-drizzle.ts +285 -65
- package/src/elements/store/files-errors.test.ts +149 -0
- package/src/elements/store/files-errors.ts +189 -0
- package/src/elements/store/kv-errors.test.ts +98 -0
- package/src/elements/store/kv-errors.ts +139 -0
- package/src/elements/store/load-plugin-tables.ts +1 -1
- package/src/elements/store/prepare-row.test.ts +57 -4
- package/src/elements/store/resource.ts +11 -7
- package/src/elements/store/runtime.ts +18 -13
- package/src/elements/store/schema-decl.test.ts +178 -0
- package/src/elements/store/sql-errors.test.ts +197 -0
- package/src/elements/store/sql-errors.ts +294 -0
- package/src/elements/store/sql-session.test.ts +52 -0
- package/src/elements/store/sql-session.ts +50 -2
- package/src/elements/store/store-errors.ts +47 -0
- package/src/elements/store/table.ts +8 -6
- package/src/http.ts +9 -1
- package/src/i18n/catalogs/ar.ts +18 -0
- package/src/i18n/catalogs/en.ts +18 -0
- package/src/index.ts +9 -1
- package/src/kernel/adopt-barrel-fresh.test.ts +1 -1
- package/src/kernel/app.ts +40 -34
- package/src/kernel/auto-registry.test.ts +26 -1
- package/src/kernel/boot.ts +2 -2
- package/src/kernel/boundary-contract.ts +6 -1
- package/src/kernel/builtin-errors.test.ts +117 -0
- package/src/kernel/builtin-errors.ts +129 -0
- package/src/kernel/call.test.ts +182 -0
- package/src/kernel/errors-vault.ts +16 -0
- package/src/kernel/errors.registry.test.ts +7 -0
- package/src/kernel/errors.ts +97 -24
- package/src/kernel/fail-helpers.ts +34 -0
- package/src/kernel/flow-units.ts +3 -3
- package/src/kernel/fx.test.ts +8 -0
- package/src/kernel/fx.ts +24 -8
- package/src/kernel/index.ts +12 -1
- package/src/kernel/mutation-id.ts +8 -0
- package/src/kernel/plugin.ts +4 -3
- package/src/kernel/project-out.test.ts +176 -0
- package/src/kernel/project-out.ts +91 -0
- package/src/kernel/realtime-bind.ts +2 -3
- package/src/kernel/router/linear.ts +12 -6
- package/src/kernel/router.test.ts +13 -0
- package/src/plugins/magic-link.ts +25 -24
- package/src/plugins/otp.ts +35 -24
- package/src/plugins/two-factor.ts +15 -0
- package/src/runs/duckdb.test.ts +2 -2
- package/src/runtime/dev-request-log.ts +29 -11
- package/src/term.test.ts +76 -0
- package/src/term.ts +166 -3
package/src/kernel/app.ts
CHANGED
|
@@ -39,15 +39,16 @@ import { requirePackageModule } from "../shared/lazy-src.ts";
|
|
|
39
39
|
import { lazyRequire } from "./lazy-require.ts";
|
|
40
40
|
import type { DurableResult, RunDurableOptions } from "../elements/clock/durable.ts";
|
|
41
41
|
import { applyClockTimezoneDefaults } from "../elements/clock/declare.ts";
|
|
42
|
-
import
|
|
42
|
+
import { catalogFromTemplates, mergeTemplateCatalogs } from "../elements/channel/declare.ts";
|
|
43
43
|
import { parseAcceptLanguage } from "../elements/channel/locale.ts";
|
|
44
44
|
import { runWithLocale } from "../i18n/locale-context.ts";
|
|
45
45
|
import { isFlow, type AnyFlowDef } from "./flow.ts";
|
|
46
46
|
import { failureFromUnknown, runCompensationPhase } from "./compensate.ts";
|
|
47
47
|
import { withAbortSignal } from "./abort-scope.ts";
|
|
48
48
|
import { withCdcMutationId } from "../elements/store/sql-session.ts";
|
|
49
|
-
import { MUTATION_ID_HEADER } from "./
|
|
49
|
+
import { MUTATION_ID_HEADER } from "./mutation-id.ts";
|
|
50
50
|
import { fxRetry } from "./concurrency.ts";
|
|
51
|
+
import { projectFlowOut } from "./project-out.ts";
|
|
51
52
|
import type {
|
|
52
53
|
CreateFxOptions,
|
|
53
54
|
Fx,
|
|
@@ -60,7 +61,7 @@ import type {
|
|
|
60
61
|
} from "./fx.ts";
|
|
61
62
|
import {
|
|
62
63
|
currentDevSurface,
|
|
63
|
-
|
|
64
|
+
failureEnvelopeFromResponse,
|
|
64
65
|
logDevRequest,
|
|
65
66
|
shouldLogDevRequests,
|
|
66
67
|
} from "../runtime/dev-request-log.ts";
|
|
@@ -350,6 +351,11 @@ export interface ExecuteResult {
|
|
|
350
351
|
readonly failure: PipelineResult["failure"];
|
|
351
352
|
readonly response: Response | undefined;
|
|
352
353
|
readonly ctx: InvocationContext;
|
|
354
|
+
/**
|
|
355
|
+
* Derived projection of {@link InvocationContext.error} — never a separate
|
|
356
|
+
* store. Always `=== ctx.error` by reference.
|
|
357
|
+
*/
|
|
358
|
+
readonly error: unknown;
|
|
353
359
|
readonly fx: Fx;
|
|
354
360
|
/** Wide-event cache dimension from this invocation's telemetry. */
|
|
355
361
|
readonly cache: "hit" | "miss" | "none";
|
|
@@ -381,13 +387,13 @@ export interface UnitHooks<D extends Record<string, unknown> = {}> {
|
|
|
381
387
|
}
|
|
382
388
|
|
|
383
389
|
/**
|
|
384
|
-
* Module-augmentation slot filled by `src/flows/
|
|
390
|
+
* Module-augmentation slot filled by `src/flows/index.ts`.
|
|
385
391
|
* Kernel tests do not import an app generated file, so this stays `{}`.
|
|
386
392
|
*/
|
|
387
393
|
export interface RegisteredFlowUnits {}
|
|
388
394
|
|
|
389
395
|
/**
|
|
390
|
-
* `$routes` derived from {@link RegisteredFlowUnits} after `import
|
|
396
|
+
* `$routes` derived from {@link RegisteredFlowUnits} after `import "@/flows"`.
|
|
391
397
|
*
|
|
392
398
|
* @typeParam U - Augmented unit map
|
|
393
399
|
*/
|
|
@@ -853,7 +859,7 @@ function registerOnceSignalBinding(
|
|
|
853
859
|
}
|
|
854
860
|
|
|
855
861
|
/**
|
|
856
|
-
* Fold `
|
|
862
|
+
* Fold `src/flows/index.ts` units into `$routes` and `flowsByName`.
|
|
857
863
|
*
|
|
858
864
|
* @param units - Drained {@link registerFlowUnits} bag
|
|
859
865
|
* @param routes - Runtime `$routes`
|
|
@@ -967,12 +973,21 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
967
973
|
// explicit `options.channel` nor a registered template exists — a defined
|
|
968
974
|
// object here would flip `resolveElementNeeds`'s `channel` need to `true`
|
|
969
975
|
// for every app, whether or not it uses channel at all.
|
|
976
|
+
const channelTemplates = mergeUnique(
|
|
977
|
+
options.channel?.templates,
|
|
978
|
+
registrySnapshot.channelTemplates,
|
|
979
|
+
);
|
|
980
|
+
const channelCatalog = mergeTemplateCatalogs(
|
|
981
|
+
catalogFromTemplates(channelTemplates),
|
|
982
|
+
options.channel?.catalog,
|
|
983
|
+
);
|
|
970
984
|
const effectiveChannel: BootOptions["channel"] =
|
|
971
985
|
options.channel === undefined && registrySnapshot.channelTemplates.length === 0
|
|
972
986
|
? undefined
|
|
973
987
|
: {
|
|
974
988
|
...(options.channel ?? {}),
|
|
975
|
-
templates:
|
|
989
|
+
templates: channelTemplates,
|
|
990
|
+
...(channelCatalog ? { catalog: channelCatalog } : {}),
|
|
976
991
|
};
|
|
977
992
|
// Same undefined-when-empty rule for AI — a bare `{}` would force the AI
|
|
978
993
|
// runtime open even when the app never declared models / prompts.
|
|
@@ -1444,7 +1459,13 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1444
1459
|
const baseSignals = overrides?.signals ?? effectiveSignals;
|
|
1445
1460
|
const baseClocks = overrides?.clocks ?? effectiveClocks;
|
|
1446
1461
|
const baseChannel = overrides?.channel ?? effectiveChannel;
|
|
1447
|
-
const
|
|
1462
|
+
const mergedTemplates = [...(baseChannel?.templates ?? []), ...pluginChannelTemplates];
|
|
1463
|
+
const mergedCatalog = mergeTemplateCatalogs(
|
|
1464
|
+
catalogFromTemplates(baseChannel?.templates),
|
|
1465
|
+
baseChannel?.catalog,
|
|
1466
|
+
catalogFromTemplates(pluginChannelTemplates),
|
|
1467
|
+
...pluginChannelCatalogs,
|
|
1468
|
+
);
|
|
1448
1469
|
|
|
1449
1470
|
cdcPkByTable = pkColumnByTableFromManifest(overrides?.manifest ?? options.manifest);
|
|
1450
1471
|
|
|
@@ -1465,7 +1486,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1465
1486
|
stores: overrides?.stores ?? effectiveStores,
|
|
1466
1487
|
channel: {
|
|
1467
1488
|
...(baseChannel ?? {}),
|
|
1468
|
-
templates:
|
|
1489
|
+
templates: mergedTemplates,
|
|
1469
1490
|
...(mergedCatalog ? { catalog: mergedCatalog } : {}),
|
|
1470
1491
|
defaultLocale:
|
|
1471
1492
|
baseChannel?.defaultLocale ??
|
|
@@ -1899,6 +1920,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1899
1920
|
},
|
|
1900
1921
|
);
|
|
1901
1922
|
if (inner.failure) return inner.failure;
|
|
1923
|
+
if (inner.ctx.error !== undefined) throw inner.ctx.error;
|
|
1902
1924
|
return inner.output;
|
|
1903
1925
|
},
|
|
1904
1926
|
});
|
|
@@ -1965,7 +1987,8 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1965
1987
|
journalSession?.rewind();
|
|
1966
1988
|
return flowDef.do(input as never, fx);
|
|
1967
1989
|
};
|
|
1968
|
-
const
|
|
1990
|
+
const raw = await (flowDef.retry ? fxRetry(run, flowDef.retry) : run());
|
|
1991
|
+
const output = isFlowFailure(raw) ? raw : await projectFlowOut(flowDef.out, raw);
|
|
1969
1992
|
if (!isFlowFailure(output) && cache && storeRt && cacheEffects) {
|
|
1970
1993
|
const ledgerFx = cache.effectsFromLedger(ledger.entries);
|
|
1971
1994
|
const writeEffects: Effects = {
|
|
@@ -2127,6 +2150,9 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2127
2150
|
failure: result.failure,
|
|
2128
2151
|
response: result.response,
|
|
2129
2152
|
ctx: result.ctx,
|
|
2153
|
+
get error() {
|
|
2154
|
+
return result.ctx.error;
|
|
2155
|
+
},
|
|
2130
2156
|
fx,
|
|
2131
2157
|
cache: cacheDimensionOf(telemetry),
|
|
2132
2158
|
durationMs,
|
|
@@ -2311,6 +2337,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2311
2337
|
response.headers.set("accept-query", '"application/json"');
|
|
2312
2338
|
}
|
|
2313
2339
|
if (shouldLogDevRequests()) {
|
|
2340
|
+
const envelope = await failureEnvelopeFromResponse(response);
|
|
2314
2341
|
logDevRequest({
|
|
2315
2342
|
surface: currentDevSurface(),
|
|
2316
2343
|
method,
|
|
@@ -2319,7 +2346,8 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2319
2346
|
runId: runLabel,
|
|
2320
2347
|
status: response.status,
|
|
2321
2348
|
ms: Math.round(performance.now() - started),
|
|
2322
|
-
detail:
|
|
2349
|
+
detail: envelope.detail,
|
|
2350
|
+
errorCode: envelope.code,
|
|
2323
2351
|
});
|
|
2324
2352
|
}
|
|
2325
2353
|
const jcb = loadJsonCodeBlock();
|
|
@@ -2535,6 +2563,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
2535
2563
|
flowDef.triggers[0] ?? ({ kind: "internal" } satisfies InternalTrigger);
|
|
2536
2564
|
const result = await execute(flowDef, input, trigger);
|
|
2537
2565
|
if (result.failure) return result.failure;
|
|
2566
|
+
if (result.ctx.error !== undefined) throw result.ctx.error;
|
|
2538
2567
|
return result.output;
|
|
2539
2568
|
},
|
|
2540
2569
|
resolveMcpTool(name) {
|
|
@@ -2616,28 +2645,5 @@ function redactArchivedFields(input: unknown, fields: readonly string[] | undefi
|
|
|
2616
2645
|
return obj;
|
|
2617
2646
|
}
|
|
2618
2647
|
|
|
2619
|
-
/**
|
|
2620
|
-
* Deep-merge channel template catalogs (later parts win per locale).
|
|
2621
|
-
*
|
|
2622
|
-
* @param parts - Catalog fragments (undefined skipped)
|
|
2623
|
-
*/
|
|
2624
|
-
function mergeTemplateCatalogs(
|
|
2625
|
-
...parts: readonly (TemplateCatalog | undefined)[]
|
|
2626
|
-
): TemplateCatalog | undefined {
|
|
2627
|
-
const out: Record<
|
|
2628
|
-
string,
|
|
2629
|
-
Record<string, { readonly subject?: string; readonly text?: string; readonly html?: string }>
|
|
2630
|
-
> = {};
|
|
2631
|
-
let any = false;
|
|
2632
|
-
for (const part of parts) {
|
|
2633
|
-
if (!part) continue;
|
|
2634
|
-
any = true;
|
|
2635
|
-
for (const [template, locales] of Object.entries(part)) {
|
|
2636
|
-
out[template] = { ...(out[template] ?? {}), ...locales };
|
|
2637
|
-
}
|
|
2638
|
-
}
|
|
2639
|
-
return any ? out : undefined;
|
|
2640
|
-
}
|
|
2641
|
-
|
|
2642
2648
|
/** @internal expose smart router type for tests */
|
|
2643
2649
|
export type { HttpTrigger, SmartRouter };
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { describe, expect, test } from "bun:test";
|
|
16
|
-
import { channel } from "../elements/channel/declare.ts";
|
|
16
|
+
import { channel, resetChannelTemplates } from "../elements/channel/declare.ts";
|
|
17
17
|
import { clock } from "../elements/clock/declare.ts";
|
|
18
18
|
import { gate } from "../elements/gate/declare.ts";
|
|
19
19
|
import { signal } from "../elements/signal/declare.ts";
|
|
@@ -291,4 +291,29 @@ describe("oke() auto-registry — stores/secrets/signals/clocks/gates/channel.te
|
|
|
291
291
|
const json = (await res.json()) as { data: { summary: string } };
|
|
292
292
|
expect(json.data.summary).toBe("one-line summary");
|
|
293
293
|
});
|
|
294
|
+
|
|
295
|
+
test("channel.template({ catalog }) drains into oke() without channel.catalog bag", () => {
|
|
296
|
+
resetBindings();
|
|
297
|
+
resetChannelTemplates();
|
|
298
|
+
const mail = channel.email({ from: "Drain <drain@localhost>" });
|
|
299
|
+
mail.template("welcome-drain", {
|
|
300
|
+
locales: ["en"],
|
|
301
|
+
catalog: {
|
|
302
|
+
en: { subject: "Hi {{name}}", text: "Hello {{name}}" },
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
const sms = channel.sms();
|
|
306
|
+
sms.template("ops.drain", {
|
|
307
|
+
catalog: { en: { text: "Disk {{pct}}%" } },
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const app = oke({
|
|
311
|
+
name: "catalog-drain",
|
|
312
|
+
autoBoot: false,
|
|
313
|
+
startScheduler: false,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
expect(app.$options.channel?.catalog?.["welcome-drain"]?.en?.subject).toBe("Hi {{name}}");
|
|
317
|
+
expect(app.$options.channel?.catalog?.["ops.drain"]?.en?.text).toBe("Disk {{pct}}%");
|
|
318
|
+
});
|
|
294
319
|
});
|
package/src/kernel/boot.ts
CHANGED
|
@@ -804,7 +804,7 @@ async function tryListFlowsUnits(rootDir: string): Promise<readonly string[] | u
|
|
|
804
804
|
/**
|
|
805
805
|
* Confirm every `src/flows/<unit>` folder on disk actually reached this
|
|
806
806
|
* boot's adopted flows — the disk-file counterpart of a stale/missing
|
|
807
|
-
* generated `.adopt()` barrel (`src/flows/
|
|
807
|
+
* generated `.adopt()` barrel (`src/flows/index.ts`). A folder present
|
|
808
808
|
* on disk with zero adopted flows under that unit means the barrel wasn't
|
|
809
809
|
* regenerated (or was hand-edited) after the folder was added.
|
|
810
810
|
*
|
|
@@ -846,7 +846,7 @@ export async function assertAdoptBarrelFresh(
|
|
|
846
846
|
staleAdoptBarrelWarned = true;
|
|
847
847
|
emitBootWarn(
|
|
848
848
|
`oke boot: src/flows/${missing[0]} exists on disk but adopted no flows — the ` +
|
|
849
|
-
'.adopt() barrel ("src/flows/
|
|
849
|
+
'.adopt() barrel ("src/flows/index.ts") is stale. Run `oke dev` or `oke build` ' +
|
|
850
850
|
"to regenerate it — dev+compose/prod refuse to boot this way.",
|
|
851
851
|
);
|
|
852
852
|
}
|
|
@@ -16,7 +16,12 @@ export interface BoundaryContract<
|
|
|
16
16
|
> {
|
|
17
17
|
/** Request / args schema (Standard Schema). */
|
|
18
18
|
readonly in?: ISchema;
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Success reply schema (Manifest + typed client). Success values are
|
|
21
|
+
* projected onto it (`fx.json.create(row)` / `return row`) — Date timestamps
|
|
22
|
+
* become ISO-8601; extra keys strip when parse succeeds. Miss is not a client
|
|
23
|
+
* error.
|
|
24
|
+
*/
|
|
20
25
|
readonly out?: OSchema;
|
|
21
26
|
/** Typed domain errors (`fx.fail`). */
|
|
22
27
|
readonly errors?: E;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in failure HTTP status map.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { fail } from "./errors.ts";
|
|
7
|
+
import { httpStatusForFailure, statusForBuiltinError } from "./builtin-errors.ts";
|
|
8
|
+
import { encodeExecuteResult, statusForFailure } from "../compiler/response.ts";
|
|
9
|
+
|
|
10
|
+
describe("statusForBuiltinError", () => {
|
|
11
|
+
test("maps REST codes", () => {
|
|
12
|
+
expect(statusForBuiltinError("NotFound")).toBe(404);
|
|
13
|
+
expect(statusForBuiltinError("Conflict")).toBe(409);
|
|
14
|
+
expect(statusForBuiltinError("ForeignKey")).toBe(409);
|
|
15
|
+
expect(statusForBuiltinError("Unauthorized")).toBe(401);
|
|
16
|
+
expect(statusForBuiltinError("Forbidden")).toBe(403);
|
|
17
|
+
expect(statusForBuiltinError("RateLimited")).toBe(429);
|
|
18
|
+
expect(statusForBuiltinError("AuthRateLimited")).toBe(429);
|
|
19
|
+
expect(statusForBuiltinError("ValidationError")).toBe(422);
|
|
20
|
+
expect(statusForBuiltinError("UnsupportedMediaType")).toBe(415);
|
|
21
|
+
expect(statusForBuiltinError("ServiceUnavailable")).toBe(503);
|
|
22
|
+
expect(statusForBuiltinError("InternalError")).toBe(500);
|
|
23
|
+
expect(statusForBuiltinError("AuthFailed")).toBe(400);
|
|
24
|
+
expect(statusForBuiltinError("InvalidQuery")).toBe(400);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("DatabaseError status follows data.reason", () => {
|
|
28
|
+
expect(statusForBuiltinError("DatabaseError", { reason: "not_null" })).toBe(422);
|
|
29
|
+
expect(statusForBuiltinError("DatabaseError", { reason: "check" })).toBe(422);
|
|
30
|
+
expect(statusForBuiltinError("DatabaseError", { reason: "invalid" })).toBe(422);
|
|
31
|
+
expect(statusForBuiltinError("DatabaseError", { reason: "too_long" })).toBe(422);
|
|
32
|
+
expect(statusForBuiltinError("DatabaseError", { reason: "out_of_range" })).toBe(422);
|
|
33
|
+
expect(statusForBuiltinError("DatabaseError", { reason: "retryable" })).toBe(503);
|
|
34
|
+
expect(statusForBuiltinError("DatabaseError", { reason: "unknown" })).toBe(500);
|
|
35
|
+
expect(statusForBuiltinError("DatabaseError", {})).toBe(500);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("domain codes are undefined so HTTP defaults to 400", () => {
|
|
39
|
+
expect(statusForBuiltinError("OutOfStock")).toBeUndefined();
|
|
40
|
+
expect(httpStatusForFailure("OutOfStock")).toBe(400);
|
|
41
|
+
expect(httpStatusForFailure("OKE1110")).toBe(500);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("statusForFailure", () => {
|
|
46
|
+
test("reads DatabaseError reason from the envelope", () => {
|
|
47
|
+
expect(statusForFailure(fail("DatabaseError", { reason: "not_null" }))).toBe(422);
|
|
48
|
+
expect(statusForFailure(fail("NotFound", { id: "n1" }))).toBe(404);
|
|
49
|
+
expect(statusForFailure(fail("FlightFull", { seatsLeft: 0 }))).toBe(400);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("encodeExecuteResult InternalError", () => {
|
|
54
|
+
test("never copies the thrown Error.message", async () => {
|
|
55
|
+
const res = await encodeExecuteResult({ error: new Error("secret leak") });
|
|
56
|
+
expect(res.status).toBe(500);
|
|
57
|
+
const body = (await res.json()) as {
|
|
58
|
+
data: null;
|
|
59
|
+
error: { code: string; message?: string; data?: unknown };
|
|
60
|
+
};
|
|
61
|
+
expect(body.error.code).toBe("InternalError");
|
|
62
|
+
expect(JSON.stringify(body)).not.toContain("secret leak");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("maps leftover unique SQLSTATE to Conflict", async () => {
|
|
66
|
+
const res = await encodeExecuteResult({
|
|
67
|
+
error: {
|
|
68
|
+
code: "23505",
|
|
69
|
+
message: "duplicate key value violates unique constraint",
|
|
70
|
+
detail: "Key (email)=(a@b.c) already exists.",
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
expect(res.status).toBe(409);
|
|
74
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
75
|
+
expect(body.error.code).toBe("Conflict");
|
|
76
|
+
expect(JSON.stringify(body)).not.toContain("a@b.c");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("maps leftover Redis connection failure to ServiceUnavailable", async () => {
|
|
80
|
+
const res = await encodeExecuteResult({
|
|
81
|
+
error: Object.assign(new Error("Connection closed"), { code: "ECONNREFUSED" }),
|
|
82
|
+
});
|
|
83
|
+
expect(res.status).toBe(503);
|
|
84
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
85
|
+
expect(body.error.code).toBe("ServiceUnavailable");
|
|
86
|
+
expect(JSON.stringify(body)).not.toContain("Connection closed");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("maps leftover S3 AccessDenied to Forbidden", async () => {
|
|
90
|
+
const res = await encodeExecuteResult({
|
|
91
|
+
error: Object.assign(new Error("Access Denied"), { code: "AccessDenied", status: 403 }),
|
|
92
|
+
});
|
|
93
|
+
expect(res.status).toBe(403);
|
|
94
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
95
|
+
expect(body.error.code).toBe("Forbidden");
|
|
96
|
+
expect(JSON.stringify(body)).not.toContain("Access Denied");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("leaves WRONGTYPE as InternalError", async () => {
|
|
100
|
+
const res = await encodeExecuteResult({
|
|
101
|
+
error: new Error("WRONGTYPE Operation against a key holding the wrong kind of value"),
|
|
102
|
+
});
|
|
103
|
+
expect(res.status).toBe(500);
|
|
104
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
105
|
+
expect(body.error.code).toBe("InternalError");
|
|
106
|
+
expect(JSON.stringify(body)).not.toContain("WRONGTYPE");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("maps exhausted serialization failure to ServiceUnavailable", async () => {
|
|
110
|
+
const res = await encodeExecuteResult({
|
|
111
|
+
error: { code: "40001", message: "could not serialize access" },
|
|
112
|
+
});
|
|
113
|
+
expect(res.status).toBe(503);
|
|
114
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
115
|
+
expect(body.error.code).toBe("ServiceUnavailable");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in flow-boundary error codes — HTTP status + payload types.
|
|
3
|
+
*
|
|
4
|
+
* Always-on for `fx.fail` helpers and the typed client. Domain codes
|
|
5
|
+
* (`OutOfStock`, `FlightFull`) stay on the exposure `errors:` bag.
|
|
6
|
+
* No Zod — this module sits on the kernel edge graph.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Built-in codes with a fixed HTTP status (not `DatabaseError`). */
|
|
10
|
+
export const BUILTIN_ERROR_STATUS = {
|
|
11
|
+
ValidationError: 422,
|
|
12
|
+
Unauthorized: 401,
|
|
13
|
+
Forbidden: 403,
|
|
14
|
+
NotFound: 404,
|
|
15
|
+
Conflict: 409,
|
|
16
|
+
ForeignKey: 409,
|
|
17
|
+
UnsupportedMediaType: 415,
|
|
18
|
+
RateLimited: 429,
|
|
19
|
+
AuthRateLimited: 429,
|
|
20
|
+
InvalidQuery: 400,
|
|
21
|
+
AuthFailed: 400,
|
|
22
|
+
InternalError: 500,
|
|
23
|
+
ServiceUnavailable: 503,
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
/** Built-in error code (including `DatabaseError`). */
|
|
27
|
+
export type BuiltinErrorCode = keyof typeof BUILTIN_ERROR_STATUS | "DatabaseError";
|
|
28
|
+
|
|
29
|
+
/** `DatabaseError.data.reason` — encoder maps these to HTTP status. */
|
|
30
|
+
export type DatabaseErrorReason =
|
|
31
|
+
| "not_null"
|
|
32
|
+
| "check"
|
|
33
|
+
| "invalid"
|
|
34
|
+
| "too_long"
|
|
35
|
+
| "out_of_range"
|
|
36
|
+
| "retryable"
|
|
37
|
+
| "unknown";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One `ValidationError` issue — same shape as the validation layer,
|
|
41
|
+
* inlined so this module stays off that graph.
|
|
42
|
+
*/
|
|
43
|
+
export type BuiltinValidationIssue = {
|
|
44
|
+
readonly message: string;
|
|
45
|
+
readonly path: ReadonlyArray<string | number>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Loose identity / constraint bag used by several built-in codes. */
|
|
49
|
+
export type BuiltinErrorBag = {
|
|
50
|
+
readonly id?: string;
|
|
51
|
+
readonly key?: string;
|
|
52
|
+
readonly code?: string;
|
|
53
|
+
readonly reason?: string;
|
|
54
|
+
readonly gate?: string;
|
|
55
|
+
readonly constraint?: string;
|
|
56
|
+
readonly table?: string;
|
|
57
|
+
readonly column?: string;
|
|
58
|
+
readonly sqlstate?: string;
|
|
59
|
+
readonly retryAfterMs?: number;
|
|
60
|
+
readonly retryAfter?: number;
|
|
61
|
+
readonly contentType?: string;
|
|
62
|
+
readonly [key: string]: unknown;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Built-in error code → payload. Client unions this with declared `errors:`.
|
|
67
|
+
*/
|
|
68
|
+
export type BuiltinErrorMap = {
|
|
69
|
+
readonly ValidationError: { readonly issues: readonly BuiltinValidationIssue[] };
|
|
70
|
+
readonly Unauthorized: BuiltinErrorBag;
|
|
71
|
+
readonly Forbidden: BuiltinErrorBag;
|
|
72
|
+
readonly NotFound: BuiltinErrorBag;
|
|
73
|
+
readonly Conflict: BuiltinErrorBag;
|
|
74
|
+
readonly ForeignKey: BuiltinErrorBag;
|
|
75
|
+
readonly UnsupportedMediaType: BuiltinErrorBag;
|
|
76
|
+
readonly RateLimited: { readonly retryAfterMs?: number };
|
|
77
|
+
readonly AuthRateLimited: BuiltinErrorBag;
|
|
78
|
+
readonly InvalidQuery: BuiltinErrorBag;
|
|
79
|
+
readonly AuthFailed: BuiltinErrorBag;
|
|
80
|
+
readonly DatabaseError: {
|
|
81
|
+
readonly reason: DatabaseErrorReason;
|
|
82
|
+
readonly sqlstate?: string;
|
|
83
|
+
readonly constraint?: string;
|
|
84
|
+
readonly table?: string;
|
|
85
|
+
readonly column?: string;
|
|
86
|
+
};
|
|
87
|
+
readonly InternalError: Record<string, never>;
|
|
88
|
+
readonly ServiceUnavailable: { readonly retryAfter?: number };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* HTTP status for a built-in failure code, or `undefined` for domain codes.
|
|
93
|
+
*
|
|
94
|
+
* `DatabaseError` uses `data.reason`: `retryable` → 503, `unknown` / missing
|
|
95
|
+
* → 500, any other reason (`not_null`, `check`, `invalid`, …) → 422.
|
|
96
|
+
*
|
|
97
|
+
* @param code - Failure `error.code`
|
|
98
|
+
* @param data - Failure `error.data` (reason for `DatabaseError`)
|
|
99
|
+
*/
|
|
100
|
+
export function statusForBuiltinError(code: string, data?: unknown): number | undefined {
|
|
101
|
+
if (code === "DatabaseError") {
|
|
102
|
+
const reason = databaseErrorReason(data);
|
|
103
|
+
if (reason === "retryable") return 503;
|
|
104
|
+
if (reason !== undefined && reason !== "unknown") return 422;
|
|
105
|
+
return 500;
|
|
106
|
+
}
|
|
107
|
+
if (Object.prototype.hasOwnProperty.call(BUILTIN_ERROR_STATUS, code)) {
|
|
108
|
+
return BUILTIN_ERROR_STATUS[code as keyof typeof BUILTIN_ERROR_STATUS];
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* HTTP status for a typed flow failure. Domain codes default to 400.
|
|
115
|
+
*
|
|
116
|
+
* @param code - Failure code
|
|
117
|
+
* @param data - Failure payload
|
|
118
|
+
*/
|
|
119
|
+
export function httpStatusForFailure(code: string, data?: unknown): number {
|
|
120
|
+
const mapped = statusForBuiltinError(code, data);
|
|
121
|
+
if (mapped !== undefined) return mapped;
|
|
122
|
+
return code.startsWith("OKE") ? 500 : 400;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function databaseErrorReason(data: unknown): string | undefined {
|
|
126
|
+
if (data === null || typeof data !== "object") return undefined;
|
|
127
|
+
const reason = (data as { reason?: unknown }).reason;
|
|
128
|
+
return typeof reason === "string" ? reason : undefined;
|
|
129
|
+
}
|