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
|
@@ -63,6 +63,7 @@ describe("extractManifest — five trigger types", () => {
|
|
|
63
63
|
http: { method: "POST", path: "/orders" },
|
|
64
64
|
});
|
|
65
65
|
expect(manifest.flows?.["triggers.http"]?.effects?.writes).toEqual(["sql:orders"]);
|
|
66
|
+
expect(manifest.flows?.["triggers.http"]?.idempotency).toEqual({ mode: "auto", ttl: "24h" });
|
|
66
67
|
|
|
67
68
|
expect(manifest.flows?.["triggers.every"]?.trigger).toEqual({
|
|
68
69
|
every: "10m",
|
|
@@ -86,6 +87,7 @@ describe("extractManifest — five trigger types", () => {
|
|
|
86
87
|
true,
|
|
87
88
|
);
|
|
88
89
|
expect(internal?.effects?.reads).toEqual(["sql:links"]);
|
|
90
|
+
expect(internal?.idempotency).toEqual({ mode: "off", ttl: "24h" });
|
|
89
91
|
});
|
|
90
92
|
});
|
|
91
93
|
|
|
@@ -99,9 +101,44 @@ describe("extractManifest — fx.raw", () => {
|
|
|
99
101
|
});
|
|
100
102
|
|
|
101
103
|
expect(manifest.flows?.["raw.unannotated"]?.cache).toBe(false);
|
|
104
|
+
expect(manifest.flows?.["raw.unannotated"]?.usesRaw).toBe(true);
|
|
105
|
+
expect(manifest.flows?.["raw.unannotated"]?.idempotency).toEqual({ mode: "off", ttl: "24h" });
|
|
102
106
|
expect(manifest.flows?.["raw.annotated"]?.cache).toBeUndefined();
|
|
107
|
+
expect(manifest.flows?.["raw.annotated"]?.usesRaw).toBe(true);
|
|
103
108
|
expect(manifest.flows?.["raw.annotated"]?.effects?.reads).toEqual(["sql:orders"]);
|
|
104
109
|
});
|
|
110
|
+
|
|
111
|
+
test("required on a GET flow is an extract error; a bad ttl is an extract error", async () => {
|
|
112
|
+
const requiredGet = `
|
|
113
|
+
import { on, flow, http } from "okengine";
|
|
114
|
+
export const read = on(
|
|
115
|
+
http.get("/read"),
|
|
116
|
+
flow("pay.read", { idempotency: "required", do: async () => ({ ok: true }) }),
|
|
117
|
+
);
|
|
118
|
+
`;
|
|
119
|
+
await expect(extractFromSources({ "src/flows/pay.ts": requiredGet })).rejects.toThrow(
|
|
120
|
+
/header would be ignored/,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const badTtl = `
|
|
124
|
+
import { on, flow, http, store, field } from "okengine";
|
|
125
|
+
export const db = store.sql("db");
|
|
126
|
+
export const payments = store.schema.table("payments", { id: field.text().primaryKey() });
|
|
127
|
+
export const charge = on(
|
|
128
|
+
http.post("/charge"),
|
|
129
|
+
flow("pay.charge", {
|
|
130
|
+
idempotency: { ttl: "nope" },
|
|
131
|
+
do: async (input, fx) => {
|
|
132
|
+
await fx.store(db).insert(payments).values(input);
|
|
133
|
+
return { ok: true };
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
`;
|
|
138
|
+
await expect(extractFromSources({ "src/flows/pay.ts": badTtl })).rejects.toThrow(
|
|
139
|
+
/not a duration/,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
105
142
|
});
|
|
106
143
|
|
|
107
144
|
describe("extractManifest — performance", () => {
|
package/src/compiler/extract.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* from source to `manifest.oke.json`.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { readdir } from "node:fs/promises";
|
|
9
10
|
import { parseSync } from "oxc-parser";
|
|
10
11
|
import { join } from "node:path";
|
|
11
12
|
|
|
@@ -15,6 +16,13 @@ import {
|
|
|
15
16
|
getAiProviderEntry,
|
|
16
17
|
} from "../elements/ai/providers.ts";
|
|
17
18
|
import { buildCronExpression, type CronField } from "../elements/clock/cron-fields.ts";
|
|
19
|
+
import {
|
|
20
|
+
hasMutatingEffects,
|
|
21
|
+
idempotencyTtlMs,
|
|
22
|
+
resolveIdempotencyMode,
|
|
23
|
+
ttlOf,
|
|
24
|
+
type FlowIdempotencyOption,
|
|
25
|
+
} from "../kernel/idempotency.ts";
|
|
18
26
|
import { SearchConfigError } from "../elements/store/search-errors.ts";
|
|
19
27
|
|
|
20
28
|
import type {
|
|
@@ -45,6 +53,7 @@ import type {
|
|
|
45
53
|
} from "../manifest/types.ts";
|
|
46
54
|
import { sqlTableRef } from "../manifest/sql-resource.ts";
|
|
47
55
|
import {
|
|
56
|
+
effectsMissingFromDeclaration,
|
|
48
57
|
identifierName,
|
|
49
58
|
inferEffects,
|
|
50
59
|
resolveCallTarget,
|
|
@@ -56,6 +65,7 @@ import {
|
|
|
56
65
|
type InferBinding,
|
|
57
66
|
type Literal,
|
|
58
67
|
} from "./effects-infer.ts";
|
|
68
|
+
import { buildFxIndex, readTsconfigPaths, type FxIndex } from "./fx-index.ts";
|
|
59
69
|
import {
|
|
60
70
|
isFlowsTreeFile,
|
|
61
71
|
isSkippedExtractSource,
|
|
@@ -131,6 +141,8 @@ interface ProjectScope {
|
|
|
131
141
|
/** Store-wide live defaulting — `oke({ store: { live: true } })`. */
|
|
132
142
|
store?: Manifest["store"];
|
|
133
143
|
flows: Record<string, Flow>;
|
|
144
|
+
/** Project functions that can receive `fx`, built once per extract. */
|
|
145
|
+
fxIndex: FxIndex;
|
|
134
146
|
/** Export name → flow id (for agent tools). */
|
|
135
147
|
flowExports: Map<string, string>;
|
|
136
148
|
/** Local binding name → store.resource declaration (for on(http.resource)). */
|
|
@@ -168,6 +180,7 @@ export async function extractManifest(options: ExtractManifestOptions = {}): Pro
|
|
|
168
180
|
|
|
169
181
|
const scope: ProjectScope = {
|
|
170
182
|
app: options.app ?? "app",
|
|
183
|
+
fxIndex: buildFxIndex([]),
|
|
171
184
|
bindings: new Map(),
|
|
172
185
|
signals: {},
|
|
173
186
|
stores: {},
|
|
@@ -198,6 +211,12 @@ export async function extractManifest(options: ExtractManifestOptions = {}): Pro
|
|
|
198
211
|
return { file, program: result.program as unknown as AstNode };
|
|
199
212
|
});
|
|
200
213
|
|
|
214
|
+
const tsPaths = options.rootDir ? await readTsconfigPaths(options.rootDir) : [];
|
|
215
|
+
scope.fxIndex = buildFxIndex(
|
|
216
|
+
parsed.map(({ file, program }) => ({ path: file.path, program })),
|
|
217
|
+
tsPaths,
|
|
218
|
+
);
|
|
219
|
+
|
|
201
220
|
// Pass 1 — declarations (elements, config, named bindings).
|
|
202
221
|
for (const { file, program } of parsed) {
|
|
203
222
|
collectDeclarations(file.path, program, scope);
|
|
@@ -295,21 +314,50 @@ export async function extractFromSources(
|
|
|
295
314
|
}
|
|
296
315
|
|
|
297
316
|
async function readSources(rootDir: string, pattern: string): Promise<SourceFile[]> {
|
|
298
|
-
// Bun.Glob (global) — bare `import … from "bun"` is rejected by JSR.
|
|
299
|
-
const glob = new Bun.Glob(pattern);
|
|
300
317
|
const files: SourceFile[] = [];
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
|
|
318
|
+
if (pattern === "**/*.{ts,tsx}") {
|
|
319
|
+
await walkSources(rootDir, rootDir, files);
|
|
320
|
+
} else {
|
|
321
|
+
// Bun.Glob (global) — bare `import … from "bun"` is rejected by JSR.
|
|
322
|
+
const glob = new Bun.Glob(pattern);
|
|
323
|
+
for await (const path of glob.scan({ cwd: rootDir, onlyFiles: true })) {
|
|
324
|
+
if (isSkippedExtractSource(path)) continue;
|
|
325
|
+
const abs = join(rootDir, path);
|
|
326
|
+
files.push({ path: toPosixPath(path), source: await Bun.file(abs).text() });
|
|
327
|
+
}
|
|
308
328
|
}
|
|
309
329
|
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
310
330
|
return files;
|
|
311
331
|
}
|
|
312
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Walk an app tree without entering `node_modules` or directory symlinks.
|
|
335
|
+
*
|
|
336
|
+
* A template's `node_modules/okengine` link points back at this repo, which
|
|
337
|
+
* contains the template again. Globbing through that link never terminates.
|
|
338
|
+
*/
|
|
339
|
+
async function walkSources(rootDir: string, dir: string, files: SourceFile[]): Promise<void> {
|
|
340
|
+
let entries;
|
|
341
|
+
try {
|
|
342
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
343
|
+
} catch {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
for (const entry of entries) {
|
|
347
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.isSymbolicLink()) continue;
|
|
348
|
+
const abs = join(dir, entry.name);
|
|
349
|
+
if (entry.isDirectory()) {
|
|
350
|
+
await walkSources(rootDir, abs, files);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (!entry.isFile()) continue;
|
|
354
|
+
if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".tsx")) continue;
|
|
355
|
+
const rel = toPosixPath(abs.slice(rootDir.length + 1));
|
|
356
|
+
if (isSkippedExtractSource(rel)) continue;
|
|
357
|
+
files.push({ path: rel, source: await Bun.file(abs).text() });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
313
361
|
function collectDeclarations(filePath: string, program: AstNode, scope: ProjectScope): void {
|
|
314
362
|
walk(program, (node) => {
|
|
315
363
|
if (node.type === "CallExpression") {
|
|
@@ -1882,13 +1930,17 @@ function registerFlow(args: {
|
|
|
1882
1930
|
}
|
|
1883
1931
|
args.scope.bindings.set(name, { kind: "flow", ref: name });
|
|
1884
1932
|
|
|
1885
|
-
const
|
|
1933
|
+
const effectsNode = objectProp(opts, "effects");
|
|
1934
|
+
const hasExplicitEffects = effectsNode !== undefined;
|
|
1886
1935
|
const doNode = objectProp(opts, "do");
|
|
1887
1936
|
const inferred = doNode
|
|
1888
1937
|
? inferEffects({
|
|
1889
1938
|
doNode,
|
|
1890
1939
|
bindings: args.scope.bindings,
|
|
1891
1940
|
hasExplicitEffects,
|
|
1941
|
+
flowName: name,
|
|
1942
|
+
file: args.file.path,
|
|
1943
|
+
resolveCallee: (callee, file) => args.scope.fxIndex.resolve(file, callee),
|
|
1892
1944
|
})
|
|
1893
1945
|
: {
|
|
1894
1946
|
effects: {} as Effects,
|
|
@@ -1900,8 +1952,16 @@ function registerFlow(args: {
|
|
|
1900
1952
|
};
|
|
1901
1953
|
|
|
1902
1954
|
let effects: Effects | undefined;
|
|
1903
|
-
if (hasExplicitEffects) {
|
|
1904
|
-
effects = parseEffectsObject(
|
|
1955
|
+
if (hasExplicitEffects && effectsNode?.type === "ObjectExpression") {
|
|
1956
|
+
effects = parseEffectsObject(effectsNode);
|
|
1957
|
+
const missing = effectsMissingFromDeclaration(effects ?? {}, inferred.effects);
|
|
1958
|
+
if (missing.length > 0) {
|
|
1959
|
+
throw new Error(
|
|
1960
|
+
`OKE1900: flow "${name}" declares effects that omit ${missing.join(", ")}. An effects block must include every effect inference can see.`,
|
|
1961
|
+
);
|
|
1962
|
+
}
|
|
1963
|
+
} else if (hasExplicitEffects) {
|
|
1964
|
+
effects = parseEffectsObject(effectsNode);
|
|
1905
1965
|
} else {
|
|
1906
1966
|
effects = inferred.effects;
|
|
1907
1967
|
}
|
|
@@ -2039,6 +2099,24 @@ function registerFlow(args: {
|
|
|
2039
2099
|
flow.cacheKeys = `computed:${effects.reads[0]}/userId`;
|
|
2040
2100
|
}
|
|
2041
2101
|
|
|
2102
|
+
const idempotencyOption = parseIdempotencyOption(opts);
|
|
2103
|
+
const httpMethod = manifestTrigger?.http?.method;
|
|
2104
|
+
const stream = boolProp(opts, "stream") === true;
|
|
2105
|
+
const live = typeof liveFromTrigger === "string";
|
|
2106
|
+
const canHonor =
|
|
2107
|
+
httpMethod !== "GET" && !stream && !live && (inferred.usesRaw || hasMutatingEffects(effects));
|
|
2108
|
+
if (idempotencyRequired(idempotencyOption) && !canHonor) {
|
|
2109
|
+
throw new Error(
|
|
2110
|
+
`flow "${name}" sets idempotency required, but the header would be ignored (read-only, GET, stream, or live).`,
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
const ttl = ttlOf(idempotencyOption);
|
|
2114
|
+
if (idempotencyTtlMs(ttl) <= 0) {
|
|
2115
|
+
throw new Error(`flow "${name}" has an idempotency ttl that is not a duration ("${ttl}").`);
|
|
2116
|
+
}
|
|
2117
|
+
flow.idempotency = resolveIdempotencyMode(idempotencyOption, canHonor);
|
|
2118
|
+
if (inferred.usesRaw) flow.usesRaw = true;
|
|
2119
|
+
|
|
2042
2120
|
putFlow(args.scope, name, flow);
|
|
2043
2121
|
}
|
|
2044
2122
|
|
|
@@ -3279,6 +3357,29 @@ function numberProp(obj: AstNode | undefined, key: string): number | undefined {
|
|
|
3279
3357
|
return typeof v === "number" ? v : undefined;
|
|
3280
3358
|
}
|
|
3281
3359
|
|
|
3360
|
+
function parseIdempotencyOption(opts: AstNode | undefined): FlowIdempotencyOption | undefined {
|
|
3361
|
+
const node = objectProp(opts, "idempotency");
|
|
3362
|
+
if (!node) return undefined;
|
|
3363
|
+
if (node.type === "Literal") {
|
|
3364
|
+
const value = (node as Literal).value;
|
|
3365
|
+
if (value === false) return false;
|
|
3366
|
+
if (value === "required") return "required";
|
|
3367
|
+
}
|
|
3368
|
+
if (node.type === "ObjectExpression") {
|
|
3369
|
+
const required = boolProp(node, "required");
|
|
3370
|
+
const ttl = stringProp(node, "ttl");
|
|
3371
|
+
return {
|
|
3372
|
+
...(required !== undefined ? { required } : {}),
|
|
3373
|
+
...(ttl !== undefined ? { ttl } : {}),
|
|
3374
|
+
};
|
|
3375
|
+
}
|
|
3376
|
+
return undefined;
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
function idempotencyRequired(option: FlowIdempotencyOption | undefined): boolean {
|
|
3380
|
+
return option === "required" || (typeof option === "object" && option.required === true);
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3282
3383
|
function boolProp(obj: AstNode | undefined, key: string): boolean | undefined {
|
|
3283
3384
|
const node = objectProp(obj, key);
|
|
3284
3385
|
if (!node || node.type !== "Literal") return undefined;
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
"app": "skyport",
|
|
4
4
|
"flows": {
|
|
5
5
|
"bookings.create": {
|
|
6
|
+
"idempotency": {
|
|
7
|
+
"mode": "auto",
|
|
8
|
+
"ttl": "24h"
|
|
9
|
+
},
|
|
6
10
|
"trigger": {
|
|
7
11
|
"http": {
|
|
8
12
|
"method": "POST",
|
|
@@ -29,12 +33,20 @@
|
|
|
29
33
|
"plane": "user"
|
|
30
34
|
},
|
|
31
35
|
"bookings.getBooking": {
|
|
36
|
+
"idempotency": {
|
|
37
|
+
"mode": "off",
|
|
38
|
+
"ttl": "24h"
|
|
39
|
+
},
|
|
32
40
|
"effects": {
|
|
33
41
|
"reads": ["sql:bookings"]
|
|
34
42
|
},
|
|
35
43
|
"source": "src/flows/bookings/index.ts:50"
|
|
36
44
|
},
|
|
37
45
|
"bookings.mine": {
|
|
46
|
+
"idempotency": {
|
|
47
|
+
"mode": "off",
|
|
48
|
+
"ttl": "24h"
|
|
49
|
+
},
|
|
38
50
|
"trigger": {
|
|
39
51
|
"http": {
|
|
40
52
|
"method": "GET",
|
|
@@ -49,6 +61,10 @@
|
|
|
49
61
|
"cacheKeys": "computed:sql:bookings/userId"
|
|
50
62
|
},
|
|
51
63
|
"bookings.refundBooking": {
|
|
64
|
+
"idempotency": {
|
|
65
|
+
"mode": "auto",
|
|
66
|
+
"ttl": "24h"
|
|
67
|
+
},
|
|
52
68
|
"effects": {
|
|
53
69
|
"reads": ["sql:bookings"],
|
|
54
70
|
"writes": ["sql:bookings"]
|
|
@@ -56,6 +72,10 @@
|
|
|
56
72
|
"source": "src/flows/bookings/index.ts:54"
|
|
57
73
|
},
|
|
58
74
|
"payments.chargeBooking": {
|
|
75
|
+
"idempotency": {
|
|
76
|
+
"mode": "off",
|
|
77
|
+
"ttl": "24h"
|
|
78
|
+
},
|
|
59
79
|
"effects": {
|
|
60
80
|
"secrets": ["STRIPE_KEY"]
|
|
61
81
|
},
|
|
@@ -64,6 +84,10 @@
|
|
|
64
84
|
"steps": ["create-intent", "confirm"]
|
|
65
85
|
},
|
|
66
86
|
"support.createTicket": {
|
|
87
|
+
"idempotency": {
|
|
88
|
+
"mode": "auto",
|
|
89
|
+
"ttl": "24h"
|
|
90
|
+
},
|
|
67
91
|
"trigger": {
|
|
68
92
|
"http": {
|
|
69
93
|
"method": "POST",
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect inference follows `fx` into helpers and same-function chain aliases.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { extractFromSources } from "./extract.ts";
|
|
7
|
+
|
|
8
|
+
const header = `
|
|
9
|
+
import { on, flow, http, store } from "okengine";
|
|
10
|
+
export const db = store.sql("app");
|
|
11
|
+
export const notes = store.schema.table("notes", { id: field.text().primaryKey() });
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
describe("extractManifest — fx helpers and chain aliases", () => {
|
|
15
|
+
test("a same-file helper's store read is part of the flow", async () => {
|
|
16
|
+
const manifest = await extractFromSources({
|
|
17
|
+
"src/flows/notes/get.ts": `
|
|
18
|
+
${header}
|
|
19
|
+
async function load(fx: Fx) {
|
|
20
|
+
await fx.store(db).select().from(notes);
|
|
21
|
+
}
|
|
22
|
+
export const get = on(http.get("/notes"), flow("notes.get", {
|
|
23
|
+
do: async (_input, fx) => {
|
|
24
|
+
await load(fx);
|
|
25
|
+
return { ok: true };
|
|
26
|
+
},
|
|
27
|
+
}));
|
|
28
|
+
`,
|
|
29
|
+
});
|
|
30
|
+
expect(manifest.flows?.["notes.get"]?.effects?.reads).toEqual(["sql:notes"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("an imported helper's store read is part of the flow", async () => {
|
|
34
|
+
const manifest = await extractFromSources({
|
|
35
|
+
"src/flows/notes/_shared.ts": `
|
|
36
|
+
${header}
|
|
37
|
+
export async function load(fx: Fx) {
|
|
38
|
+
await fx.store(db).select().from(notes);
|
|
39
|
+
}
|
|
40
|
+
`,
|
|
41
|
+
"src/flows/notes/get.ts": `
|
|
42
|
+
import { on, flow, http } from "okengine";
|
|
43
|
+
import { load } from "./_shared";
|
|
44
|
+
export const get = on(http.get("/notes"), flow("notes.get", {
|
|
45
|
+
do: async (_input, fx) => load(fx),
|
|
46
|
+
}));
|
|
47
|
+
`,
|
|
48
|
+
});
|
|
49
|
+
expect(manifest.flows?.["notes.get"]?.effects?.reads).toEqual(["sql:notes"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a same-function chain alias records the read", async () => {
|
|
53
|
+
const manifest = await extractFromSources({
|
|
54
|
+
"src/flows/notes/get.ts": `
|
|
55
|
+
${header}
|
|
56
|
+
export const get = on(http.get("/notes"), flow("notes.get", {
|
|
57
|
+
do: async (_input, fx) => {
|
|
58
|
+
const q = fx.store(db);
|
|
59
|
+
const rows = await q.select().from(notes);
|
|
60
|
+
return rows;
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
63
|
+
`,
|
|
64
|
+
});
|
|
65
|
+
expect(manifest.flows?.["notes.get"]?.effects?.reads).toEqual(["sql:notes"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("an as-cast store alias records the index write", async () => {
|
|
69
|
+
const manifest = await extractFromSources({
|
|
70
|
+
"src/flows/search/index.ts": `
|
|
71
|
+
import { on, flow, http, store } from "okengine";
|
|
72
|
+
export const taskIndex = store.index("tasks");
|
|
73
|
+
export const sync = on(http.post("/sync"), flow("search.sync", {
|
|
74
|
+
do: async (_input, fx) => {
|
|
75
|
+
const idx = fx.store(taskIndex) as {
|
|
76
|
+
driverId: string;
|
|
77
|
+
upsert: (id: string, doc: unknown) => Promise<void>;
|
|
78
|
+
};
|
|
79
|
+
if (idx.driverId === "meilisearch") {
|
|
80
|
+
await idx.upsert("1", { id: "1" });
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
}));
|
|
84
|
+
`,
|
|
85
|
+
});
|
|
86
|
+
expect(manifest.flows?.["search.sync"]?.effects?.writes).toEqual(["index:tasks"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a chain alias that escapes fails extract", async () => {
|
|
90
|
+
const bodies = [
|
|
91
|
+
`const q = fx.store(db); return q;`,
|
|
92
|
+
`const q = fx.store(db); await other(q);`,
|
|
93
|
+
`let q = fx.store(db); q = fx.store(db);`,
|
|
94
|
+
`const q = fx.store(db); const bag = { q }; return bag;`,
|
|
95
|
+
`const q = fx.store(db); const bag = [q]; return bag;`,
|
|
96
|
+
];
|
|
97
|
+
for (const body of bodies) {
|
|
98
|
+
await expect(
|
|
99
|
+
extractFromSources({
|
|
100
|
+
"src/flows/notes/get.ts": `
|
|
101
|
+
${header}
|
|
102
|
+
export const get = on(http.get("/notes"), flow("notes.get", {
|
|
103
|
+
do: async (_input, fx) => {
|
|
104
|
+
${body}
|
|
105
|
+
},
|
|
106
|
+
}));
|
|
107
|
+
`,
|
|
108
|
+
}),
|
|
109
|
+
).rejects.toThrow(/OKE1900/);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("renaming or destructuring fx fails extract", async () => {
|
|
114
|
+
const bodies = [
|
|
115
|
+
`do: async (input, ctx) => ({ ok: true })`,
|
|
116
|
+
`do: async (_input, fx) => { const ctx = fx; return ctx.id(); }`,
|
|
117
|
+
`do: async (_input, fx) => { const { store } = fx; return store; }`,
|
|
118
|
+
];
|
|
119
|
+
for (const body of bodies) {
|
|
120
|
+
await expect(
|
|
121
|
+
extractFromSources({
|
|
122
|
+
"src/flows/notes/get.ts": `
|
|
123
|
+
${header}
|
|
124
|
+
export const get = on(http.get("/notes"), flow("notes.get", { ${body} }));
|
|
125
|
+
`,
|
|
126
|
+
}),
|
|
127
|
+
).rejects.toThrow(/OKE1900/);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("an effects block must cover inferred reads and may add keys", async () => {
|
|
132
|
+
await expect(
|
|
133
|
+
extractFromSources({
|
|
134
|
+
"src/flows/notes/get.ts": `
|
|
135
|
+
${header}
|
|
136
|
+
async function load(fx: Fx) {
|
|
137
|
+
await fx.store(db).select().from(notes);
|
|
138
|
+
}
|
|
139
|
+
export const get = on(http.get("/notes"), flow("notes.get", {
|
|
140
|
+
effects: { reads: ["sql:other"] },
|
|
141
|
+
do: async (_input, fx) => load(fx),
|
|
142
|
+
}));
|
|
143
|
+
`,
|
|
144
|
+
}),
|
|
145
|
+
).rejects.toThrow(/OKE1900/);
|
|
146
|
+
|
|
147
|
+
const manifest = await extractFromSources({
|
|
148
|
+
"src/flows/notes/get.ts": `
|
|
149
|
+
${header}
|
|
150
|
+
async function load(fx: Fx) {
|
|
151
|
+
await fx.store(db).select().from(notes);
|
|
152
|
+
}
|
|
153
|
+
export const get = on(http.get("/notes"), flow("notes.get", {
|
|
154
|
+
effects: { reads: ["sql:notes"], writes: ["kv:extra"] },
|
|
155
|
+
do: async (_input, fx) => load(fx),
|
|
156
|
+
}));
|
|
157
|
+
`,
|
|
158
|
+
});
|
|
159
|
+
expect(manifest.flows?.["notes.get"]?.effects).toMatchObject({
|
|
160
|
+
reads: ["sql:notes"],
|
|
161
|
+
writes: ["kv:extra"],
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|