okengine 0.23.0 → 0.23.2
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/package.json +1 -1
- package/site/content/docs/ai/mcp.mdx +4 -4
- package/site/content/docs/elements/channel/index.mdx +4 -4
- package/site/content/docs/elements/channel/receipts.mdx +7 -5
- package/site/content/docs/elements/channel/sms.mdx +2 -1
- package/site/content/docs/elements/flow/index.mdx +22 -18
- package/site/content/docs/elements/signal/broadcast.mdx +4 -2
- package/site/content/docs/elements/signal/index.mdx +15 -14
- package/site/content/docs/plugins/otp.mdx +3 -3
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- package/site/content/docs/reference/fx.mdx +12 -4
- package/src/auth/tenants.ts +17 -0
- package/src/cli/doctor-diff.test.ts +29 -1
- package/src/cli/doctor-diff.ts +35 -0
- package/src/cli/manifest-pr-diff.ts +16 -0
- package/src/compiler/aot.test.ts +3 -2
- package/src/compiler/aot.ts +45 -1
- package/src/compiler/dynamic.ts +1 -1
- package/src/compiler/effects-fetch.test.ts +77 -0
- package/src/compiler/effects-infer.ts +87 -16
- package/src/compiler/effects-join.test.ts +48 -0
- package/src/compiler/extract.test.ts +28 -0
- package/src/compiler/extract.ts +11 -1
- package/src/compiler/fx-index.ts +58 -4
- package/src/compiler/http-parse.test.ts +101 -0
- package/src/compiler/http-parse.ts +188 -9
- package/src/compiler/interpret.ts +25 -2
- package/src/console/index.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-CHBck4n_.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-parked-approval.ts +5 -1
- package/src/drivers/journal-postgres.test.ts +106 -2
- package/src/drivers/journal-postgres.ts +475 -14
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +238 -0
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +486 -213
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +934 -93
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/runtime.ts +20 -19
- package/src/elements/channel/runtime.ts +42 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +395 -0
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -0
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -0
- package/src/elements/store/cache-bus.test.ts +121 -0
- package/src/elements/store/cache-bus.ts +278 -0
- package/src/elements/store/cache.test.ts +179 -15
- package/src/elements/store/cache.ts +357 -51
- package/src/elements/store/prepare-row.test.ts +6 -2
- package/src/elements/store/runtime.ts +44 -2
- package/src/elements/store/sql-errors.test.ts +131 -0
- package/src/elements/store/sql-errors.ts +39 -16
- package/src/elements/store/sql-nested-tx.test.ts +344 -0
- package/src/elements/store/sql-session.test.ts +2 -0
- package/src/elements/store/sql-session.ts +275 -9
- package/src/elements/store.ts +1 -0
- package/src/kernel/app.ts +207 -43
- package/src/kernel/auto-cache.test.ts +228 -0
- package/src/kernel/auto-registry.test.ts +5 -1
- package/src/kernel/boot-bind/channel.ts +63 -2
- package/src/kernel/boot-bind/honor-config.test.ts +40 -10
- package/src/kernel/boot-bind/signal.ts +72 -25
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +54 -10
- package/src/kernel/boundary-contract.ts +7 -0
- package/src/kernel/builtin-errors.ts +2 -0
- package/src/kernel/call.test.ts +1 -0
- package/src/kernel/errors-compiler.ts +11 -0
- package/src/kernel/errors-text.ts +16 -0
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/flow.ts +33 -3
- package/src/kernel/fx-call-types.test.ts +32 -0
- package/src/kernel/fx-decide.ts +11 -7
- package/src/kernel/fx-sql-handle.ts +52 -7
- package/src/kernel/fx.test.ts +1 -3
- package/src/kernel/fx.ts +27 -16
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +4 -4
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +158 -0
- package/src/kernel/journal.ts +515 -54
- package/src/kernel/pipeline-tenant.ts +5 -1
- package/src/kernel/pipeline.ts +5 -0
- package/src/kernel/signal-tx.ts +28 -0
- package/src/kernel/store-transaction.test.ts +95 -0
- package/src/mcp/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +26 -4
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +404 -33
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +31 -5
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
- package/src/runtime/bun.ts +5 -0
- package/src/runtime/types.ts +5 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { extractFromSources } from "./extract.ts";
|
|
6
7
|
import { inferEffects, type AstNode, type InferBinding } from "./effects-infer.ts";
|
|
7
8
|
|
|
8
9
|
function parseDo(src: string): AstNode {
|
|
@@ -39,5 +40,81 @@ describe("inferEffects — fx.fetch → effects.fetches", () => {
|
|
|
39
40
|
expect(inferred.effects.fetches).toEqual(["api.stripe.com"]);
|
|
40
41
|
expect(inferred.effects.calls).toBeUndefined();
|
|
41
42
|
expect(inferred.effects.sends).toBeUndefined();
|
|
43
|
+
expect(inferred.bareIrreversible).toEqual(["fetch"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("fx.fetch inside fx.step is not a bare irreversible call", () => {
|
|
47
|
+
const stepBody = {
|
|
48
|
+
type: "ArrowFunctionExpression",
|
|
49
|
+
params: [],
|
|
50
|
+
body: {
|
|
51
|
+
type: "BlockStatement",
|
|
52
|
+
body: [
|
|
53
|
+
{
|
|
54
|
+
type: "ExpressionStatement",
|
|
55
|
+
expression: {
|
|
56
|
+
type: "CallExpression",
|
|
57
|
+
callee: {
|
|
58
|
+
type: "MemberExpression",
|
|
59
|
+
object: { type: "Identifier", name: "fx" },
|
|
60
|
+
property: { type: "Identifier", name: "fetch" },
|
|
61
|
+
computed: false,
|
|
62
|
+
},
|
|
63
|
+
arguments: [{ type: "Literal", value: "https://example.com/data" }],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
const inferred = inferEffects({
|
|
70
|
+
doNode: {
|
|
71
|
+
type: "ArrowFunctionExpression",
|
|
72
|
+
params: [
|
|
73
|
+
{ type: "Identifier", name: "_input" },
|
|
74
|
+
{ type: "Identifier", name: "fx" },
|
|
75
|
+
],
|
|
76
|
+
body: {
|
|
77
|
+
type: "BlockStatement",
|
|
78
|
+
body: [
|
|
79
|
+
{
|
|
80
|
+
type: "ExpressionStatement",
|
|
81
|
+
expression: {
|
|
82
|
+
type: "CallExpression",
|
|
83
|
+
callee: {
|
|
84
|
+
type: "MemberExpression",
|
|
85
|
+
object: { type: "Identifier", name: "fx" },
|
|
86
|
+
property: { type: "Identifier", name: "step" },
|
|
87
|
+
computed: false,
|
|
88
|
+
},
|
|
89
|
+
arguments: [{ type: "Literal", value: "load" }, stepBody],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
} as AstNode,
|
|
95
|
+
bindings: new Map<string, InferBinding>(),
|
|
96
|
+
hasExplicitEffects: false,
|
|
97
|
+
});
|
|
98
|
+
expect(inferred.bareIrreversible).toEqual([]);
|
|
99
|
+
expect(inferred.steps).toEqual(["load"]);
|
|
100
|
+
expect(inferred.effects.fetches).toEqual(["example.com"]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("extract — durable fx.fetch outside fx.step", () => {
|
|
105
|
+
test("OKE1901 when a durable flow calls fx.fetch directly", async () => {
|
|
106
|
+
await expect(
|
|
107
|
+
extractFromSources({
|
|
108
|
+
"src/flows/load.ts": `
|
|
109
|
+
import { flow, on, http } from "okengine";
|
|
110
|
+
export const load = on(http.get("/load"), flow("load", {
|
|
111
|
+
durable: true,
|
|
112
|
+
do: async (_input, fx) => {
|
|
113
|
+
return fx.fetch("https://example.com/data");
|
|
114
|
+
},
|
|
115
|
+
}));
|
|
116
|
+
`,
|
|
117
|
+
}),
|
|
118
|
+
).rejects.toThrow(/OKE1901/);
|
|
42
119
|
});
|
|
43
120
|
});
|
|
@@ -110,6 +110,11 @@ export interface InferEffectsOptions {
|
|
|
110
110
|
* never pass `fx` into another function.
|
|
111
111
|
*/
|
|
112
112
|
readonly resolveCallee?: (callee: AstNode, file: string) => FxCalleeResolution;
|
|
113
|
+
/**
|
|
114
|
+
* Flow name of an imported handle in `file`. Absent when inference does
|
|
115
|
+
* not follow imports (unit tests).
|
|
116
|
+
*/
|
|
117
|
+
readonly resolveFlowRef?: (localName: string, file: string) => string | undefined;
|
|
113
118
|
}
|
|
114
119
|
|
|
115
120
|
/** Result of effect inference for one flow body. */
|
|
@@ -129,6 +134,11 @@ export interface InferredEffects {
|
|
|
129
134
|
readonly nondeterministic: boolean;
|
|
130
135
|
/** True when the body references `fx.auth.userId`. */
|
|
131
136
|
readonly readsUserId: boolean;
|
|
137
|
+
/**
|
|
138
|
+
* `fx.fetch` / `fx.send` calls that are not inside an `fx.step` callback.
|
|
139
|
+
* A `durable: true` flow must not call these directly.
|
|
140
|
+
*/
|
|
141
|
+
readonly bareIrreversible: readonly ("fetch" | "send")[];
|
|
132
142
|
}
|
|
133
143
|
|
|
134
144
|
const READ_METHODS = new Set([
|
|
@@ -145,6 +155,10 @@ const READ_METHODS = new Set([
|
|
|
145
155
|
"page",
|
|
146
156
|
"search",
|
|
147
157
|
"raw",
|
|
158
|
+
"innerJoin",
|
|
159
|
+
"leftJoin",
|
|
160
|
+
"rightJoin",
|
|
161
|
+
"fullJoin",
|
|
148
162
|
]);
|
|
149
163
|
|
|
150
164
|
const WRITE_METHODS = new Set([
|
|
@@ -175,6 +189,10 @@ const TABLE_ARG_METHODS = new Set([
|
|
|
175
189
|
"count",
|
|
176
190
|
"page",
|
|
177
191
|
"search",
|
|
192
|
+
"innerJoin",
|
|
193
|
+
"leftJoin",
|
|
194
|
+
"rightJoin",
|
|
195
|
+
"fullJoin",
|
|
178
196
|
]);
|
|
179
197
|
|
|
180
198
|
/**
|
|
@@ -204,7 +222,8 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
|
|
|
204
222
|
name: "liveQuery" | "applySearchEmbedCdc";
|
|
205
223
|
call: CallExpression;
|
|
206
224
|
}> = [];
|
|
207
|
-
const
|
|
225
|
+
const bareIrreversible = new Set<"fetch" | "send">();
|
|
226
|
+
const chains = collectFxChains(options, opaque, userIdRoots, intrinsics, bareIrreversible);
|
|
208
227
|
|
|
209
228
|
for (const { call, chain } of chains) {
|
|
210
229
|
if (chain.rootMethod === "raw") {
|
|
@@ -313,7 +332,13 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
|
|
|
313
332
|
}
|
|
314
333
|
|
|
315
334
|
if (chain.rootMethod === "call" && call === chain.rootCall) {
|
|
316
|
-
const
|
|
335
|
+
const arg = unwrapValueNode(call.arguments[0]);
|
|
336
|
+
const local = identifierName(arg);
|
|
337
|
+
const imported =
|
|
338
|
+
local && options.file && options.resolveFlowRef
|
|
339
|
+
? options.resolveFlowRef(local, options.file)
|
|
340
|
+
: undefined;
|
|
341
|
+
const ref = imported ?? resolveCallTarget(call.arguments[0], options.bindings);
|
|
317
342
|
if (ref) calls.add(ref);
|
|
318
343
|
continue;
|
|
319
344
|
}
|
|
@@ -359,18 +384,28 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
|
|
|
359
384
|
// Skip incomplete chains (`select` before `.from`, `insert` before table) —
|
|
360
385
|
// the sibling call that carries the table arg records the real resource.
|
|
361
386
|
const leaf = resolved.methods[resolved.methods.length - 1]!;
|
|
362
|
-
const
|
|
387
|
+
const tables = tablesFromStoreChain(call, options.bindings, chain);
|
|
388
|
+
const hasTable = tables.length > 0;
|
|
363
389
|
if (!hasTable && (leaf === "select" || leaf === "insert" || leaf === "update")) {
|
|
364
390
|
continue;
|
|
365
391
|
}
|
|
366
392
|
// Intermediate chain links that only forward (where/values/returning)
|
|
367
393
|
// still carry read/write from earlier methods — record once we have a table
|
|
368
394
|
// or a terminal key-based op.
|
|
369
|
-
if (
|
|
395
|
+
if (
|
|
396
|
+
!hasTable &&
|
|
397
|
+
(leaf === "where" || leaf === "values" || leaf === "returning" || leaf === "groupBy")
|
|
398
|
+
) {
|
|
370
399
|
continue;
|
|
371
400
|
}
|
|
372
|
-
|
|
373
|
-
|
|
401
|
+
const resources =
|
|
402
|
+
tables.length > 0
|
|
403
|
+
? tables.map((table) => sqlTableRef(table) as ResourceRef)
|
|
404
|
+
: [resolved.resource];
|
|
405
|
+
for (const resource of resources) {
|
|
406
|
+
if (op === "read" || op === "both") reads.add(resource);
|
|
407
|
+
if (op === "write" || op === "both") writes.add(resource);
|
|
408
|
+
}
|
|
374
409
|
}
|
|
375
410
|
}
|
|
376
411
|
|
|
@@ -407,6 +442,7 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
|
|
|
407
442
|
cacheIneligible: usesRaw && !options.hasExplicitEffects,
|
|
408
443
|
nondeterministic: asks.size > 0 || embeds.size > 0 || decides.size > 0,
|
|
409
444
|
readsUserId: userIdRoots.some((node) => containsAuthUserId(node)),
|
|
445
|
+
bareIrreversible: [...bareIrreversible],
|
|
410
446
|
};
|
|
411
447
|
}
|
|
412
448
|
|
|
@@ -610,21 +646,37 @@ function tableFromStoreChain(
|
|
|
610
646
|
bindings: ReadonlyMap<string, InferBinding>,
|
|
611
647
|
chain?: FxChain,
|
|
612
648
|
): string | undefined {
|
|
649
|
+
return tablesFromStoreChain(call, bindings, chain)[0];
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Every table argument on a store chain, including joins.
|
|
654
|
+
*
|
|
655
|
+
* @param call - Any call in the chain
|
|
656
|
+
* @param bindings - Scope bindings
|
|
657
|
+
* @param chain - Precomputed chain when already known
|
|
658
|
+
*/
|
|
659
|
+
function tablesFromStoreChain(
|
|
660
|
+
call: CallExpression,
|
|
661
|
+
bindings: ReadonlyMap<string, InferBinding>,
|
|
662
|
+
chain?: FxChain,
|
|
663
|
+
): string[] {
|
|
613
664
|
const found = tableFromStoreWalk(call, bindings, chain);
|
|
614
|
-
if (found) return found;
|
|
665
|
+
if (found.length > 0) return found;
|
|
615
666
|
if (chain?.aliasLeaf && chain.aliasLeaf !== call) {
|
|
616
667
|
return tableFromStoreWalk(chain.aliasLeaf, bindings);
|
|
617
668
|
}
|
|
618
|
-
return
|
|
669
|
+
return [];
|
|
619
670
|
}
|
|
620
671
|
|
|
621
672
|
function tableFromStoreWalk(
|
|
622
673
|
call: CallExpression,
|
|
623
674
|
bindings: ReadonlyMap<string, InferBinding>,
|
|
624
675
|
chain?: FxChain,
|
|
625
|
-
): string
|
|
676
|
+
): string[] {
|
|
626
677
|
let current: AstNode | undefined = call;
|
|
627
678
|
const seen = new Set<AstNode>();
|
|
679
|
+
const names: string[] = [];
|
|
628
680
|
while (current && current.type === "CallExpression" && !seen.has(current)) {
|
|
629
681
|
seen.add(current);
|
|
630
682
|
const c = current as CallExpression;
|
|
@@ -644,7 +696,8 @@ function tableFromStoreWalk(
|
|
|
644
696
|
const id = identifierName(c.arguments[0]);
|
|
645
697
|
if (id) {
|
|
646
698
|
const binding = bindings.get(id);
|
|
647
|
-
|
|
699
|
+
const tableName = binding?.kind === "table" ? binding.ref : id;
|
|
700
|
+
if (!names.includes(tableName)) names.push(tableName);
|
|
648
701
|
}
|
|
649
702
|
}
|
|
650
703
|
}
|
|
@@ -658,7 +711,7 @@ function tableFromStoreWalk(
|
|
|
658
711
|
}
|
|
659
712
|
break;
|
|
660
713
|
}
|
|
661
|
-
return
|
|
714
|
+
return names;
|
|
662
715
|
}
|
|
663
716
|
|
|
664
717
|
function methodNameOfCall(call: CallExpression): string | undefined {
|
|
@@ -908,7 +961,9 @@ function collectFxChains(
|
|
|
908
961
|
opaque: string[],
|
|
909
962
|
userIdRoots: AstNode[],
|
|
910
963
|
intrinsics: Array<{ name: "liveQuery" | "applySearchEmbedCdc"; call: CallExpression }>,
|
|
964
|
+
bareIrreversible: Set<"fetch" | "send">,
|
|
911
965
|
): ChainHit[] {
|
|
966
|
+
const stepBodies = new Set<AstNode>();
|
|
912
967
|
const hits: ChainHit[] = [];
|
|
913
968
|
const seen = new Set<AstNode>();
|
|
914
969
|
|
|
@@ -918,7 +973,7 @@ function collectFxChains(
|
|
|
918
973
|
aliases: ReadonlyMap<string, ChainAlias>,
|
|
919
974
|
locals: ReadonlyMap<string, AstNode>,
|
|
920
975
|
): void => {
|
|
921
|
-
analyzeFunction(fn, "fx", aliases, locals, file);
|
|
976
|
+
analyzeFunction(fn, "fx", aliases, locals, file, stepBodies.has(fn));
|
|
922
977
|
};
|
|
923
978
|
|
|
924
979
|
const analyzeFunction = (
|
|
@@ -927,13 +982,14 @@ function collectFxChains(
|
|
|
927
982
|
outerAliases: ReadonlyMap<string, ChainAlias>,
|
|
928
983
|
outerLocals: ReadonlyMap<string, AstNode>,
|
|
929
984
|
file: string,
|
|
985
|
+
insideStep: boolean,
|
|
930
986
|
): void => {
|
|
931
987
|
if (seen.has(fn)) return;
|
|
932
988
|
seen.add(fn);
|
|
933
989
|
userIdRoots.push(fn);
|
|
934
990
|
const body = (fn as AstNode & { body?: AstNode }).body;
|
|
935
991
|
if (!body) return;
|
|
936
|
-
analyzeBody(body, door, outerAliases, outerLocals, file);
|
|
992
|
+
analyzeBody(body, door, outerAliases, outerLocals, file, insideStep);
|
|
937
993
|
};
|
|
938
994
|
|
|
939
995
|
const analyzeBody = (
|
|
@@ -942,6 +998,7 @@ function collectFxChains(
|
|
|
942
998
|
outerAliases: ReadonlyMap<string, ChainAlias>,
|
|
943
999
|
outerLocals: ReadonlyMap<string, AstNode>,
|
|
944
1000
|
file: string,
|
|
1001
|
+
insideStep: boolean,
|
|
945
1002
|
): void => {
|
|
946
1003
|
const aliases = new Map(outerAliases);
|
|
947
1004
|
const locals = new Map(outerLocals);
|
|
@@ -951,6 +1008,20 @@ function collectFxChains(
|
|
|
951
1008
|
});
|
|
952
1009
|
for (const call of callsSkippingNested(body)) {
|
|
953
1010
|
const chain = chainFromCall(call, aliases);
|
|
1011
|
+
if (chain?.rootMethod === "step") {
|
|
1012
|
+
for (const arg of call.arguments) {
|
|
1013
|
+
const fn = unwrapValue(arg);
|
|
1014
|
+
if (fn && isFunctionNode(fn)) stepBodies.add(fn);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
if (
|
|
1018
|
+
!insideStep &&
|
|
1019
|
+
chain &&
|
|
1020
|
+
call === chain.rootCall &&
|
|
1021
|
+
(chain.rootMethod === "fetch" || chain.rootMethod === "send")
|
|
1022
|
+
) {
|
|
1023
|
+
bareIrreversible.add(chain.rootMethod);
|
|
1024
|
+
}
|
|
954
1025
|
if (chain) {
|
|
955
1026
|
hits.push({ call, chain });
|
|
956
1027
|
continue;
|
|
@@ -961,7 +1032,7 @@ function collectFxChains(
|
|
|
961
1032
|
if (seen.has(nested)) continue;
|
|
962
1033
|
const shadows = functionParams(nested).some((param) => paramIdentifierName(param) === door);
|
|
963
1034
|
if (shadows) continue;
|
|
964
|
-
analyzeFunction(nested, door, aliases, locals, file);
|
|
1035
|
+
analyzeFunction(nested, door, aliases, locals, file, insideStep || stepBodies.has(nested));
|
|
965
1036
|
}
|
|
966
1037
|
};
|
|
967
1038
|
|
|
@@ -972,9 +1043,9 @@ function collectFxChains(
|
|
|
972
1043
|
if (second && paramIdentifierName(second) !== "fx") {
|
|
973
1044
|
opaque.push("the do callback's second parameter is not named fx");
|
|
974
1045
|
}
|
|
975
|
-
analyzeFunction(options.doNode, "fx", new Map(), new Map(), startFile);
|
|
1046
|
+
analyzeFunction(options.doNode, "fx", new Map(), new Map(), startFile, false);
|
|
976
1047
|
} else {
|
|
977
|
-
analyzeBody(options.doNode, "fx", new Map(), new Map(), startFile);
|
|
1048
|
+
analyzeBody(options.doNode, "fx", new Map(), new Map(), startFile, false);
|
|
978
1049
|
}
|
|
979
1050
|
|
|
980
1051
|
return hits;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Joins and groupBy list every source table under the flow's reads.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { extractFromSources } from "./extract.ts";
|
|
7
|
+
|
|
8
|
+
describe("extractManifest — joins", () => {
|
|
9
|
+
test("innerJoin and groupBy record every table", async () => {
|
|
10
|
+
const manifest = await extractFromSources({
|
|
11
|
+
"src/app.ts": `
|
|
12
|
+
import { oke } from "okengine";
|
|
13
|
+
export const app = oke({ name: "joins" });
|
|
14
|
+
`,
|
|
15
|
+
"src/flows/orders.ts": `
|
|
16
|
+
import { on, flow, http, store, field } from "okengine";
|
|
17
|
+
import { eq, count } from "drizzle-orm";
|
|
18
|
+
|
|
19
|
+
export const db = store.sql("db");
|
|
20
|
+
export const orders = store.schema.table("orders", {
|
|
21
|
+
id: field.text().primaryKey(),
|
|
22
|
+
customerId: field.text(),
|
|
23
|
+
});
|
|
24
|
+
export const customers = store.schema.table("customers", {
|
|
25
|
+
id: field.text().primaryKey(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const totals = on(
|
|
29
|
+
http.get("/totals"),
|
|
30
|
+
flow("orders.totals", {
|
|
31
|
+
do: async (_input, fx) => {
|
|
32
|
+
return fx
|
|
33
|
+
.store(db)
|
|
34
|
+
.select({ n: count() })
|
|
35
|
+
.from(orders)
|
|
36
|
+
.innerJoin(customers, eq(orders.customerId, customers.id))
|
|
37
|
+
.groupBy(customers.id);
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
`,
|
|
42
|
+
});
|
|
43
|
+
expect(manifest.flows?.["orders.totals"]?.effects?.reads?.slice().sort()).toEqual([
|
|
44
|
+
"sql:customers",
|
|
45
|
+
"sql:orders",
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -1577,6 +1577,34 @@ export const ping = flow({ do: () => ({ ok: true }) });
|
|
|
1577
1577
|
expect(manifest.flows?.["notes.ping"]).toBeDefined();
|
|
1578
1578
|
});
|
|
1579
1579
|
|
|
1580
|
+
test("fx.call of an imported flow handle records that flow's name", async () => {
|
|
1581
|
+
const tasks = `
|
|
1582
|
+
import { on, flow, http } from "okengine";
|
|
1583
|
+
export const create = on(
|
|
1584
|
+
http.post("/tasks"),
|
|
1585
|
+
flow("tasks.create", { do: async () => ({ id: "1" }) }),
|
|
1586
|
+
);
|
|
1587
|
+
`;
|
|
1588
|
+
const forms = `
|
|
1589
|
+
import { on, flow, http } from "okengine";
|
|
1590
|
+
import { create as createTask } from "../tasks/index.ts";
|
|
1591
|
+
export const submit = on(
|
|
1592
|
+
http.post("/forms/:id/submit"),
|
|
1593
|
+
flow("forms.submit", {
|
|
1594
|
+
do: async (_input, fx) => {
|
|
1595
|
+
await fx.call(createTask, { title: "SSO" });
|
|
1596
|
+
return { ok: true };
|
|
1597
|
+
},
|
|
1598
|
+
}),
|
|
1599
|
+
);
|
|
1600
|
+
`;
|
|
1601
|
+
const manifest = await extractFromSources({
|
|
1602
|
+
"src/flows/tasks/index.ts": tasks,
|
|
1603
|
+
"src/flows/forms/index.ts": forms,
|
|
1604
|
+
});
|
|
1605
|
+
expect(manifest.flows?.["forms.submit"]?.effects?.calls).toEqual(["tasks.create"]);
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1580
1608
|
test("effects are identical for the same do body in a tree file vs a barrel", async () => {
|
|
1581
1609
|
const doBody = `
|
|
1582
1610
|
const rows = await fx.store(db).select().from(notes);
|
package/src/compiler/extract.ts
CHANGED
|
@@ -2194,6 +2194,7 @@ function registerFlow(args: {
|
|
|
2194
2194
|
flowName: name,
|
|
2195
2195
|
file: args.file.path,
|
|
2196
2196
|
resolveCallee: (callee, file) => args.scope.fxIndex.resolve(file, callee),
|
|
2197
|
+
resolveFlowRef: (localName, file) => args.scope.fxIndex.flowRef(file, localName),
|
|
2197
2198
|
})
|
|
2198
2199
|
: {
|
|
2199
2200
|
effects: {} as Effects,
|
|
@@ -2202,6 +2203,7 @@ function registerFlow(args: {
|
|
|
2202
2203
|
cacheIneligible: false,
|
|
2203
2204
|
nondeterministic: false,
|
|
2204
2205
|
readsUserId: false,
|
|
2206
|
+
bareIrreversible: [] as ("fetch" | "send")[],
|
|
2205
2207
|
};
|
|
2206
2208
|
|
|
2207
2209
|
let effects: Effects | undefined;
|
|
@@ -2262,7 +2264,15 @@ function registerFlow(args: {
|
|
|
2262
2264
|
const line = lineAt(args.file.source, args.flowCall.start ?? 0);
|
|
2263
2265
|
flow.source = `${args.file.path}:${line}`;
|
|
2264
2266
|
|
|
2265
|
-
if (boolProp(opts, "durable"))
|
|
2267
|
+
if (boolProp(opts, "durable")) {
|
|
2268
|
+
flow.durable = true;
|
|
2269
|
+
const bare = inferred.bareIrreversible[0];
|
|
2270
|
+
if (bare !== undefined) {
|
|
2271
|
+
throw new Error(
|
|
2272
|
+
`OKE1901: durable flow "${name}" calls fx.${bare} outside fx.step. Wrap fx.fetch and fx.send in fx.step.`,
|
|
2273
|
+
);
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2266
2276
|
assertDecisionPlacement(name, flow, args.scope);
|
|
2267
2277
|
if (typeof liveFromTrigger === "string") {
|
|
2268
2278
|
flow.live = liveFromTrigger;
|
package/src/compiler/fx-index.ts
CHANGED
|
@@ -26,6 +26,14 @@ export interface FxIndex {
|
|
|
26
26
|
* @param callee - Call callee node
|
|
27
27
|
*/
|
|
28
28
|
resolve(file: string, callee: AstNode): FxCalleeResolution;
|
|
29
|
+
/**
|
|
30
|
+
* Flow name behind an imported handle (`import { create as createTask }`).
|
|
31
|
+
* Absent when `localName` is not a named import of a `flow()` / `on()` export.
|
|
32
|
+
*
|
|
33
|
+
* @param file - File that contains the `fx.call`
|
|
34
|
+
* @param localName - Local binding (`createTask`)
|
|
35
|
+
*/
|
|
36
|
+
flowRef(file: string, localName: string): string | undefined;
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
interface NamedImport {
|
|
@@ -61,6 +69,8 @@ interface FileFx {
|
|
|
61
69
|
readonly functions: Map<string, AstNode>;
|
|
62
70
|
readonly imports: Map<string, ImportBinding>;
|
|
63
71
|
readonly exports: Map<string, LocalExport | Reexport>;
|
|
72
|
+
/** Local binding → `flow("name")` / `on(…, flow("name"))` literal. */
|
|
73
|
+
readonly flowNames: Map<string, string>;
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
const INTRINSIC_SOURCES: Readonly<
|
|
@@ -176,7 +186,29 @@ export function buildFxIndex(
|
|
|
176
186
|
return resolveExport(target, imported.imported, seen);
|
|
177
187
|
}
|
|
178
188
|
|
|
189
|
+
function flowExportName(file: string, exportName: string, seen: Set<string>): string | undefined {
|
|
190
|
+
const key = `${file}#flow:${exportName}`;
|
|
191
|
+
if (seen.has(key)) return undefined;
|
|
192
|
+
seen.add(key);
|
|
193
|
+
const indexed = byFile.get(file);
|
|
194
|
+
if (!indexed) return undefined;
|
|
195
|
+
const exported = indexed.exports.get(exportName);
|
|
196
|
+
if (!exported) return indexed.flowNames.get(exportName);
|
|
197
|
+
if (exported.kind === "local") return indexed.flowNames.get(exported.binding);
|
|
198
|
+
const target = resolveModule(file, exported.from);
|
|
199
|
+
if (!target) return undefined;
|
|
200
|
+
return flowExportName(target, exported.imported, seen);
|
|
201
|
+
}
|
|
202
|
+
|
|
179
203
|
return {
|
|
204
|
+
flowRef(file, localName) {
|
|
205
|
+
const from = normalizePosix(file);
|
|
206
|
+
const imported = byFile.get(from)?.imports.get(localName);
|
|
207
|
+
if (!imported || imported.kind !== "named") return undefined;
|
|
208
|
+
const target = resolveModule(from, imported.from);
|
|
209
|
+
if (!target) return undefined;
|
|
210
|
+
return flowExportName(target, imported.imported, new Set());
|
|
211
|
+
},
|
|
180
212
|
resolve(file, callee) {
|
|
181
213
|
const from = normalizePosix(file);
|
|
182
214
|
const seen = new Set<string>();
|
|
@@ -212,9 +244,10 @@ function indexFile(program: AstNode): FileFx {
|
|
|
212
244
|
const functions = new Map<string, AstNode>();
|
|
213
245
|
const imports = new Map<string, ImportBinding>();
|
|
214
246
|
const exports = new Map<string, LocalExport | Reexport>();
|
|
247
|
+
const flowNames = new Map<string, string>();
|
|
215
248
|
const body = (program as AstNode & { body?: AstNode[] }).body ?? [];
|
|
216
|
-
for (const stmt of body) indexStmt(stmt, functions, imports, exports);
|
|
217
|
-
return { functions, imports, exports };
|
|
249
|
+
for (const stmt of body) indexStmt(stmt, functions, imports, exports, flowNames);
|
|
250
|
+
return { functions, imports, exports, flowNames };
|
|
218
251
|
}
|
|
219
252
|
|
|
220
253
|
function indexStmt(
|
|
@@ -222,6 +255,7 @@ function indexStmt(
|
|
|
222
255
|
functions: Map<string, AstNode>,
|
|
223
256
|
imports: Map<string, ImportBinding>,
|
|
224
257
|
exports: Map<string, LocalExport | Reexport>,
|
|
258
|
+
flowNames: Map<string, string>,
|
|
225
259
|
): void {
|
|
226
260
|
if (stmt.type === "ImportDeclaration") {
|
|
227
261
|
indexImport(stmt, imports);
|
|
@@ -232,7 +266,7 @@ function indexStmt(
|
|
|
232
266
|
return;
|
|
233
267
|
}
|
|
234
268
|
if (stmt.type === "VariableDeclaration") {
|
|
235
|
-
rememberVars(stmt, functions, exports, false);
|
|
269
|
+
rememberVars(stmt, functions, exports, flowNames, false);
|
|
236
270
|
return;
|
|
237
271
|
}
|
|
238
272
|
if (stmt.type === "ExportDefaultDeclaration") {
|
|
@@ -247,7 +281,7 @@ function indexStmt(
|
|
|
247
281
|
const decl = (stmt as AstNode & { declaration?: AstNode | null }).declaration;
|
|
248
282
|
const source = stringArg((stmt as AstNode & { source?: AstNode }).source);
|
|
249
283
|
if (decl?.type === "FunctionDeclaration") rememberFunction(decl, functions, exports, true);
|
|
250
|
-
if (decl?.type === "VariableDeclaration") rememberVars(decl, functions, exports, true);
|
|
284
|
+
if (decl?.type === "VariableDeclaration") rememberVars(decl, functions, exports, flowNames, true);
|
|
251
285
|
const specifiers = (stmt as AstNode & { specifiers?: AstNode[] }).specifiers ?? [];
|
|
252
286
|
for (const spec of specifiers) {
|
|
253
287
|
const local = exportedLocalName(spec);
|
|
@@ -309,6 +343,7 @@ function rememberVars(
|
|
|
309
343
|
decl: AstNode,
|
|
310
344
|
functions: Map<string, AstNode>,
|
|
311
345
|
exports: Map<string, LocalExport | Reexport>,
|
|
346
|
+
flowNames: Map<string, string>,
|
|
312
347
|
exportIt: boolean,
|
|
313
348
|
): void {
|
|
314
349
|
const declarations = (decl as AstNode & { declarations?: AstNode[] }).declarations ?? [];
|
|
@@ -317,10 +352,29 @@ function rememberVars(
|
|
|
317
352
|
if (!name) continue;
|
|
318
353
|
const init = (item as AstNode & { init?: AstNode | null }).init;
|
|
319
354
|
if (init && isFunctionNode(unwrapValue(init))) functions.set(name, unwrapValue(init));
|
|
355
|
+
const flowName = init ? flowLiteralName(unwrapValue(init)) : undefined;
|
|
356
|
+
if (flowName) flowNames.set(name, flowName);
|
|
320
357
|
if (exportIt) exports.set(name, { kind: "local", binding: name });
|
|
321
358
|
}
|
|
322
359
|
}
|
|
323
360
|
|
|
361
|
+
/**
|
|
362
|
+
* `flow("name")` / `call("name")` / `on(trigger, flow("name"))` literal.
|
|
363
|
+
*
|
|
364
|
+
* @param node - Initializer
|
|
365
|
+
*/
|
|
366
|
+
function flowLiteralName(node: AstNode): string | undefined {
|
|
367
|
+
if (node.type !== "CallExpression") return undefined;
|
|
368
|
+
const call = node as AstNode & { callee: AstNode; arguments?: AstNode[] };
|
|
369
|
+
const callee = identifierName(call.callee);
|
|
370
|
+
if (callee === "flow" || callee === "call") return stringArg(call.arguments?.[0]);
|
|
371
|
+
if (callee === "on") {
|
|
372
|
+
const body = call.arguments?.[1];
|
|
373
|
+
return body ? flowLiteralName(unwrapValue(body)) : undefined;
|
|
374
|
+
}
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
|
|
324
378
|
function exportedLocalName(spec: AstNode): string | undefined {
|
|
325
379
|
return (
|
|
326
380
|
identifierName((spec as AstNode & { local?: AstNode }).local) ??
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { assembleInput, HttpBodyRejected, parseBody } from "./http-parse.ts";
|
|
4
|
+
|
|
5
|
+
function request(body: string, contentType?: string, length?: string): Request {
|
|
6
|
+
const headers = new Headers();
|
|
7
|
+
if (contentType) headers.set("content-type", contentType);
|
|
8
|
+
if (length) headers.set("content-length", length);
|
|
9
|
+
return new Request("http://localhost/hooks", { method: "POST", body, headers });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("request bodies", () => {
|
|
13
|
+
test("rejects an overstated and an honest body over the cap", async () => {
|
|
14
|
+
await expect(
|
|
15
|
+
parseBody(request("{}", "application/json", "99"), { maxBytes: 4 }),
|
|
16
|
+
).rejects.toBeInstanceOf(HttpBodyRejected);
|
|
17
|
+
await expect(
|
|
18
|
+
parseBody(request("{}", "application/json"), { maxBytes: 1 }),
|
|
19
|
+
).rejects.toMatchObject({
|
|
20
|
+
code: "PayloadTooLarge",
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("sendBeacon text/plain JSON is 415 unless the route opts out", async () => {
|
|
25
|
+
const beacon = request('{"n":1}', "text/plain;charset=UTF-8");
|
|
26
|
+
await expect(parseBody(beacon)).rejects.toMatchObject({ code: "UnsupportedMediaType" });
|
|
27
|
+
await expect(
|
|
28
|
+
parseBody(request('{"n":1}', "text/plain"), { jsonContentType: "any" }),
|
|
29
|
+
).resolves.toEqual({
|
|
30
|
+
n: 1,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a form body that parses as JSON is 415, and a normal form body stays a string", async () => {
|
|
35
|
+
await expect(
|
|
36
|
+
parseBody(request("{not json", "application/x-www-form-urlencoded")),
|
|
37
|
+
).resolves.toBe("{not json");
|
|
38
|
+
await expect(
|
|
39
|
+
parseBody(request('{"a":1}', "application/x-www-form-urlencoded")),
|
|
40
|
+
).rejects.toMatchObject({ code: "UnsupportedMediaType" });
|
|
41
|
+
await expect(parseBody(request("a=1&b=2", "application/x-www-form-urlencoded"))).resolves.toBe(
|
|
42
|
+
"a=1&b=2",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("application/json must parse, and +json is accepted", async () => {
|
|
47
|
+
await expect(parseBody(request("{", "application/json"))).rejects.toMatchObject({
|
|
48
|
+
code: "InvalidQuery",
|
|
49
|
+
reason: "malformed_body",
|
|
50
|
+
});
|
|
51
|
+
await expect(
|
|
52
|
+
parseBody(request('{"n":1}', "application/vnd.x+json; charset=utf-8")),
|
|
53
|
+
).resolves.toEqual({ n: 1 });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("drops __proto__ and keeps a constructor field", async () => {
|
|
57
|
+
const parsed = await parseBody(
|
|
58
|
+
request('{"__proto__":{"isAdmin":true},"constructor":"ok"}', "application/json"),
|
|
59
|
+
);
|
|
60
|
+
expect((parsed as { isAdmin?: boolean; constructor?: string }).isAdmin).toBeUndefined();
|
|
61
|
+
expect((parsed as { constructor?: string }).constructor).toBe("ok");
|
|
62
|
+
expect(Object.prototype).not.toHaveProperty("isAdmin");
|
|
63
|
+
const input = assembleInput({ body: parsed });
|
|
64
|
+
expect((input as { isAdmin?: boolean }).isAdmin).toBeUndefined();
|
|
65
|
+
|
|
66
|
+
const escaped = await parseBody(
|
|
67
|
+
request('{"\\u005f\\u005fproto\\u005f\\u005f":{"isAdmin":true},"a":1}', "application/json"),
|
|
68
|
+
);
|
|
69
|
+
expect((escaped as { a?: number; isAdmin?: boolean }).a).toBe(1);
|
|
70
|
+
expect((escaped as { isAdmin?: boolean }).isAdmin).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("a tighter cap cancels a chunked body instead of buffering it", async () => {
|
|
74
|
+
let pulled = 0;
|
|
75
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
76
|
+
pull(controller) {
|
|
77
|
+
pulled += 1;
|
|
78
|
+
if (pulled > 4) {
|
|
79
|
+
controller.close();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
controller.enqueue(new Uint8Array(8));
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const req = new Request("http://localhost/hooks", {
|
|
86
|
+
method: "POST",
|
|
87
|
+
body: stream,
|
|
88
|
+
headers: { "content-type": "application/octet-stream" },
|
|
89
|
+
});
|
|
90
|
+
await expect(parseBody(req, { maxBytes: 8 })).rejects.toMatchObject({
|
|
91
|
+
code: "PayloadTooLarge",
|
|
92
|
+
});
|
|
93
|
+
expect(pulled).toBeLessThan(4);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("an empty body is undefined", async () => {
|
|
97
|
+
await expect(
|
|
98
|
+
parseBody(new Request("http://localhost/", { method: "DELETE" })),
|
|
99
|
+
).resolves.toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
});
|