okengine 0.21.1 → 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 (140) 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 -20
  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/docker-cli.test.ts +1 -1
  33. package/src/cli/index.ts +9 -1
  34. package/src/cli/load-config.images.test.ts +10 -10
  35. package/src/cli/load-config.ts +1 -1
  36. package/src/cli/vault-cmd.test.ts +23 -0
  37. package/src/cli/vault-cmd.ts +7 -0
  38. package/src/client/create.ts +18 -1
  39. package/src/client/transport.test.ts +225 -0
  40. package/src/client/transport.ts +132 -19
  41. package/src/client/types.ts +29 -3
  42. package/src/compiler/effects-infer.ts +726 -16
  43. package/src/compiler/extract.test.ts +37 -0
  44. package/src/compiler/extract.ts +113 -12
  45. package/src/compiler/fixtures/skyport.expected.json +24 -0
  46. package/src/compiler/fx-follow.test.ts +164 -0
  47. package/src/compiler/fx-index.ts +399 -0
  48. package/src/console/server/flows-invoke.test.ts +2 -2
  49. package/src/console/ui-next/dist/assets/{access-page-Bgt9bq2r.js → access-page-Ze6ckjdd.js} +2 -2
  50. package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
  51. package/src/console/ui-next/dist/assets/{cache-glyph-92uM5MO7.js → cache-glyph-rL0lHxat.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{call-pii-button-DtGVPtcs.js → call-pii-button-Deo4PP18.js} +1 -1
  53. package/src/console/ui-next/dist/assets/{collapsible-DN7l6zmC.js → collapsible-CoJ6amHf.js} +1 -1
  54. package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
  55. package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
  56. package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
  57. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
  58. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
  59. package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
  60. package/src/console/ui-next/dist/assets/{flows-page-BMHs-IzK.js → flows-page-D2E7BtKK.js} +1 -1
  61. package/src/console/ui-next/dist/assets/{highlighted-json-BlAEVgNW.js → highlighted-json-CPYBzDh0.js} +1 -1
  62. package/src/console/ui-next/dist/assets/{http-method-BDf7OAHv.js → http-method-DTPevKmM.js} +1 -1
  63. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
  64. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
  65. package/src/console/ui-next/dist/assets/{replica-lag-B3GLNVfF.js → replica-lag-BM27lXld.js} +3 -3
  66. package/src/console/ui-next/dist/assets/{request-meta-DMbnAe3f.js → request-meta-BjFT7DNl.js} +1 -1
  67. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
  68. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
  69. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Htk8Cm9t.js → trace-detail-sheet-Cmitq3da.js} +2 -2
  70. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CFMPWX4f.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
  71. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
  72. package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
  73. package/src/console/ui-next/dist/assets/{vault-page-CBYl0LW_.js → vault-page-BOMr0go9.js} +2 -2
  74. package/src/console/ui-next/dist/index.html +2 -2
  75. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
  76. package/src/docker/docker.test.ts +13 -13
  77. package/src/docker/images-config.test.ts +12 -12
  78. package/src/docker/stack-id.test.ts +1 -1
  79. package/src/drivers/journal-postgres.ts +168 -3
  80. package/src/drivers/meilisearch.ts +2 -0
  81. package/src/drivers/vault-builtin.test.ts +10 -0
  82. package/src/drivers/vault-builtin.ts +13 -1
  83. package/src/drivers/vault-remote-bag.ts +5 -1
  84. package/src/drivers/vault-types.ts +6 -1
  85. package/src/elements/store/emit-drizzle.ts +2 -2
  86. package/src/elements/store/live-default.test.ts +2 -0
  87. package/src/elements/store/live-http.test.ts +1 -0
  88. package/src/elements/store/resource-list-docs.test.ts +2 -2
  89. package/src/elements/store/resource.test.ts +3 -3
  90. package/src/elements/store/schema-decl.test.ts +1 -0
  91. package/src/elements/store/sql-condition.ts +24 -7
  92. package/src/elements/store/upsert-app.test.ts +1 -1
  93. package/src/elements/vault/audit.test.ts +137 -0
  94. package/src/elements/vault/audit.ts +106 -13
  95. package/src/elements/vault/boot-chain.ts +9 -1
  96. package/src/elements/vault/builtin-adapter.ts +33 -3
  97. package/src/i18n/catalogs/ar.ts +4 -0
  98. package/src/i18n/catalogs/en.ts +4 -0
  99. package/src/kernel/abort-scope.ts +33 -2
  100. package/src/kernel/app.ts +191 -18
  101. package/src/kernel/auto-cache.test.ts +6 -6
  102. package/src/kernel/boot-bind/vault.ts +1 -0
  103. package/src/kernel/boot.ts +19 -1
  104. package/src/kernel/builtin-errors.ts +8 -0
  105. package/src/kernel/client-descriptor.ts +1 -0
  106. package/src/kernel/concurrency.test.ts +33 -0
  107. package/src/kernel/effects-stamping.test.ts +2 -2
  108. package/src/kernel/errors-compiler.ts +20 -0
  109. package/src/kernel/external-effects.test.ts +36 -0
  110. package/src/kernel/flow.ts +24 -0
  111. package/src/kernel/fx-fetch.ts +5 -1
  112. package/src/kernel/fx.ts +5 -1
  113. package/src/kernel/idempotency-store.ts +578 -0
  114. package/src/kernel/idempotency.test.ts +556 -0
  115. package/src/kernel/idempotency.ts +304 -0
  116. package/src/kernel/index.ts +1 -0
  117. package/src/kernel/journal.ts +56 -0
  118. package/src/manifest/types.ts +8 -0
  119. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  120. package/src/plugins/otp.test.ts +65 -1
  121. package/src/plugins/otp.ts +59 -7
  122. package/src/release/absolute-regression.test.ts +63 -0
  123. package/src/release/http-graph.test.ts +25 -0
  124. package/src/release/http-graph.ts +90 -0
  125. package/src/release/index.ts +3 -0
  126. package/src/release/limits.ts +17 -2
  127. package/src/release/measure.ts +105 -14
  128. package/src/test/create-test-app.test.ts +3 -1
  129. package/src/test/create-test-app.ts +40 -0
  130. package/src/test/live-signals.test.ts +1 -0
  131. package/src/test/provisions.integration.test.ts +1 -0
  132. package/src/test/tenant-isolation.test.ts +1 -0
  133. package/src/console/ui-next/dist/assets/agent-disclosure-B43CXZZR.js +0 -1
  134. package/src/console/ui-next/dist/assets/duration-tone-BmIR9FV8.js +0 -9
  135. package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
  136. package/src/console/ui-next/dist/assets/index-BKpaes3n.js +0 -63
  137. package/src/console/ui-next/dist/assets/observability-page-WnVLI-0j.js +0 -4
  138. package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
  139. package/src/console/ui-next/dist/assets/store-page-KvFDingJ.js +0 -41
  140. package/src/console/ui-next/dist/assets/units-page-C4NdNuxP.js +0 -1
