okengine 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/AGENTS.md +4 -4
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +15 -9
  5. package/manifest.v1.schema.json +10 -0
  6. package/package.json +22 -25
  7. package/site/content/docs/ai/skills.mdx +1 -1
  8. package/site/content/docs/client/calling.mdx +27 -19
  9. package/site/content/docs/client/index.mdx +1 -1
  10. package/site/content/docs/elements/channel/email.mdx +1 -1
  11. package/site/content/docs/elements/channel/index.mdx +1 -1
  12. package/site/content/docs/elements/flow/http.mdx +4 -4
  13. package/site/content/docs/elements/flow/index.mdx +3 -3
  14. package/site/content/docs/elements/flow/meta.json +1 -1
  15. package/site/content/docs/elements/gate/tenancy.mdx +1 -1
  16. package/site/content/docs/elements/vault/rotation.mdx +15 -0
  17. package/site/content/docs/plugins/otp.mdx +31 -31
  18. package/site/content/docs/recipes/mailpit.mdx +1 -1
  19. package/site/content/docs/recipes/meilisearch.mdx +1 -1
  20. package/site/content/docs/recipes/pgdog.mdx +1 -1
  21. package/site/content/docs/reference/configuration.mdx +2 -2
  22. package/site/content/docs/reference/errors.mdx +12 -2
  23. package/site/content/docs/reference/fx.mdx +28 -15
  24. package/site/content/docs/reference/idempotency.mdx +192 -0
  25. package/site/content/docs/reference/index.mdx +6 -1
  26. package/site/content/docs/reference/meta.json +2 -1
  27. package/site/content/docs/understand/meta.json +1 -1
  28. package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
  29. package/site/content/docs/understand/the-architecture.mdx +14 -12
  30. package/site/content/docs/understand/try-it.mdx +3 -3
  31. package/src/cli/competitor-mention-removal.test.ts +3 -3
  32. package/src/cli/dev.test.ts +105 -2
  33. package/src/cli/dev.ts +37 -2
  34. package/src/cli/docker-cli.test.ts +1 -1
  35. package/src/cli/index.ts +9 -1
  36. package/src/cli/load-config.images.test.ts +10 -10
  37. package/src/cli/load-config.ts +1 -1
  38. package/src/cli/vault-cmd.test.ts +23 -0
  39. package/src/cli/vault-cmd.ts +7 -0
  40. package/src/client/create.ts +31 -27
  41. package/src/client/live.ts +44 -101
  42. package/src/client/sse.ts +20 -66
  43. package/src/client/stream.ts +25 -67
  44. package/src/client/transport.test.ts +225 -0
  45. package/src/client/transport.ts +158 -90
  46. package/src/client/types.ts +29 -3
  47. package/src/client/wire.ts +119 -0
  48. package/src/compiler/aot.ts +3 -32
  49. package/src/compiler/dynamic.ts +13 -11
  50. package/src/compiler/effects-infer.ts +726 -16
  51. package/src/compiler/extract.test.ts +37 -0
  52. package/src/compiler/extract.ts +113 -12
  53. package/src/compiler/fixtures/skyport.expected.json +24 -0
  54. package/src/compiler/fx-follow.test.ts +164 -0
  55. package/src/compiler/fx-index.ts +399 -0
  56. package/src/compiler/interpret.ts +45 -0
  57. package/src/console/server/flows-invoke.test.ts +2 -2
  58. package/src/console/ui-next/dist/assets/{access-page-tIbsiphz.js → access-page-Ze6ckjdd.js} +2 -2
  59. package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
  60. package/src/console/ui-next/dist/assets/{cache-glyph-BCC-DxKT.js → cache-glyph-rL0lHxat.js} +1 -1
  61. package/src/console/ui-next/dist/assets/{call-pii-button-jOmYISdc.js → call-pii-button-Deo4PP18.js} +1 -1
  62. package/src/console/ui-next/dist/assets/{collapsible-DC2xNaAb.js → collapsible-CoJ6amHf.js} +1 -1
  63. package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
  64. package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
  65. package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
  66. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
  67. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
  68. package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
  69. package/src/console/ui-next/dist/assets/{flows-page-gT1lsWQK.js → flows-page-D2E7BtKK.js} +1 -1
  70. package/src/console/ui-next/dist/assets/{highlighted-json-C2GZEJNI.js → highlighted-json-CPYBzDh0.js} +1 -1
  71. package/src/console/ui-next/dist/assets/{http-method-BdYjcIrD.js → http-method-DTPevKmM.js} +1 -1
  72. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
  73. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
  74. package/src/console/ui-next/dist/assets/{replica-lag-C4QdAF7J.js → replica-lag-BM27lXld.js} +3 -3
  75. package/src/console/ui-next/dist/assets/{request-meta-C43DHyld.js → request-meta-BjFT7DNl.js} +1 -1
  76. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
  77. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
  78. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Dcpo4Us_.js → trace-detail-sheet-Cmitq3da.js} +2 -2
  79. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CKJTuv43.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
  80. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
  81. package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
  82. package/src/console/ui-next/dist/assets/{vault-page-BwZ9YjTW.js → vault-page-BOMr0go9.js} +2 -2
  83. package/src/console/ui-next/dist/index.html +2 -2
  84. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
  85. package/src/docker/docker.test.ts +13 -13
  86. package/src/docker/images-config.test.ts +12 -12
  87. package/src/docker/stack-id.test.ts +1 -1
  88. package/src/drivers/journal-postgres.ts +168 -3
  89. package/src/drivers/meilisearch.ts +2 -0
  90. package/src/drivers/vault-builtin.test.ts +10 -0
  91. package/src/drivers/vault-builtin.ts +13 -1
  92. package/src/drivers/vault-remote-bag.ts +5 -1
  93. package/src/drivers/vault-types.ts +6 -1
  94. package/src/elements/store/emit-drizzle.ts +2 -2
  95. package/src/elements/store/live-default.test.ts +2 -0
  96. package/src/elements/store/live-http.test.ts +1 -0
  97. package/src/elements/store/resource-list-docs.test.ts +2 -2
  98. package/src/elements/store/resource.test.ts +3 -3
  99. package/src/elements/store/schema-decl.test.ts +1 -0
  100. package/src/elements/store/sql-condition.ts +24 -7
  101. package/src/elements/store/sql-session.ts +20 -4
  102. package/src/elements/store/upsert-app.test.ts +1 -1
  103. package/src/elements/vault/audit.test.ts +137 -0
  104. package/src/elements/vault/audit.ts +106 -13
  105. package/src/elements/vault/boot-chain.ts +9 -1
  106. package/src/elements/vault/builtin-adapter.ts +33 -3
  107. package/src/i18n/catalogs/ar.ts +4 -0
  108. package/src/i18n/catalogs/en.ts +4 -0
  109. package/src/kernel/abort-scope.ts +33 -2
  110. package/src/kernel/app.ts +200 -20
  111. package/src/kernel/auto-cache.test.ts +6 -6
  112. package/src/kernel/boot-bind/vault.ts +1 -0
  113. package/src/kernel/boot.ts +19 -1
  114. package/src/kernel/builtin-errors.ts +8 -0
  115. package/src/kernel/client-descriptor.test.ts +78 -0
  116. package/src/kernel/client-descriptor.ts +24 -0
  117. package/src/kernel/concurrency.test.ts +33 -0
  118. package/src/kernel/effects-stamping.test.ts +2 -2
  119. package/src/kernel/errors-compiler.ts +20 -0
  120. package/src/kernel/errors-text.ts +99 -0
  121. package/src/kernel/errors.ts +90 -138
  122. package/src/kernel/external-effects.test.ts +36 -0
  123. package/src/kernel/flow.ts +24 -0
  124. package/src/kernel/fx-fetch.ts +5 -1
  125. package/src/kernel/fx-sql-handle.ts +305 -0
  126. package/src/kernel/fx.ts +42 -330
  127. package/src/kernel/idempotency-store.ts +578 -0
  128. package/src/kernel/idempotency.test.ts +556 -0
  129. package/src/kernel/idempotency.ts +304 -0
  130. package/src/kernel/index.ts +1 -0
  131. package/src/kernel/journal.ts +56 -0
  132. package/src/kernel/json-result.ts +59 -0
  133. package/src/kernel/project-out.ts +6 -1
  134. package/src/manifest/types.ts +8 -0
  135. package/src/okid-extended.ts +175 -0
  136. package/src/okid-shared.ts +103 -0
  137. package/src/okid.ts +30 -213
  138. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  139. package/src/plugins/otp.test.ts +65 -1
  140. package/src/plugins/otp.ts +59 -7
  141. package/src/release/absolute-regression.test.ts +63 -0
  142. package/src/release/build-lib.ts +9 -0
  143. package/src/release/http-graph.test.ts +25 -0
  144. package/src/release/http-graph.ts +90 -0
  145. package/src/release/index.ts +3 -0
  146. package/src/release/limits.ts +17 -2
  147. package/src/release/measure.ts +105 -14
  148. package/src/test/create-test-app.test.ts +3 -1
  149. package/src/test/create-test-app.ts +40 -0
  150. package/src/test/live-signals.test.ts +1 -0
  151. package/src/test/provisions.integration.test.ts +1 -0
  152. package/src/test/tenant-isolation.test.ts +1 -0
  153. package/src/console/ui-next/dist/assets/agent-disclosure-CSKumwS2.js +0 -1
  154. package/src/console/ui-next/dist/assets/duration-tone-CwoV56jn.js +0 -9
  155. package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
  156. package/src/console/ui-next/dist/assets/index-Cul17AcV.js +0 -63
  157. package/src/console/ui-next/dist/assets/observability-page-oY9vdYBk.js +0 -4
  158. package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
  159. package/src/console/ui-next/dist/assets/store-page-CL0D9dOq.js +0 -41
  160. package/src/console/ui-next/dist/assets/units-page-1PlT19ft.js +0 -1
@@ -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", () => {
@@ -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
- for await (const path of glob.scan({
302
- cwd: rootDir,
303
- onlyFiles: true,
304
- })) {
305
- if (isSkippedExtractSource(path)) continue;
306
- const abs = join(rootDir, path);
307
- files.push({ path: toPosixPath(path), source: await Bun.file(abs).text() });
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 hasExplicitEffects = objectProp(opts, "effects") !== undefined;
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(objectProp(opts, "effects"));
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
+ });