okengine 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/site/content/docs/elements/clock.mdx +2 -2
  4. package/site/content/docs/elements/flow.mdx +10 -10
  5. package/site/content/docs/elements/gate.mdx +4 -4
  6. package/site/content/docs/elements/signal.mdx +4 -7
  7. package/site/content/docs/elements/store.mdx +1 -1
  8. package/site/content/docs/elements/vault.mdx +1 -1
  9. package/site/content/docs/get-started/basic-usage.mdx +1 -3
  10. package/site/content/docs/get-started/introduction.mdx +1 -1
  11. package/site/content/docs/reference/cli.md +12 -12
  12. package/site/content/docs/reference/fx.mdx +3 -4
  13. package/src/auth/auth.test.ts +1 -2
  14. package/src/auth/bindings.ts +5 -15
  15. package/src/auth/gate-auth.test.ts +2 -2
  16. package/src/cli/ai-setup/apply.ts +13 -0
  17. package/src/cli/build.test.ts +67 -0
  18. package/src/cli/build.ts +34 -0
  19. package/src/cli/db-seed.ts +1 -1
  20. package/src/cli/dev-app-runner.ts +6 -0
  21. package/src/cli/dev-controls.test.ts +43 -48
  22. package/src/cli/dev-controls.ts +23 -172
  23. package/src/cli/dev.test.ts +2 -4
  24. package/src/cli/dev.ts +63 -16
  25. package/src/cli/docker-cli.test.ts +15 -0
  26. package/src/cli/start.ts +12 -1
  27. package/src/client/notes-contract.test.ts +3 -4
  28. package/src/compiler/aot.test.ts +1 -2
  29. package/src/compiler/effects-infer.ts +37 -8
  30. package/src/compiler/extract.test.ts +186 -6
  31. package/src/compiler/extract.ts +31 -6
  32. package/src/compiler/fixtures/raw/raw-unannotated.ts +2 -4
  33. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +4 -8
  34. package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +1 -2
  35. package/src/compiler/fixtures/skyport/src/flows/support/index.ts +1 -2
  36. package/src/compiler/fixtures/skyport.expected.json +3 -3
  37. package/src/compiler/fixtures/triggers/five-triggers.ts +5 -10
  38. package/src/compiler/generate-adopt.test.ts +85 -0
  39. package/src/compiler/generate-adopt.ts +85 -0
  40. package/src/console/server/dry-run.audit.test.ts +2 -2
  41. package/src/console/server/flows.ts +50 -150
  42. package/src/docker/compose.ts +13 -3
  43. package/src/docker/recipes/llama-cpp.ts +10 -1
  44. package/src/docker/stack-id.ts +1 -0
  45. package/src/elements/channel/declare.ts +27 -1
  46. package/src/elements/clock/chaos-child.ts +3 -6
  47. package/src/elements/clock/chaos.test.ts +1 -2
  48. package/src/elements/clock.test.ts +2 -4
  49. package/src/elements/signal/declare.ts +25 -1
  50. package/src/elements/store/cache.test.ts +28 -0
  51. package/src/elements/store/declare.ts +27 -1
  52. package/src/elements/store/resource.ts +6 -11
  53. package/src/elements/store/upsert-app.test.ts +1 -2
  54. package/src/elements/vault/declare.ts +29 -1
  55. package/src/kernel/adopt-barrel-fresh.test.ts +103 -0
  56. package/src/kernel/app.ts +80 -9
  57. package/src/kernel/auto-registry.test.ts +198 -0
  58. package/src/kernel/boot-bind/honor-config.test.ts +5 -5
  59. package/src/kernel/boot.test.ts +19 -15
  60. package/src/kernel/boot.ts +186 -8
  61. package/src/kernel/call.test.ts +5 -10
  62. package/src/kernel/compensate.test.ts +3 -6
  63. package/src/kernel/concurrency.test.ts +3 -5
  64. package/src/kernel/correlation.test.ts +4 -8
  65. package/src/kernel/edge.test.ts +1 -1
  66. package/src/kernel/effects-stamping.test.ts +204 -0
  67. package/src/kernel/element-registries.ts +27 -0
  68. package/src/kernel/errors.ts +22 -0
  69. package/src/kernel/flow.test.ts +8 -15
  70. package/src/kernel/flow.ts +12 -14
  71. package/src/kernel/fx.ts +42 -13
  72. package/src/kernel/hook-timing.test.ts +2 -2
  73. package/src/kernel/hooks.test.ts +5 -10
  74. package/src/kernel/horizontal-child.ts +5 -10
  75. package/src/kernel/journal-boot.test.ts +2 -4
  76. package/src/kernel/pipeline.test.ts +6 -12
  77. package/src/kernel/plugin/capabilities.test.ts +1 -1
  78. package/src/kernel/plugin/decorate.test.ts +3 -8
  79. package/src/kernel/plugin/scoping.test.ts +6 -16
  80. package/src/kernel/plugin-elements.test.ts +1 -1
  81. package/src/kernel/plugin-needs.test.ts +1 -1
  82. package/src/kernel/ready.test.ts +1 -2
  83. package/src/kernel/registry-isolation.test.ts +5 -5
  84. package/src/kernel/triggers.ts +1 -1
  85. package/src/manifest/sql-resource.ts +41 -0
  86. package/src/plugins/anonymous.ts +1 -3
  87. package/src/plugins/compression.test.ts +6 -8
  88. package/src/plugins/config-source.test.ts +1 -1
  89. package/src/plugins/config-source.ts +1 -2
  90. package/src/plugins/cors.test.ts +10 -10
  91. package/src/plugins/csrf.test.ts +2 -2
  92. package/src/plugins/headers.test.ts +14 -15
  93. package/src/plugins/ip-allowlist.test.ts +9 -9
  94. package/src/plugins/magic-link.ts +2 -6
  95. package/src/plugins/maintenance-mode.test.ts +6 -6
  96. package/src/plugins/otp.ts +5 -15
  97. package/src/plugins/passkey.ts +4 -12
  98. package/src/plugins/two-factor.ts +3 -9
  99. package/src/plugins/username.ts +2 -6
  100. package/src/release/measure.ts +1 -1
  101. package/src/runs/runs.test.ts +3 -5
  102. package/src/runtime/serve.test.ts +2 -4
  103. package/src/test/create-test-app.test.ts +3 -7
  104. package/src/test/provisions.integration.test.ts +4 -9
  105. package/src/test/reset-element-registries.ts +31 -0