@@ -73,6 +73,12 @@ export interface InferBinding {
73
73
  readonly version?: number;
74
74
  }
75
75
 
76
+ /** What a callee that is passed `fx` resolves to. */
77
+ export type FxCalleeResolution =
78
+ | { readonly kind: "function"; readonly fn: AstNode; readonly file?: string }
79
+ | { readonly kind: "intrinsic"; readonly name: "liveQuery" | "applySearchEmbedCdc" }
80
+ | { readonly kind: "unresolved"; readonly label: string };
81
+
76
82
  /** Options for {@link inferEffects}. */
77
83
  export interface InferEffectsOptions {
78
84
  /** The `do` handler AST node (function / arrow). */
@@ -81,9 +87,25 @@ export interface InferEffectsOptions {
81
87
  readonly bindings: ReadonlyMap<string, InferBinding>;
82
88
  /**
83
89
  * True when the flow options already declare an `effects` object.
84
- * Required for `fx.raw` to stay cache-eligible.
90
+ * Required for `fx.raw` to stay cache-eligible. Opaque `fx` (a renamed
91
+ * parameter, an escaped chain alias, an unresolved callee) is allowed
92
+ * only when this is set; the declared set must still cover every effect
93
+ * the walk can see.
85
94
  */
86
95
  readonly hasExplicitEffects: boolean;
96
+ /** Manifest flow name, used in OKE1900. */
97
+ readonly flowName?: string;
98
+ /**
99
+ * Resolve a callee in the flow's file. Absent only for unit tests that
100
+ * never pass `fx` into another function.
101
+ */
102
+ /** File that contains `do`, so imported helpers resolve from there. */
103
+ readonly file?: string;
104
+ /**
105
+ * Resolve a callee as seen from `file`. Absent only for unit tests that
106
+ * never pass `fx` into another function.
107
+ */
108
+ readonly resolveCallee?: (callee: AstNode, file: string) => FxCalleeResolution;
87
109
  }
88
110
 
89
111
  /** Result of effect inference for one flow body. */
@@ -171,10 +193,15 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
171
193
  const steps: string[] = [];
172
194
  let usesRaw = false;
173
195
 
174
- for (const call of collectCallExpressions(options.doNode)) {
175
- const chain = fxMemberChain(call);
176
- if (!chain) continue;
196
+ const opaque: string[] = [];
197
+ const userIdRoots: AstNode[] = [options.doNode];
198
+ const intrinsics: Array<{
199
+ name: "liveQuery" | "applySearchEmbedCdc";
200
+ call: CallExpression;
201
+ }> = [];
202
+ const chains = collectFxChains(options, opaque, userIdRoots, intrinsics);
177
203
 
204
+ for (const { call, chain } of chains) {
178
205
  if (chain.rootMethod === "raw") {
179
206
  usesRaw = true;
180
207
  continue;
@@ -301,14 +328,14 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
301
328
  }
302
329
 
303
330
  if (chain.rootMethod === "store") {
304
- const resolved = storeResourceFromCall(call, options.bindings);
331
+ const resolved = storeResourceFromCall(call, options.bindings, chain);
305
332
  if (!resolved) continue;
306
333
  const op = classifyStoreMethods(resolved.methods);
307
334
  if (op === "none") continue;
308
335
  // Skip incomplete chains (`select` before `.from`, `insert` before table) —
309
336
  // the sibling call that carries the table arg records the real resource.
310
337
  const leaf = resolved.methods[resolved.methods.length - 1]!;
311
- const hasTable = tableFromStoreChain(call, options.bindings) !== undefined;
338
+ const hasTable = tableFromStoreChain(call, options.bindings, chain) !== undefined;
312
339
  if (!hasTable && (leaf === "select" || leaf === "insert" || leaf === "update")) {
313
340
  continue;
314
341
  }
@@ -323,6 +350,20 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
323
350
  }
324
351
  }
325
352
 
353
+ for (const item of intrinsics) {
354
+ noteIntrinsic(item.name, item.call, options.bindings, reads, writes, embeds, (detail) => {
355
+ opaque.push(detail);
356
+ });
357
+ }
358
+
359
+ if (opaque.length > 0 && !options.hasExplicitEffects) {
360
+ const flow = options.flowName ?? "flow";
361
+ const detail = opaque[0] ?? "hides fx from effect inference";
362
+ throw new Error(
363
+ `OKE1900: flow "${flow}" ${detail}. Name the parameter fx and keep chain aliases in the same function, or declare effects that include every effect inference can see.`,
364
+ );
365
+ }
366
+
326
367
  const effects: Effects = {};
327
368
  if (reads.size > 0) effects.reads = sortUnique([...reads]);
328
369
  if (writes.size > 0) effects.writes = sortUnique([...writes]);
@@ -340,10 +381,42 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
340
381
  usesRaw,
341
382
  cacheIneligible: usesRaw && !options.hasExplicitEffects,
342
383
  nondeterministic: asks.size > 0 || embeds.size > 0,
343
- readsUserId: containsAuthUserId(options.doNode),
384
+ readsUserId: userIdRoots.some((node) => containsAuthUserId(node)),
344
385
  };
345
386
  }
346
387
 
388
+ const EFFECT_KEYS = [
389
+ "reads",
390
+ "writes",
391
+ "emits",
392
+ "sends",
393
+ "asks",
394
+ "embeds",
395
+ "secrets",
396
+ "calls",
397
+ "fetches",
398
+ ] as const satisfies readonly (keyof Effects)[];
399
+
400
+ /**
401
+ * Keys in `inferred` that `declared` does not include.
402
+ *
403
+ * An explicit `effects` object is a floor: it may add keys inference
404
+ * cannot see, and it must include every key the walk can see.
405
+ *
406
+ * @param declared - Author-declared effects
407
+ * @param inferred - Effects the walk can see
408
+ */
409
+ export function effectsMissingFromDeclaration(declared: Effects, inferred: Effects): string[] {
410
+ const missing: string[] = [];
411
+ for (const key of EFFECT_KEYS) {
412
+ const have = new Set<string>(declared[key] ?? []);
413
+ for (const item of inferred[key] ?? []) {
414
+ if (!have.has(item)) missing.push(`${key} ${item}`);
415
+ }
416
+ }
417
+ return missing;
418
+ }
419
+
347
420
  /**
348
421
  * Walk an AST and collect every CallExpression.
349
422
  *
@@ -380,6 +453,8 @@ interface FxChain {
380
453
  rootMethod: string;
381
454
  methods: string[];
382
455
  rootCall: CallExpression;
456
+ /** Outermost call of an alias init, when the continuation is a later statement. */
457
+ aliasLeaf?: CallExpression;
383
458
  }
384
459
 