@@ -15,6 +15,7 @@ import type {
15
15
  TemplateRef,
16
16
  FlowRef,
17
17
  } from "../manifest/types.ts";
18
+ import { sqlTableRef } from "../manifest/sql-resource.ts";
18
19
 
19
20
  /** Minimal ESTree-shaped nodes produced by oxc-parser. */
20
21
  export interface AstNode {
@@ -54,6 +55,7 @@ export interface InferBinding {
54
55
  | "template"
55
56
  | "flow"
56
57
  | "embed"
58
+ | "table"
57
59
  | "unknown";
58
60
  /** Resolved resource / name. */
59
61
  readonly ref: string;
@@ -103,9 +105,20 @@ const READ_METHODS = new Set([
103
105
  "findMany",
104
106
  "findFirst",
105
107
  "find",
108
+ "list",
106
109
  ]);
107
110
 
108
- const WRITE_METHODS = new Set(["insert", "set", "delete", "increment", "update", "upsert", "log"]);
111
+ const WRITE_METHODS = new Set([
112
+ "insert",
113
+ "set",
114
+ "delete",
115
+ "increment",
116
+ "update",
117
+ "upsert",
118
+ "log",
119
+ "put",
120
+ "putImage",
121
+ ]);
109
122
 
110
123
  /** Methods whose first argument is a table / collection identifier. */
111
124
  const TABLE_ARG_METHODS = new Set([
@@ -203,7 +216,7 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
203
216
  // Skip incomplete chains (`select` before `.from`, `insert` before table) —
204
217
  // the sibling call that carries the table arg records the real resource.
205
218
  const leaf = resolved.methods[resolved.methods.length - 1]!;
206
- const hasTable = tableFromStoreChain(call) !== undefined;
219
+ const hasTable = tableFromStoreChain(call, options.bindings) !== undefined;
207
220
  if (!hasTable && (leaf === "select" || leaf === "insert" || leaf === "update")) {
208
221
  continue;
209
222
  }
@@ -352,11 +365,14 @@ export function storeResourceFromCall(
352
365
  const storeBinding = resolveBinding(storeArg, bindings);
353
366
  const facet = storeBinding?.facet ?? "sql";
354
367
 
355
- const table = tableFromStoreChain(call);
368
+ const table = tableFromStoreChain(call, bindings);
356
369
 
357
370
  if (table) {
358
371
  return {
359
- resource: `${facet}:${table}` as ResourceRef,
372
+ // sql:<table> is the shared naming convention with the kernel's
373
+ // runtime capability gate (see ../manifest/sql-resource.ts) — table
374
+ // args only ever occur on sql-facet methods in practice.
375
+ resource: (facet === "sql" ? sqlTableRef(table) : `${facet}:${table}`) as ResourceRef,
360
376
  methods: chain.methods,
361
377
  };
362
378
  }
@@ -382,11 +398,21 @@ export function storeResourceFromCall(
382
398
  }
383
399
 
384
400
  /**
385
- * Walk an `fx.store(…).a().b(table)` chain and return the table identifier.
401
+ * Walk an `fx.store(…).a().b(table)` chain and return the declared table
402
+ * name — resolved through a registered `table` binding (the real string
403
+ * passed to `store.schema.table(name, …)`) when the argument is one, so a
404
+ * JS binding named differently from its declared table (`const notesTable
405
+ * = store.schema.table("notes", …)`) still resolves to `"notes"`, matching
406
+ * what the kernel reads off the live table object at call time. Falls back
407
+ * to the raw identifier text otherwise.
386
408
  *
387
409
  * @param call - Any call in the chain
410
+ * @param bindings - Scope bindings
388
411
  */
389
- function tableFromStoreChain(call: CallExpression): string | undefined {
412
+ function tableFromStoreChain(
413
+ call: CallExpression,
414
+ bindings: ReadonlyMap<string, InferBinding>,
415
+ ): string | undefined {
390
416
  let current: AstNode | undefined = call;
391
417
  while (current && current.type === "CallExpression") {
392
418
  const c = current as CallExpression;
@@ -394,8 +420,11 @@ function tableFromStoreChain(call: CallExpression): string | undefined {
394
420
  if (!link || link.rootMethod !== "store") break;
395
421
  const leaf = link.methods[link.methods.length - 1]!;
396
422
  if (TABLE_ARG_METHODS.has(leaf)) {
397
- const table = identifierName(c.arguments[0]);
398
- if (table) return table;
423
+ const id = identifierName(c.arguments[0]);
424
+ if (id) {
425
+ const binding = bindings.get(id);
426
+ return binding?.kind === "table" ? binding.ref : id;
427
+ }
399
428
  }
400
429
  const callee = c.callee;
401
430
  if (callee.type === "MemberExpression") {
@@ -123,8 +123,7 @@ describe("extractManifest — performance", () => {
123
123
  parts.push(`
124
124
  export const flow_${i} = on(
125
125
  http.get("/f/${i}"),
126
- flow({
127
- name: "synth.flow_${i}",
126
+ flow("synth.flow_${i}", {
128
127
  do: async (input, fx) => {
129
128
  await fx.store(db).insert(items).values(input);
130
129
  const rows = await fx.store(db).select().from(items);
@@ -166,8 +165,7 @@ export const daily = { name: "daily" };
166
165
 
167
166
  export const listLinks = on(
168
167
  http.get("/links"),
169
- flow({
170
- name: "rel.root",
168
+ flow("rel.root", {
171
169
  do: async (_input, fx) => {
172
170
  return fx.store(db).select().from(links);
173
171
  },
@@ -178,8 +176,7 @@ export const listLinks = on(
178
176
  // relations, so the related table must NOT appear in effects.
179
177
  export const withDaily = on(
180
178
  http.get("/links/with-daily"),
181
- flow({
182
- name: "rel.with",
179
+ flow("rel.with", {
183
180
  do: async (_input, fx) => {
184
181
  return fx.store(db).findMany({ with: { daily: true } });
185
182
  },
@@ -344,3 +341,186 @@ export const remove = mounted.remove;
344
341
  expect(manifest.flows?.list?.breaking).toBe(true);
345
342
  });
346
343
  });
344
+
345
+ describe("extractManifest — files-store write methods", () => {
346
+ test("fx.store(files).put(...) infers a write, unannotated", async () => {
347
+ const source = `
348
+ import { on, flow, http, gate, store } from "okengine";
349
+
350
+ export const files = store.files("uploads");
351
+
352
+ export const attach = on(
353
+ http.post("/attach").gate(gate.public),
354
+ flow("notes.attach", {
355
+ do: async (input, fx) => {
356
+ await fx.store(files).put("key", input.text);
357
+ return { ok: true };
358
+ },
359
+ }),
360
+ );
361
+ `;
362
+ const manifest = await extractFromSources({ "src/flows/attach.ts": source });
363
+
364
+ expect(manifest.flows?.["notes.attach"]?.effects?.writes).toEqual(["files:uploads"]);
365
+ });
366
+
367
+ test("fx.store(files).putImage(...) infers a write, unannotated", async () => {
368
+ const source = `
369
+ import { on, flow, http, gate, store } from "okengine";
370
+
371
+ export const files = store.files("uploads");
372
+
373
+ export const attach = on(
374
+ http.post("/attach").gate(gate.public),
375
+ flow("notes.attachImage", {
376
+ do: async (input, fx) => {
377
+ await fx.store(files).putImage("key", input.bytes);
378
+ return { ok: true };
379
+ },
380
+ }),
381
+ );
382
+ `;
383
+ const manifest = await extractFromSources({ "src/flows/attach.ts": source });
384
+
385
+ expect(manifest.flows?.["notes.attachImage"]?.effects?.writes).toEqual(["files:uploads"]);
386
+ });
387
+
388
+ test("fx.store(files).list(...) infers a read, unannotated", async () => {
389
+ const source = `
390
+ import { on, flow, every, store } from "okengine";
391
+
392
+ export const files = store.files("uploads");
393
+
394
+ export const sweep = on(
395
+ every("1d"),
396
+ flow("notes.sweep", {
397
+ do: async (_input, fx) => {
398
+ const keys = await fx.store(files).list();
399
+ return { count: keys.length };
400
+ },
401
+ }),
402
+ );
403
+ `;
404
+ const manifest = await extractFromSources({ "src/flows/sweep.ts": source });
405
+
406
+ expect(manifest.flows?.["notes.sweep"]?.effects?.reads).toEqual(["files:uploads"]);
407
+ });
408
+ });
409
+
410
+ describe("extractManifest — channel medium binder aliasing", () => {
411
+ test("mail.template(...) resolves through `const mail = channel.email(...)`", async () => {
412
+ const source = `
413
+ import { on, flow, http, gate, channel } from "okengine";
414
+
415
+ const mail = channel.email({ from: "Notes <notes@localhost>" });
416
+
417
+ export const noteCreatedMail = mail.template("note-created", {
418
+ locales: ["en", "ar"],
419
+ });
420
+
421
+ export const onCreated = on(
422
+ http.post("/hook").gate(gate.public),
423
+ flow("notes.onCreated", {
424
+ do: async (payload, fx) => {
425
+ await fx.send(noteCreatedMail, { to: "you@localhost", data: payload });
426
+ },
427
+ }),
428
+ );
429
+ `;
430
+ const manifest = await extractFromSources({ "src/flows/notes.ts": source });
431
+
432
+ expect(manifest.flows?.["notes.onCreated"]?.effects?.sends).toEqual(["note-created"]);
433
+ expect(manifest.channels?.["note-created"]?.medium).toBe("email");
434
+ });
435
+
436
+ test("sms medium binder alias carries its medium (not defaulted to email)", async () => {
437
+ const source = `
438
+ import { on, flow, http, gate, channel } from "okengine";
439
+
440
+ const otp = channel.sms({});
441
+
442
+ export const otpTemplate = otp.template("otp-code", {});
443
+
444
+ export const send = on(
445
+ http.post("/otp").gate(gate.public),
446
+ flow("notes.sendOtp", {
447
+ do: async (payload, fx) => {
448
+ await fx.send(otpTemplate, { to: "+10000000000", data: payload });
449
+ },
450
+ }),
451
+ );
452
+ `;
453
+ const manifest = await extractFromSources({ "src/flows/otp.ts": source });
454
+
455
+ expect(manifest.flows?.["notes.sendOtp"]?.effects?.sends).toEqual(["otp-code"]);
456
+ expect(manifest.channels?.["otp-code"]?.medium).toBe("sms");
457
+ });
458
+ });
459
+
460
+ describe("extractManifest — flow(name, options) positional signature", () => {
461
+ test("bare flow(name, {...}) extracts name, in, out, effects", async () => {
462
+ const source = `
463
+ import { flow } from "okengine";
464
+
465
+ export const chargeOrder = flow("checkout.chargeOrder", {
466
+ in: ChargeIn,
467
+ out: ChargeOut,
468
+ effects: { reads: ["sql:orders"], writes: ["sql:orders"] },
469
+ do: async (input, fx) => {
470
+ return { ok: true };
471
+ },
472
+ });
473
+ `;
474
+ const manifest = await extractFromSources({ "src/flows/checkout.ts": source });
475
+
476
+ expect(manifest.flows?.["checkout.chargeOrder"]).toBeDefined();
477
+ expect(manifest.flows?.["checkout.chargeOrder"]?.in).toBeDefined();
478
+ expect(manifest.flows?.["checkout.chargeOrder"]?.out).toBeDefined();
479
+ expect(manifest.flows?.["checkout.chargeOrder"]?.effects?.reads).toEqual(["sql:orders"]);
480
+ expect(manifest.flows?.["checkout.chargeOrder"]?.effects?.writes).toEqual(["sql:orders"]);
481
+ });
482
+
483
+ test("on(trigger, flow(name, {...})) extracts the same shape as bare flow()", async () => {
484
+ const source = `
485
+ import { on, http, flow } from "okengine";
486
+
487
+ export const getOrder = on(
488
+ http.get("/orders/:id"),
489
+ flow("checkout.getOrder", {
490
+ out: OrderOut,
491
+ effects: { reads: ["sql:orders"] },
492
+ do: async (input, fx) => {
493
+ return { id: input.id };
494
+ },
495
+ }),
496
+ );
497
+ `;
498
+ const manifest = await extractFromSources({ "src/flows/checkout.ts": source });
499
+
500
+ expect(manifest.flows?.["checkout.getOrder"]).toBeDefined();
501
+ expect(manifest.flows?.["checkout.getOrder"]?.trigger?.http?.method).toBe("GET");
502
+ expect(manifest.flows?.["checkout.getOrder"]?.out).toBeDefined();
503
+ expect(manifest.flows?.["checkout.getOrder"]?.effects?.reads).toEqual(["sql:orders"]);
504
+ });
505
+
506
+ test("a stray `name:` key inside the options object is ignored — the positional arg wins", async () => {
507
+ // Regression guard for the exact class of AST-shape assumption that broke
508
+ // silently before (channel-alias tracking, `.put()` inference): the
509
+ // compiler must read the flow's identity from the first positional
510
+ // argument, never from a `name` property on the options object, even
511
+ // when one happens to be present.
512
+ const source = `
513
+ import { flow } from "okengine";
514
+
515
+ export const decoy = flow("checkout.realName", {
516
+ name: "checkout.decoyName",
517
+ effects: { reads: ["sql:orders"] },
518
+ do: async () => ({ ok: true }),
519
+ });
520
+ `;
521
+ const manifest = await extractFromSources({ "src/flows/checkout.ts": source });
522
+
523
+ expect(manifest.flows?.["checkout.realName"]).toBeDefined();
524
+ expect(manifest.flows?.["checkout.decoyName"]).toBeUndefined();
525
+ });
526
+ });
@@ -14,6 +14,7 @@ import type {
14
14
  AiModel,
15
15
  AiPrompt,
16
16
  Channel,
17
+ ChannelMedium,
17
18
  Clock,
18
19
  DeclaredColumn,
19
20
  Effects,
@@ -94,6 +95,8 @@ interface ProjectScope {
94
95
  string,
95
96
  { storeName: string; storeRef: string; unit?: string; breaking?: boolean }
96
97
  >;
98
+ /** Local binding name → medium, for `const x = channel.email(…)` binders. */
99
+ channelMediumBindings: Map<string, ChannelMedium>;
97
100
  }
98
101
 
99
102
  /**
@@ -123,6 +126,7 @@ export async function extractManifest(options: ExtractManifestOptions = {}): Pro
123
126
  flows: {},
124
127
  flowExports: new Map(),
125
128
  resources: new Map(),
129
+ channelMediumBindings: new Map(),
126
130
  };
127
131
 
128
132
  const parsed = files.map((file) => {
@@ -329,7 +333,14 @@ function collectSchemaTable(call: CallExpression, program: AstNode, scope: Proje
329
333
  const entry = { name: tableName, columns };
330
334
  scope.schemaTables.set(tableName, entry);
331
335
  const bindingName = enclosingConstName(call, program);
332
- if (bindingName) scope.schemaTables.set(bindingName, entry);
336
+ if (bindingName) {
337
+ scope.schemaTables.set(bindingName, entry);
338
+ // Declared table name, not the JS binding — resolved by
339
+ // tableFromStoreChain so `const notesTable = store.schema.table("notes",
340
+ // …)` still infers "sql:notes", matching what the kernel reads off the
341
+ // live table object (see ../manifest/sql-resource.ts).
342
+ scope.bindings.set(bindingName, { kind: "table", ref: tableName });
343
+ }
333
344
  }
334
345
 
335
346
  function attachSchemaOption(
@@ -584,6 +595,8 @@ function camelToSnakeKey(key: string): string {
584
595
  .toLowerCase();
585
596
  }
586
597
 
598
+ const CHANNEL_MEDIUM_METHODS = new Set(["email", "sms", "whatsapp", "push"]);
599
+
587
600
  function visitDeclarationCall(call: CallExpression, program: AstNode, scope: ProjectScope): void {
588
601
  const callee = call.callee;
589
602
 
@@ -718,7 +731,17 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
718
731
  }
719
732
  }
720
733
 
721
- if (obj === "channel" && prop === "template") {
734
+ // channel.email(…) / .sms(…) / .whatsapp(…) / .push(…) — medium binder,
735
+ // usually held in a local const and called later as `mail.template(…)`.
736
+ if (obj === "channel" && prop && CHANNEL_MEDIUM_METHODS.has(prop)) {
737
+ const bindingName = enclosingConstName(call, program);
738
+ if (bindingName) {
739
+ scope.channelMediumBindings.set(bindingName, prop as ChannelMedium);
740
+ }
741
+ }
742
+
743
+ const boundMedium = obj ? scope.channelMediumBindings.get(obj) : undefined;
744
+ if ((obj === "channel" && prop === "template") || (prop === "template" && boundMedium)) {
722
745
  const templateName = stringArg(call.arguments[0]);
723
746
  const opts = objectArg(call.arguments[1]);
724
747
  if (templateName) {
@@ -726,7 +749,9 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
726
749
  const locales = stringArrayProp(opts, "locales");
727
750
  const description = stringProp(opts, "description");
728
751
  scope.channels[templateName] = {
729
- ...(medium ? { medium: medium as Channel["medium"] } : { medium: "email" }),
752
+ ...(medium
753
+ ? { medium: medium as Channel["medium"] }
754
+ : { medium: boundMedium ?? "email" }),
730
755
  ...(locales ? { locales } : {}),
731
756
  ...(description ? { description } : {}),
732
757
  };
@@ -1134,11 +1159,11 @@ function registerFlow(args: {
1134
1159
  scope: ProjectScope;
1135
1160
  exportName: string | undefined;
1136
1161
  }): void {
1137
- const opts = objectArg(args.flowCall.arguments[0]);
1162
+ const opts = objectArg(args.flowCall.arguments[1]);
1138
1163
  if (!opts) return;
1139
1164
 
1140
1165
  const name =
1141
- stringProp(opts, "name") ??
1166
+ stringArg(args.flowCall.arguments[0]) ??
1142
1167
  args.exportName ??
1143
1168
  `flow_${Object.keys(args.scope.flows).length + 1}`;
1144
1169
 
@@ -1207,7 +1232,7 @@ function registerFlow(args: {
1207
1232
  const errors = parseErrors(objectProp(opts, "errors"));
1208
1233
  if (errors) flow.errors = errors;
1209
1234
 
1210
- if (effects && Object.keys(effects).length > 0) flow.effects = effects;
1235
+ if (effects) flow.effects = effects;
1211
1236
 
1212
1237
  const line = lineAt(args.file.source, args.flowCall.start ?? 0);
1213
1238
  flow.source = `${args.file.path}:${line}`;
@@ -5,8 +5,7 @@ import { on, flow, http } from "okengine";
5
5
  */
6
6
  export const escapeHatch = on(
7
7
  http.get("/raw"),
8
- flow({
9
- name: "raw.unannotated",
8
+ flow("raw.unannotated", {
10
9
  do: async (_, fx) => {
11
10
  return fx.raw("select 1");
12
11
  },
@@ -18,8 +17,7 @@ export const escapeHatch = on(
18
17
  */
19
18
  export const annotated = on(
20
19
  http.get("/raw-ok"),
21
- flow({
22
- name: "raw.annotated",
20
+ flow("raw.annotated", {
23
21
  effects: { reads: ["sql:orders"] },
24
22
  do: async (_, fx) => {
25
23
  return fx.raw("select * from orders");
@@ -15,8 +15,7 @@ void 0;
15
15
 
16
16
  export const create = on(
17
17
  http.post("/bookings").gate(member, canBook, fair),
18
- flow({
19
- name: "bookings.create",
18
+ flow("bookings.create", {
20
19
  in: BookingIn,
21
20
  out: BookingOut,
22
21
  errors: { FlightFull },
@@ -35,8 +34,7 @@ export const create = on(
35
34
 
36
35
  export const mine = on(
37
36
  http.get("/bookings").gate(member).live(),
38
- flow({
39
- name: "bookings.mine",
37
+ flow("bookings.mine", {
40
38
  live: true,
41
39
  do: (_, fx) =>
42
40
  fx
@@ -47,13 +45,11 @@ export const mine = on(
47
45
  }),
48
46
  );
49
47
 
50
- export const getBooking = flow({
51
- name: "bookings.getBooking",
48
+ export const getBooking = flow("bookings.getBooking", {
52
49
  do: ({ id }, fx) => fx.store(db).findById(bookings, id),
53
50
  });
54
51
 
55
- export const refundBooking = flow({
56
- name: "bookings.refundBooking",
52
+ export const refundBooking = flow("bookings.refundBooking", {
57
53
  do: async ({ id }, fx) => {
58
54
  await fx.store(db).update(bookings).set({ status: "refunded" }).where({ id });
59
55
  return fx.store(db).findById(bookings, id);
@@ -1,8 +1,7 @@
1
1
  import { flow } from "okengine";
2
2
  import { stripeKey } from "../../vault.ts";
3
3
 
4
- export const chargeBooking = flow({
5
- name: "payments.chargeBooking",
4
+ export const chargeBooking = flow("payments.chargeBooking", {
6
5
  durable: true,
7
6
  do: async ({ orderId }, fx) => {
8
7
  // Journal only serializable step values — a Redacted is not journaled.
@@ -6,8 +6,7 @@ import { tickets } from "../../schema.ts";
6
6
 
7
7
  export const createTicket = on(
8
8
  http.post("/tickets").gate(member),
9
- flow({
10
- name: "support.createTicket",
9
+ flow("support.createTicket", {
11
10
  pii: "masked",
12
11
  cost: { estimatePerCall: 0.011, budget: 0.02 },
13
12
  do: async (input, fx) => {
@@ -32,7 +32,7 @@
32
32
  "effects": {
33
33
  "reads": ["sql:bookings"]
34
34
  },
35
- "source": "src/flows/bookings/index.ts:50"
35
+ "source": "src/flows/bookings/index.ts:48"
36
36
  },
37
37
  "bookings.mine": {
38
38
  "trigger": {
@@ -45,7 +45,7 @@
45
45
  "effects": {
46
46
  "reads": ["sql:bookings"]
47
47
  },
48
- "source": "src/flows/bookings/index.ts:38",
48
+ "source": "src/flows/bookings/index.ts:37",
49
49
  "live": true,
50
50
  "cacheKeys": "computed:sql:bookings/userId"
51
51
  },
@@ -54,7 +54,7 @@
54
54
  "reads": ["sql:bookings"],
55
55
  "writes": ["sql:bookings"]
56
56
  },
57
- "source": "src/flows/bookings/index.ts:55"
57
+ "source": "src/flows/bookings/index.ts:52"
58
58
  },
59
59
  "payments.chargeBooking": {
60
60
  "effects": {
@@ -16,8 +16,7 @@ export const linkClicked = signal("link-clicked", {
16
16
  // ① HTTP
17
17
  export const create = on(
18
18
  http.post("/orders"),
19
- flow({
20
- name: "triggers.http",
19
+ flow("triggers.http", {
21
20
  do: async (input, fx) => {
22
21
  await fx.store(db).insert(orders).values(input);
23
22
  return { ok: true };
@@ -28,8 +27,7 @@ export const create = on(
28
27
  // ② CLOCK / every
29
28
  on(
30
29
  every("10m"),
31
- flow({
32
- name: "triggers.every",
30
+ flow("triggers.every", {
33
31
  do: (_, fx) => fx.store(db).delete(links).where({ createdAt: 0 }),
34
32
  }),
35
33
  );
@@ -37,8 +35,7 @@ on(
37
35
  // ③ SIGNAL
38
36
  on(
39
37
  linkClicked,
40
- flow({
41
- name: "triggers.signal",
38
+ flow("triggers.signal", {
42
39
  do: async ({ code }, fx) => {
43
40
  const clicks = await fx.store(db).increment(links, code, "clicks");
44
41
  await fx.emit(linkClicked, { code, clicks });
@@ -49,8 +46,7 @@ on(
49
46
  // ④ CDC
50
47
  on(
51
48
  table("orders").changed("status"),
52
- flow({
53
- name: "triggers.cdc",
49
+ flow("triggers.cdc", {
54
50
  do: ({ before, after }, fx) => fx.store(db).insert(orders).values({ before, after }),
55
51
  }),
56
52
  );
@@ -58,8 +54,7 @@ on(
58
54
  // ⑤ INTERNAL / call-only
59
55
  export const stats = on(
60
56
  internal,
61
- flow({
62
- name: "triggers.internal",
57
+ flow("triggers.internal", {
63
58
  do: async ({ code }, fx) => {
64
59
  const [row] = await fx.store(db).select().from(links).where({ code });
65
60
  return row?.clicks ?? 0;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `.adopt()` barrel generator — real filesystem before/after proofs.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { generateAdoptBarrel } from "./generate-adopt.ts";
10
+
11
+ async function makeUnit(root: string, name: string, withIndex = true): Promise<void> {
12
+ const dir = join(root, "src/flows", name);
13
+ await mkdir(dir, { recursive: true });
14
+ if (withIndex) await writeFile(join(dir, "index.ts"), "export const nothing = 1;\n");
15
+ }
16
+
17
+ describe("generateAdoptBarrel", () => {
18
+ test("emits one `export * as <unit>` per flows folder with an index.ts barrel", async () => {
19
+ const root = await mkdtemp(join(tmpdir(), "oke-adopt-gen-"));
20
+ try {
21
+ await makeUnit(root, "notes");
22
+ await makeUnit(root, "main");
23
+ const result = await generateAdoptBarrel({ rootDir: root });
24
+ expect(result.units).toEqual(["main", "notes"]);
25
+ expect(result.source).toContain('export * as main from "./main/index.ts";');
26
+ expect(result.source).toContain('export * as notes from "./notes/index.ts";');
27
+ expect(result.skipped).toEqual([]);
28
+ } finally {
29
+ await rm(root, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ test("skips a unit folder with no index.ts barrel (never crashes)", async () => {
34
+ const root = await mkdtemp(join(tmpdir(), "oke-adopt-gen-"));
35
+ try {
36
+ await makeUnit(root, "notes");
37
+ await makeUnit(root, "scratch", false);
38
+ const result = await generateAdoptBarrel({ rootDir: root });
39
+ expect(result.units).toEqual(["notes"]);
40
+ expect(result.skipped).toEqual(["scratch"]);
41
+ } finally {
42
+ await rm(root, { recursive: true, force: true });
43
+ }
44
+ });
45
+
46
+ test("missing flows directory yields an empty barrel, never throws", async () => {
47
+ const root = await mkdtemp(join(tmpdir(), "oke-adopt-gen-"));
48
+ try {
49
+ const result = await generateAdoptBarrel({ rootDir: root });
50
+ expect(result.units).toEqual([]);
51
+ expect(result.source).toContain("AUTO-GENERATED");
52
+ } finally {
53
+ await rm(root, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ test("real before/after: generated barrel matches the hand-written form it replaces", async () => {
58
+ const root = await mkdtemp(join(tmpdir(), "oke-adopt-gen-"));
59
+ try {
60
+ await mkdir(join(root, "src/flows/main"), { recursive: true });
61
+ await mkdir(join(root, "src/flows/notes"), { recursive: true });
62
+ await writeFile(join(root, "src/flows/main/index.ts"), 'export const root = "main-flow";\n');
63
+ await writeFile(
64
+ join(root, "src/flows/notes/index.ts"),
65
+ 'export const create = "notes-flow";\n',
66
+ );
67
+ const result = await generateAdoptBarrel({ rootDir: root });
68
+ const generatedPath = join(root, "src/flows/generated.ts");
69
+ await writeFile(generatedPath, result.source);
70
+
71
+ // Same shape as `import * as main from "./flows/main"; import * as
72
+ // notes from "./flows/notes"; .adopt({ main, notes })` — a namespace
73
+ // object keyed by unit, one key per flows folder, real modules loaded.
74
+ const generatedModule = (await import(generatedPath)) as {
75
+ main: { root: string };
76
+ notes: { create: string };
77
+ };
78
+ expect(Object.keys(generatedModule).sort()).toEqual(["main", "notes"]);
79
+ expect(generatedModule.main.root).toBe("main-flow");
80
+ expect(generatedModule.notes.create).toBe("notes-flow");
81
+ } finally {
82
+ await rm(root, { recursive: true, force: true });
83
+ }
84
+ });
85
+ });