385
460
  /**
@@ -451,15 +526,16 @@ export function fxMemberChain(call: CallExpression): FxChain | null {
451
526
  export function storeResourceFromCall(
452
527
  call: CallExpression,
453
528
  bindings: ReadonlyMap<string, InferBinding>,
529
+ known?: FxChain,
454
530
  ): { resource: ResourceRef; methods: string[] } | undefined {
455
- const chain = fxMemberChain(call);
531
+ const chain = known ?? fxMemberChain(call);
456
532
  if (!chain || chain.rootMethod !== "store") return undefined;
457
533
 
458
534
  const storeArg = chain.rootCall.arguments[0];
459
535
  const storeBinding = resolveBinding(storeArg, bindings);
460
536
  const facet = storeBinding?.facet ?? "sql";
461
537
 
462
- const table = tableFromStoreChain(call, bindings);
538
+ const table = tableFromStoreChain(call, bindings, chain);
463
539
 
464
540
  if (table) {
465
541
  return {
@@ -506,18 +582,36 @@ export function storeResourceFromCall(
506
582
  function tableFromStoreChain(
507
583
  call: CallExpression,
508
584
  bindings: ReadonlyMap<string, InferBinding>,
585
+ chain?: FxChain,
586
+ ): string | undefined {
587
+ const found = tableFromStoreWalk(call, bindings, chain);
588
+ if (found) return found;
589
+ if (chain?.aliasLeaf && chain.aliasLeaf !== call) {
590
+ return tableFromStoreWalk(chain.aliasLeaf, bindings);
591
+ }
592
+ return undefined;
593
+ }
594
+
595
+ function tableFromStoreWalk(
596
+ call: CallExpression,
597
+ bindings: ReadonlyMap<string, InferBinding>,
598
+ chain?: FxChain,
509
599
  ): string | undefined {
510
600
  let current: AstNode | undefined = call;
511
- while (current && current.type === "CallExpression") {
601
+ const seen = new Set<AstNode>();
602
+ while (current && current.type === "CallExpression" && !seen.has(current)) {
603
+ seen.add(current);
512
604
  const c = current as CallExpression;
513
- const link = fxMemberChain(c);
514
- if (!link || link.rootMethod !== "store") break;
515
- const leaf = link.methods[link.methods.length - 1]!;
605
+ const link = chain ? undefined : fxMemberChain(c);
606
+ if (!chain && (!link || link.rootMethod !== "store")) break;
607
+ const leaf = chain ? methodNameOfCall(c) : link?.methods[link.methods.length - 1];
608
+ if (!leaf) break;
516
609
  if (TABLE_ARG_METHODS.has(leaf)) {
517
610
  // KV / files / index take a key, not a table. Treating `delete(key)` as
518
611
  // `delete(table)` stamps `kv:key` instead of `kv:drafts` and the runtime
519
612
  // then throws OKE1002 on the real namespace.
520
- const storeBinding = resolveBinding(link.rootCall.arguments[0], bindings);
613
+ const storeArg = chain?.rootCall.arguments[0] ?? link?.rootCall.arguments[0];
614
+ const storeBinding = resolveBinding(storeArg, bindings);
521
615
  if (storeBinding?.facet !== undefined && storeBinding.facet !== "sql") {
522
616
  // keep walking — no table on this link
523
617
  } else {
@@ -530,14 +624,24 @@ function tableFromStoreChain(
530
624
  }
531
625
  const callee = c.callee;
532
626
  if (callee.type === "MemberExpression") {
533
- current = (callee as AstNode & { object: AstNode }).object;
534
- continue;
627
+ const obj = (callee as AstNode & { object: AstNode }).object;
628
+ if (obj.type === "CallExpression") {
629
+ current = obj;
630
+ continue;
631
+ }
535
632
  }
536
633
  break;
537
634
  }
538
635
  return undefined;
539
636
  }
540
637
 
638
+ function methodNameOfCall(call: CallExpression): string | undefined {
639
+ const callee = call.callee;
640
+ if (callee.type !== "MemberExpression") return undefined;
641
+ const property = (callee as AstNode & { property?: AstNode }).property;
642
+ return identifierName(property);
643
+ }
644
+
541
645
  function classifyStoreMethods(methods: string[]): "read" | "write" | "both" | "none" {
542
646
  const ops = methods.slice(1);
543
647
  let read = false;
@@ -751,3 +855,609 @@ function containsAuthUserId(root: AstNode): boolean {
751
855
  });
752
856
  return found;
753
857
  }
858
+
859
+ interface ChainAlias {
860
+ readonly methods: readonly string[];
861
+ readonly rootMethod: string;
862
+ readonly rootCall: CallExpression;
863
+ readonly leafCall: CallExpression;
864
+ }
865
+
866
+ interface ChainHit {
867
+ readonly call: CallExpression;
868
+ readonly chain: FxChain;
869
+ }
870
+
871
+ const VALUE_WRAPPERS = new Set([
872
+ "ParenthesizedExpression",
873
+ "TSAsExpression",
874
+ "TSTypeAssertion",
875
+ "TSSatisfiesExpression",
876
+ "TSNonNullExpression",
877
+ "AwaitExpression",
878
+ ]);
879
+
880
+ function collectFxChains(
881
+ options: InferEffectsOptions,
882
+ opaque: string[],
883
+ userIdRoots: AstNode[],
884
+ intrinsics: Array<{ name: "liveQuery" | "applySearchEmbedCdc"; call: CallExpression }>,
885
+ ): ChainHit[] {
886
+ const hits: ChainHit[] = [];
887
+ const seen = new Set<AstNode>();
888
+
889
+ const follow = (
890
+ fn: AstNode,
891
+ file: string,
892
+ aliases: ReadonlyMap<string, ChainAlias>,
893
+ locals: ReadonlyMap<string, AstNode>,
894
+ ): void => {
895
+ analyzeFunction(fn, "fx", aliases, locals, file);
896
+ };
897
+
898
+ const analyzeFunction = (
899
+ fn: AstNode,
900
+ door: string,
901
+ outerAliases: ReadonlyMap<string, ChainAlias>,
902
+ outerLocals: ReadonlyMap<string, AstNode>,
903
+ file: string,
904
+ ): void => {
905
+ if (seen.has(fn)) return;
906
+ seen.add(fn);
907
+ userIdRoots.push(fn);
908
+ const body = (fn as AstNode & { body?: AstNode }).body;
909
+ if (!body) return;
910
+ analyzeBody(body, door, outerAliases, outerLocals, file);
911
+ };
912
+
913
+ const analyzeBody = (
914
+ body: AstNode,
915
+ door: string,
916
+ outerAliases: ReadonlyMap<string, ChainAlias>,
917
+ outerLocals: ReadonlyMap<string, AstNode>,
918
+ file: string,
919
+ ): void => {
920
+ const aliases = new Map(outerAliases);
921
+ const locals = new Map(outerLocals);
922
+ collectDirect(body, aliases, locals);
923
+ checkEscapes(body, door, new Set(aliases.keys()), (detail) => {
924
+ opaque.push(detail);
925
+ });
926
+ for (const call of callsSkippingNested(body)) {
927
+ const chain = chainFromCall(call, aliases);
928
+ if (chain) {
929
+ hits.push({ call, chain });
930
+ continue;
931
+ }
932
+ noteFxPass(call, door, aliases, locals, file, opaque, intrinsics, follow);
933
+ }
934
+ for (const nested of nestedFunctions(body)) {
935
+ if (seen.has(nested)) continue;
936
+ const shadows = functionParams(nested).some((param) => paramIdentifierName(param) === door);
937
+ if (shadows) continue;
938
+ analyzeFunction(nested, door, aliases, locals, file);
939
+ }
940
+ };
941
+
942
+ const startFile = options.file ?? "";
943
+ if (isFunctionNode(options.doNode)) {
944
+ const params = functionParams(options.doNode);
945
+ const second = params.length >= 2 ? params[1] : undefined;
946
+ if (second && paramIdentifierName(second) !== "fx") {
947
+ opaque.push("the do callback's second parameter is not named fx");
948
+ }
949
+ analyzeFunction(options.doNode, "fx", new Map(), new Map(), startFile);
950
+ } else {
951
+ analyzeBody(options.doNode, "fx", new Map(), new Map(), startFile);
952
+ }
953
+
954
+ return hits;
955
+
956
+ function noteFxPass(
957
+ call: CallExpression,
958
+ door: string,
959
+ aliases: ReadonlyMap<string, ChainAlias>,
960
+ locals: ReadonlyMap<string, AstNode>,
961
+ file: string,
962
+ reasons: string[],
963
+ foundIntrinsics: Array<{ name: "liveQuery" | "applySearchEmbedCdc"; call: CallExpression }>,
964
+ enter: (
965
+ fn: AstNode,
966
+ file: string,
967
+ aliases: ReadonlyMap<string, ChainAlias>,
968
+ locals: ReadonlyMap<string, AstNode>,
969
+ ) => void,
970
+ ): void {
971
+ const args = call.arguments ?? [];
972
+ for (let index = 0; index < args.length; index++) {
973
+ const arg = unwrapValue(args[index]);
974
+ if (!arg || arg.type !== "Identifier") continue;
975
+ const name = identifierName(arg);
976
+ if (!name) continue;
977
+ if (aliases.has(name)) {
978
+ reasons.push(`passes chain alias "${name}" to a call`);
979
+ continue;
980
+ }
981
+ if (name !== door) continue;
982
+ const resolved = resolveCallee(call.callee, locals, options, file);
983
+ if (resolved.kind === "function") {
984
+ const param = functionParams(resolved.fn)[index];
985
+ if (!param || paramIdentifierName(param) !== "fx") {
986
+ reasons.push("passes fx to a parameter that is not named fx");
987
+ continue;
988
+ }
989
+ const local = calleeLocal(call.callee, locals);
990
+ enter(
991
+ resolved.fn,
992
+ resolved.file ?? file,
993
+ local ? aliases : new Map(),
994
+ local ? locals : new Map(),
995
+ );
996
+ continue;
997
+ }
998
+ if (resolved.kind === "intrinsic") {
999
+ if (index !== 0) {
1000
+ reasons.push(`passes fx to ${resolved.name} in a position inference cannot read`);
1001
+ continue;
1002
+ }
1003
+ foundIntrinsics.push({ name: resolved.name, call });
1004
+ continue;
1005
+ }
1006
+ reasons.push(`passes fx to "${resolved.label}", which inference cannot resolve`);
1007
+ }
1008
+ }
1009
+ }
1010
+
1011
+ function calleeLocal(callee: AstNode, locals: ReadonlyMap<string, AstNode>): boolean {
1012
+ if (callee.type !== "Identifier") return false;
1013
+ const name = identifierName(callee);
1014
+ return name !== undefined && locals.has(name);
1015
+ }
1016
+
1017
+ function resolveCallee(
1018
+ callee: AstNode,
1019
+ locals: ReadonlyMap<string, AstNode>,
1020
+ options: InferEffectsOptions,
1021
+ file: string,
1022
+ ): FxCalleeResolution {
1023
+ if (callee.type === "Identifier") {
1024
+ const name = identifierName(callee);
1025
+ const local = name ? locals.get(name) : undefined;
1026
+ if (local) return { kind: "function", fn: local, file };
1027
+ }
1028
+ if (options.resolveCallee) return options.resolveCallee(callee, file);
1029
+ const label = identifierName(callee) ?? "callee";
1030
+ return { kind: "unresolved", label };
1031
+ }
1032
+
1033
+ function noteIntrinsic(
1034
+ name: "liveQuery" | "applySearchEmbedCdc",
1035
+ call: CallExpression,
1036
+ bindings: ReadonlyMap<string, InferBinding>,
1037
+ reads: Set<ResourceRef | SignalResourceRef | AuthApiKeysResourceRef | AuthTenantsResourceRef>,
1038
+ writes: Set<ResourceRef | AuthApiKeysResourceRef | AuthTenantsResourceRef>,
1039
+ embeds: Set<EmbedRef>,
1040
+ opaque: (detail: string) => void,
1041
+ ): void {
1042
+ if (name === "liveQuery") {
1043
+ const ref = sqlRefFromArg(call.arguments[1], bindings);
1044
+ if (!ref) {
1045
+ opaque("calls liveQuery with a table inference cannot resolve");
1046
+ return;
1047
+ }
1048
+ reads.add(ref as ResourceRef);
1049
+ return;
1050
+ }
1051
+ const sqlRef = stringArg(unwrapValueNode(call.arguments[5]));
1052
+ if (!sqlRef) {
1053
+ opaque("calls applySearchEmbedCdc with a sqlRef inference cannot resolve");
1054
+ return;
1055
+ }
1056
+ const ref = (sqlRef.includes(":") ? sqlRef : `sql:${sqlRef}`) as ResourceRef;
1057
+ reads.add(ref);
1058
+ writes.add(ref);
1059
+ const models = embedModelsFromColumns(call.arguments[3]);
1060
+ if (!models) {
1061
+ opaque("calls applySearchEmbedCdc with embed columns inference cannot resolve");
1062
+ return;
1063
+ }
1064
+ for (const model of models) embeds.add(model);
1065
+ }
1066
+
1067
+ function sqlRefFromArg(
1068
+ arg: AstNode | undefined,
1069
+ bindings: ReadonlyMap<string, InferBinding>,
1070
+ ): string | undefined {
1071
+ if (!arg) return undefined;
1072
+ const node = unwrapValue(arg);
1073
+ const lit = stringArg(node);
1074
+ if (lit) return lit.includes(":") ? lit : `sql:${lit}`;
1075
+ const binding = resolveBinding(node, bindings);
1076
+ if (binding?.kind === "table") return sqlTableRef(binding.ref);
1077
+ if (binding?.kind === "store") return binding.ref;
1078
+ return undefined;
1079
+ }
1080
+
1081
+ function embedModelsFromColumns(arg: AstNode | undefined): string[] | null {
1082
+ if (!arg) return null;
1083
+ const node = unwrapValue(arg);
1084
+ if (!node || node.type !== "ArrayExpression") return null;
1085
+ const elements = (node as AstNode & { elements?: readonly (AstNode | null)[] }).elements ?? [];
1086
+ const models: string[] = [];
1087
+ for (const element of elements) {
1088
+ if (!element || element.type !== "ObjectExpression") return null;
1089
+ const model = objectStringProp(element, "model");
1090
+ if (model === undefined) {
1091
+ if (objectHasProp(element, "model")) return null;
1092
+ models.push("default");
1093
+ continue;
1094
+ }
1095
+ models.push(model);
1096
+ }
1097
+ return models;
1098
+ }
1099
+
1100
+ function objectStringProp(obj: AstNode, key: string): string | undefined {
1101
+ for (const prop of objectProps(obj)) {
1102
+ if (propKeyName(prop) !== key) continue;
1103
+ return stringArg(unwrapValue((prop as AstNode & { value?: AstNode }).value));
1104
+ }
1105
+ return undefined;
1106
+ }
1107
+
1108
+ function objectHasProp(obj: AstNode, key: string): boolean {
1109
+ return objectProps(obj).some((prop) => propKeyName(prop) === key);
1110
+ }
1111
+
1112
+ function objectProps(obj: AstNode): AstNode[] {
1113
+ return ((obj as AstNode & { properties?: AstNode[] }).properties ?? []).filter(
1114
+ (prop) => prop.type === "Property" || prop.type === "ObjectProperty",
1115
+ );
1116
+ }
1117
+
1118
+ function propKeyName(prop: AstNode): string | undefined {
1119
+ const key = (prop as AstNode & { key?: AstNode }).key;
1120
+ return identifierName(key) ?? stringArg(key);
1121
+ }
1122
+
1123
+ function collectDirect(
1124
+ body: AstNode,
1125
+ aliases: Map<string, ChainAlias>,
1126
+ locals: Map<string, AstNode>,
1127
+ ): void {
1128
+ walkStatements(body, (node) => {
1129
+ if (node.type === "FunctionDeclaration") {
1130
+ const name = identifierName((node as AstNode & { id?: AstNode }).id);
1131
+ if (name) locals.set(name, node);
1132
+ return;
1133
+ }
1134
+ if (node.type !== "VariableDeclarator") return;
1135
+ const name = identifierName((node as AstNode & { id?: AstNode }).id);
1136
+ const init = (node as AstNode & { init?: AstNode | null }).init;
1137
+ if (!name || !init) return;
1138
+ const value = unwrapValue(init);
1139
+ if (value && isFunctionNode(value)) {
1140
+ locals.set(name, value);
1141
+ return;
1142
+ }
1143
+ const alias = aliasFromInit(init, aliases);
1144
+ if (alias) aliases.set(name, alias);
1145
+ });
1146
+ }
1147
+
1148
+ function aliasFromInit(init: AstNode, aliases: ReadonlyMap<string, ChainAlias>): ChainAlias | null {
1149
+ const node = unwrapValue(init);
1150
+ if (!node || node.type !== "CallExpression") return null;
1151
+ const chain = chainFromCall(node as CallExpression, aliases);
1152
+ if (!chain || chain.rootMethod !== "store") return null;
1153
+ // `const rows = await fx.store(db).select().from(table)` is the query
1154
+ // result. `const q = await fx.store(db)` is still the handle.
1155
+ if (expressionIsAwaited(init) && !isStoreHandle(chain)) return null;
1156
+ return {
1157
+ methods: chain.methods,
1158
+ rootMethod: chain.rootMethod,
1159
+ rootCall: chain.rootCall,
1160
+ leafCall: chain.aliasLeaf ?? (node as CallExpression),
1161
+ };
1162
+ }
1163
+
1164
+ function isStoreHandle(chain: FxChain): boolean {
1165
+ return chain.rootMethod === "store" && classifyStoreMethods(chain.methods) === "none";
1166
+ }
1167
+
1168
+ function expressionIsAwaited(node: AstNode): boolean {
1169
+ let current: AstNode | undefined = node;
1170
+ for (let i = 0; i < 8 && current; i++) {
1171
+ if (current.type === "AwaitExpression") return true;
1172
+ if (!VALUE_WRAPPERS.has(current.type)) return false;
1173
+ current =
1174
+ (current as AstNode & { expression?: AstNode }).expression ??
1175
+ (current as AstNode & { argument?: AstNode }).argument;
1176
+ }
1177
+ return false;
1178
+ }
1179
+
1180
+ function chainFromCall(
1181
+ call: CallExpression,
1182
+ aliases: ReadonlyMap<string, ChainAlias>,
1183
+ ): FxChain | null {
1184
+ const direct = fxMemberChain(call);
1185
+ if (direct) return direct;
1186
+
1187
+ let current: CallExpression | null = call;
1188
+ const methods: string[] = [];
1189
+ while (current) {
1190
+ const callee = current.callee;
1191
+ if (callee.type !== "MemberExpression") return null;
1192
+ const member = callee as AstNode & {
1193
+ object: AstNode;
1194
+ property: AstNode;
1195
+ computed?: boolean;
1196
+ };
1197
+ if (member.computed === true || member.property.type !== "Identifier") return null;
1198
+ methods.unshift((member.property as Identifier).name);
1199
+ if (member.object.type === "Identifier") {
1200
+ const alias = aliases.get((member.object as Identifier).name);
1201
+ if (!alias) return null;
1202
+ return {
1203
+ rootMethod: alias.rootMethod,
1204
+ methods: [...alias.methods, ...methods],
1205
+ rootCall: alias.rootCall,
1206
+ aliasLeaf: alias.leafCall,
1207
+ };
1208
+ }
1209
+ if (member.object.type === "CallExpression") {
1210
+ current = member.object as CallExpression;
1211
+ continue;
1212
+ }
1213
+ return null;
1214
+ }
1215
+ return null;
1216
+ }
1217
+
1218
+ function checkEscapes(
1219
+ root: AstNode,
1220
+ door: string,
1221
+ aliasNames: ReadonlySet<string>,
1222
+ report: (detail: string) => void,
1223
+ ): void {
1224
+ const rec = (node: AstNode, parent: AstNode | undefined, inCallArg: boolean): void => {
1225
+ if (node !== root && isFunctionNode(node)) return;
1226
+ if (node.type === "Identifier") {
1227
+ const name = (node as Identifier).name;
1228
+ const isAlias = aliasNames.has(name);
1229
+ const isDoor = name === door;
1230
+ if (
1231
+ (isAlias || isDoor) &&
1232
+ !isTypePosition(parent) &&
1233
+ !(isDoor && inCallArg) &&
1234
+ !isAllowedFxUse(node, parent)
1235
+ ) {
1236
+ report(describeFxEscape(name, parent, isAlias));
1237
+ }
1238
+ }
1239
+ if (node.type === "CallExpression") {
1240
+ const call = node as CallExpression;
1241
+ rec(call.callee, node, false);
1242
+ for (const arg of call.arguments ?? []) {
1243
+ if (arg) rec(arg, node, true);
1244
+ }
1245
+ return;
1246
+ }
1247
+ if (VALUE_WRAPPERS.has(node.type) && inCallArg) {
1248
+ const inner =
1249
+ (node as AstNode & { expression?: AstNode }).expression ??
1250
+ (node as AstNode & { argument?: AstNode }).argument;
1251
+ if (inner) rec(inner, node, true);
1252
+ return;
1253
+ }
1254
+ for (const value of Object.values(node)) {
1255
+ if (Array.isArray(value)) {
1256
+ for (const item of value) {
1257
+ if (isAstNode(item)) rec(item, node, inCallArg);
1258
+ }
1259
+ } else if (isAstNode(value)) {
1260
+ rec(value, node, inCallArg);
1261
+ }
1262
+ }
1263
+ };
1264
+ rec(root, undefined, false);
1265
+ }
1266
+
1267
+ function isAllowedFxUse(node: AstNode, parent: AstNode | undefined): boolean {
1268
+ if (!parent) return false;
1269
+ if (parent.type === "MemberExpression") {
1270
+ const member = parent as AstNode & { object?: AstNode; property?: AstNode; computed?: boolean };
1271
+ if (member.property === node && member.computed !== true) return true;
1272
+ if (member.object === node) return true;
1273
+ }
1274
+ if (parent.type === "VariableDeclarator" && (parent as AstNode & { id?: AstNode }).id === node) {
1275
+ return true;
1276
+ }
1277
+ if (
1278
+ (parent.type === "FunctionDeclaration" ||
1279
+ parent.type === "FunctionExpression" ||
1280
+ parent.type === "ArrowFunctionExpression") &&
1281
+ (parent as AstNode & { id?: AstNode }).id === node
1282
+ ) {
1283
+ return true;
1284
+ }
1285
+ return false;
1286
+ }
1287
+
1288
+ function describeFxEscape(name: string, parent: AstNode | undefined, isAlias: boolean): string {
1289
+ if (isAlias) {
1290
+ if (parent?.type === "ReturnStatement") return `returns chain alias "${name}"`;
1291
+ if (parent?.type === "CallExpression") return `passes chain alias "${name}" to a call`;
1292
+ if (
1293
+ parent?.type === "ArrayExpression" ||
1294
+ parent?.type === "ObjectExpression" ||
1295
+ parent?.type === "Property" ||
1296
+ parent?.type === "ObjectProperty"
1297
+ ) {
1298
+ return `puts chain alias "${name}" in an object or array`;
1299
+ }
1300
+ if (parent?.type === "AssignmentExpression") return `reassigns chain alias "${name}"`;
1301
+ return `lets chain alias "${name}" escape`;
1302
+ }
1303
+ if (parent?.type === "VariableDeclarator") return "aliases fx";
1304
+ if (
1305
+ parent?.type === "ObjectPattern" ||
1306
+ parent?.type === "ArrayPattern" ||
1307
+ parent?.type === "Property" ||
1308
+ parent?.type === "ObjectProperty" ||
1309
+ parent?.type === "RestElement"
1310
+ ) {
1311
+ return "destructures fx";
1312
+ }
1313
+ return "lets fx escape";
1314
+ }
1315
+
1316
+ function isTypePosition(parent: AstNode | undefined): boolean {
1317
+ if (!parent) return false;
1318
+ if (!parent.type.startsWith("TS")) return false;
1319
+ return !VALUE_WRAPPERS.has(parent.type);
1320
+ }
1321
+
1322
+ function callsSkippingNested(body: AstNode): CallExpression[] {
1323
+ const out: CallExpression[] = [];
1324
+ const take = (node: AstNode): void => {
1325
+ if (node.type === "CallExpression") out.push(node as CallExpression);
1326
+ };
1327
+ take(body);
1328
+ walkSkipFunctions(body, take);
1329
+ return out;
1330
+ }
1331
+
1332
+ function nestedFunctions(body: AstNode): AstNode[] {
1333
+ const out: AstNode[] = [];
1334
+ walkSkipFunctions(body, (node) => {
1335
+ if (isFunctionNode(node)) out.push(node);
1336
+ });
1337
+ return out;
1338
+ }
1339
+
1340
+ function walkSkipFunctions(root: AstNode, visit: (node: AstNode) => void): void {
1341
+ const rec = (node: AstNode): void => {
1342
+ for (const value of Object.values(node)) {
1343
+ if (Array.isArray(value)) {
1344
+ for (const item of value) {
1345
+ if (!isAstNode(item)) continue;
1346
+ visit(item);
1347
+ if (!isFunctionNode(item)) rec(item);
1348
+ }
1349
+ } else if (isAstNode(value)) {
1350
+ visit(value);
1351
+ if (!isFunctionNode(value)) rec(value);
1352
+ }
1353
+ }
1354
+ };
1355
+ rec(root);
1356
+ }
1357
+
1358
+ function walkStatements(node: AstNode, visit: (node: AstNode) => void): void {
1359
+ if (node.type === "BlockStatement" || node.type === "Program" || node.type === "StaticBlock") {
1360
+ const body = (node as AstNode & { body?: AstNode[] }).body ?? [];
1361
+ for (const stmt of body) walkStatement(stmt, visit);
1362
+ return;
1363
+ }
1364
+ walkSkipFunctions(node, (child) => {
1365
+ if (child.type === "VariableDeclarator" || child.type === "FunctionDeclaration") visit(child);
1366
+ });
1367
+ }
1368
+
1369
+ function walkStatement(stmt: AstNode, visit: (node: AstNode) => void): void {
1370
+ if (stmt.type === "FunctionDeclaration") {
1371
+ visit(stmt);
1372
+ return;
1373
+ }
1374
+ if (stmt.type === "VariableDeclaration") {
1375
+ const declarations = (stmt as AstNode & { declarations?: AstNode[] }).declarations ?? [];
1376
+ for (const decl of declarations) visit(decl);
1377
+ return;
1378
+ }
1379
+ if (stmt.type === "BlockStatement") {
1380
+ walkStatements(stmt, visit);
1381
+ return;
1382
+ }
1383
+ if (
1384
+ stmt.type === "IfStatement" ||
1385
+ stmt.type === "ForStatement" ||
1386
+ stmt.type === "ForInStatement" ||
1387
+ stmt.type === "ForOfStatement" ||
1388
+ stmt.type === "WhileStatement" ||
1389
+ stmt.type === "DoWhileStatement" ||
1390
+ stmt.type === "LabeledStatement"
1391
+ ) {
1392
+ const consequent = (stmt as AstNode & { consequent?: AstNode }).consequent;
1393
+ const alternate = (stmt as AstNode & { alternate?: AstNode }).alternate;
1394
+ const body = (stmt as AstNode & { body?: AstNode }).body;
1395
+ if (consequent) walkStatement(consequent, visit);
1396
+ if (alternate) walkStatement(alternate, visit);
1397
+ if (body && body !== consequent) walkStatement(body, visit);
1398
+ return;
1399
+ }
1400
+ if (stmt.type === "TryStatement") {
1401
+ const block = (stmt as AstNode & { block?: AstNode }).block;
1402
+ const handler = (stmt as AstNode & { handler?: AstNode }).handler;
1403
+ const finalizer = (stmt as AstNode & { finalizer?: AstNode }).finalizer;
1404
+ if (block) walkStatement(block, visit);
1405
+ const handlerBody = (handler as (AstNode & { body?: AstNode }) | undefined)?.body;
1406
+ if (handlerBody) walkStatement(handlerBody, visit);
1407
+ if (finalizer) walkStatement(finalizer, visit);
1408
+ return;
1409
+ }
1410
+ if (stmt.type === "SwitchStatement") {
1411
+ const cases = (stmt as AstNode & { cases?: AstNode[] }).cases ?? [];
1412
+ for (const item of cases) {
1413
+ const consequent = (item as AstNode & { consequent?: AstNode[] }).consequent ?? [];
1414
+ for (const child of consequent) walkStatement(child, visit);
1415
+ }
1416
+ }
1417
+ }
1418
+
1419
+ function functionParams(fn: AstNode): AstNode[] {
1420
+ return (fn as AstNode & { params?: AstNode[] }).params ?? [];
1421
+ }
1422
+
1423
+ function paramIdentifierName(param: AstNode): string | undefined {
1424
+ if (param.type === "Identifier") return identifierName(param);
1425
+ if (param.type === "AssignmentPattern" || param.type === "RestElement") {
1426
+ const inner =
1427
+ (param as AstNode & { left?: AstNode }).left ??
1428
+ (param as AstNode & { argument?: AstNode }).argument;
1429
+ return identifierName(inner);
1430
+ }
1431
+ return undefined;
1432
+ }
1433
+
1434
+ function isFunctionNode(node: AstNode): boolean {
1435
+ return (
1436
+ node.type === "ArrowFunctionExpression" ||
1437
+ node.type === "FunctionExpression" ||
1438
+ node.type === "FunctionDeclaration"
1439
+ );
1440
+ }
1441
+
1442
+ function unwrapValue(node: AstNode | undefined): AstNode | undefined {
1443
+ if (!node) return undefined;
1444
+ return unwrapValueNode(node);
1445
+ }
1446
+
1447
+ function unwrapValueNode(node: AstNode | undefined): AstNode | undefined {
1448
+ if (!node) return undefined;
1449
+ let current = node;
1450
+ for (let i = 0; i < 8; i++) {
1451
+ if (!VALUE_WRAPPERS.has(current.type)) break;
1452
+ const next =
1453
+ (current as AstNode & { expression?: AstNode }).expression ??
1454
+ (current as AstNode & { argument?: AstNode }).argument;
1455
+ if (!next) break;
1456
+ current = next;
1457
+ }
1458
+ return current;
1459
+ }
1460
+
1461
+ function isAstNode(value: unknown): value is AstNode {
1462
+ return typeof value === "object" && value !== null && typeof (value as AstNode).type === "string";
1463
+ }