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
@@ -218,8 +218,7 @@ describe("retry + durable journal", () => {
218
218
  let attempts = 0;
219
219
  let crash = true;
220
220
 
221
- const f = flow({
222
- name: "pay.retry",
221
+ const f = flow("pay.retry", {
223
222
  durable: true,
224
223
  do: async (_input, fx) => {
225
224
  const charged = await fx.step("charge", () =>
@@ -250,13 +249,12 @@ describe("retry + durable journal", () => {
250
249
  expect(attempts).toBe(1);
251
250
  });
252
251
 
253
- test("flow({ retry }) reuses journal — completed steps do not re-run", async () => {
252
+ test("flow(name, { retry }) reuses journal — completed steps do not re-run", async () => {
254
253
  const journalStore = createMemoryJournalStore();
255
254
  const calls: string[] = [];
256
255
  let failOnce = true;
257
256
 
258
- const f = flow({
259
- name: "flow.retry",
257
+ const f = flow("flow.retry", {
260
258
  durable: true,
261
259
  retry: { retries: 2, delay: 0, jitter: false },
262
260
  do: async (_input, fx) => {
@@ -17,8 +17,7 @@ describe("correlation — parentId across Flow → Signal → Flow", () => {
17
17
  const runs = createRunsRuntime({ driver: "memory" });
18
18
  await runs.open();
19
19
 
20
- const producer = flow({
21
- name: "orders.create",
20
+ const producer = flow("orders.create", {
22
21
  effects: { emits: ["corr-order-placed"] },
23
22
  do: async (input: { id: string }, fx) => {
24
23
  await fx.emit(orderPlaced, { id: input.id });
@@ -26,8 +25,7 @@ describe("correlation — parentId across Flow → Signal → Flow", () => {
26
25
  },
27
26
  });
28
27
 
29
- const consumer = flow({
30
- name: "orders.notify",
28
+ const consumer = flow("orders.notify", {
31
29
  do: async (_input: { id: string }) => ({ notified: true as const }),
32
30
  });
33
31
 
@@ -72,13 +70,11 @@ describe("correlation — parentId across Flow → Signal → Flow", () => {
72
70
  const runs = createRunsRuntime({ driver: "memory" });
73
71
  await runs.open();
74
72
 
75
- const child = flow({
76
- name: "inner.work",
73
+ const child = flow("inner.work", {
77
74
  do: () => ({ done: true as const }),
78
75
  });
79
76
 
80
- const parent = flow({
81
- name: "outer.work",
77
+ const parent = flow("outer.work", {
82
78
  effects: { calls: ["inner.work"] },
83
79
  do: async (_input, fx) => {
84
80
  await fx.call(child, {});
@@ -19,7 +19,7 @@ beforeEach(() => {
19
19
 
20
20
  describe("plugin edge handlers", () => {
21
21
  test("an edge handler answers an unmatched request; the flow still owns matched ones", async () => {
22
- on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
22
+ on(http.get("/x"), flow("x.get", { do: () => ({ ok: true }) }));
23
23
  const edge = plugin("edge-test", { version: "0.0.1" }).edge((_request, info) => {
24
24
  if (info.method === "OPTIONS") {
25
25
  return new Response(null, { status: 204, headers: { "x-edge": "yes" } });
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Boot-level proof: compiled/inferred effects reaching a REAL running app's
3
+ * capability token — not an isolated compiler-only or kernel-only test.
4
+ *
5
+ * Before this file existed, `flow(name, {...})` with no hand-declared `effects`
6
+ * always minted an OPEN capability token (every access allowed, no gate at
7
+ * all) in every environment, including `docker` / `prod` — `extractManifest`
8
+ * inference only ever fed the static `manifest.oke.json` artifact (Console,
9
+ * docs, publish), never the live boot path. Confirmed via real `oke()` boot
10
+ * + `app.fetch()`, not assumption.
11
+ *
12
+ * Also documents the sql:<table> (compiler inference) vs sql:<store-name>
13
+ * (kernel `gatedSqlHandle`) naming mismatch this stamping bridge surfaces:
14
+ * once inference actually reaches the runtime, a flow relying on it for a
15
+ * real `fx.store(db).insert(table)` write must not throw `UNDECLARED_WRITE`
16
+ * for touching the exact table it declared.
17
+ */
18
+
19
+ import { afterEach, describe, expect, test } from "bun:test";
20
+ import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { z } from "zod";
24
+ import { gate } from "../elements/gate.ts";
25
+ import { field, id, now, store } from "../elements/store.ts";
26
+ import { oke } from "./app.ts";
27
+ import { resetNoEffectsWarnForTests } from "./boot.ts";
28
+ import { flow, resetFlowSeq } from "./flow.ts";
29
+ import { on, resetBindings } from "./on.ts";
30
+ import { createTestApp } from "../test/create-test-app.ts";
31
+ import { http } from "./triggers.ts";
32
+
33
+ afterEach(() => {
34
+ resetBindings();
35
+ resetFlowSeq();
36
+ resetNoEffectsWarnForTests();
37
+ });
38
+
39
+ const notes = store.schema.table("notes", {
40
+ id: field.text().primaryKey().defaultFn(id),
41
+ title: field.text().notNull(),
42
+ createdAt: field.integer().notNull().defaultFn(now),
43
+ });
44
+
45
+ const CreateIn = z.object({ id: z.string(), title: z.string() });
46
+ const CreateOut = z.object({ ok: z.boolean() });
47
+
48
+ function buildUnannotatedCreateFlow(db: ReturnType<typeof store.sql>) {
49
+ return on(
50
+ http.post("/notes").gate(gate.public),
51
+ flow("notes.create", {
52
+ in: CreateIn,
53
+ out: CreateOut,
54
+ // Deliberately no `effects` — the "let the compiler infer it" case.
55
+ do: async (input, fx) => {
56
+ await fx.store(db).insert(notes).values({ id: input.id, title: input.title, createdAt: 1 });
57
+ return { ok: true };
58
+ },
59
+ }),
60
+ );
61
+ }
62
+
63
+ describe("boot-level: undeclared-effects flow, no manifest / rootDir", () => {
64
+ test("local / test: open token — real insert succeeds (documented dev-loop fallback)", async () => {
65
+ resetBindings();
66
+ resetFlowSeq();
67
+ const db = store.sql("app", { schema: { notes } });
68
+ // `on(...)` (inside buildUnannotatedCreateFlow) must run BEFORE `oke(...)`
69
+ // is constructed — oke() synchronously drains the global on() registry
70
+ // at construction time; a binding created only as .adopt()'s argument
71
+ // (after oke() already ran) never gets wired to a route.
72
+ const create = buildUnannotatedCreateFlow(db);
73
+ const app = oke({ name: "stamp-open", gate: { policies: [gate.public] } }).adopt({ create });
74
+ Object.assign(app.$options, { stores: [db] });
75
+ await createTestApp(app); // createTestApp always boots env: "test"
76
+
77
+ const res = await app.fetch(
78
+ new Request("http://localhost/notes", {
79
+ method: "POST",
80
+ headers: { "content-type": "application/json" },
81
+ body: JSON.stringify({ id: "x1", title: "hi" }),
82
+ }),
83
+ );
84
+ expect(res.status).toBe(200);
85
+ });
86
+
87
+ test("docker: hard fail at boot — OKE1008, never an open token in a deploy-shaped env", async () => {
88
+ resetBindings();
89
+ resetFlowSeq();
90
+ const db = store.sql("app", { schema: { notes } });
91
+ const create = buildUnannotatedCreateFlow(db);
92
+ const app = oke({ name: "stamp-docker", gate: { policies: [gate.public] } }).adopt({ create });
93
+
94
+ await expect(
95
+ app.boot({ env: "docker", stores: [db], unguardedHttp: "allow", startScheduler: false }),
96
+ ).rejects.toThrow(/OKE1008/);
97
+ });
98
+
99
+ test("prod: hard fail at boot — same posture as docker", async () => {
100
+ resetBindings();
101
+ resetFlowSeq();
102
+ const db = store.sql("app", { schema: { notes } });
103
+ const create = buildUnannotatedCreateFlow(db);
104
+ const app = oke({ name: "stamp-prod", gate: { policies: [gate.public] } }).adopt({ create });
105
+
106
+ await expect(
107
+ app.boot({ env: "prod", stores: [db], unguardedHttp: "allow", startScheduler: false }),
108
+ ).rejects.toThrow(/OKE1008/);
109
+ });
110
+ });
111
+
112
+ describe("boot-level: table-ref resolution stays backward compatible", () => {
113
+ test("older store-level effects (sql:<store>) still cover a schema-table op — every existing template's convention", async () => {
114
+ resetBindings();
115
+ resetFlowSeq();
116
+ const db = store.sql("app", { schema: { notes } });
117
+ const create = on(
118
+ http.post("/notes").gate(gate.public),
119
+ flow("notes.create", {
120
+ in: CreateIn,
121
+ out: CreateOut,
122
+ // The convention every template/test predates Direction B with —
123
+ // store-level, not table-level. Must keep working unchanged.
124
+ effects: { writes: ["sql:app"] },
125
+ do: async (input, fx) => {
126
+ await fx
127
+ .store(db)
128
+ .insert(notes)
129
+ .values({ id: input.id, title: input.title, createdAt: 1 });
130
+ return { ok: true };
131
+ },
132
+ }),
133
+ );
134
+ const app = oke({ name: "stamp-back-compat", gate: { policies: [gate.public] } }).adopt({
135
+ create,
136
+ });
137
+ Object.assign(app.$options, { stores: [db] });
138
+ await createTestApp(app);
139
+
140
+ const res = await app.fetch(
141
+ new Request("http://localhost/notes", {
142
+ method: "POST",
143
+ headers: { "content-type": "application/json" },
144
+ body: JSON.stringify({ id: "x1", title: "hi" }),
145
+ }),
146
+ );
147
+ expect(res.status).toBe(200);
148
+ });
149
+ });
150
+
151
+ describe("boot-level: undeclared-effects flow, rootDir stamping from a real source tree", () => {
152
+ test("real fx.store(db).insert(table) write succeeds once inference is stamped onto the capability token", async () => {
153
+ resetBindings();
154
+ resetFlowSeq();
155
+ const dir = await mkdtemp(join(tmpdir(), "oke-stamp-"));
156
+ try {
157
+ await mkdir(join(dir, "flows", "notes"), { recursive: true });
158
+ // A real source file with NO manual `effects:` — extractManifest infers
159
+ // `writes: ["sql:notes"]` for this from the real `fx.store(db).insert`
160
+ // call, exactly like the kernel test flow above.
161
+ await writeFile(
162
+ join(dir, "flows", "notes", "index.ts"),
163
+ `
164
+ import { on, flow, http, gate } from "okengine";
165
+ // Unresolved bindings are fine — extraction is AST-only, never executed.
166
+ // What matters is the literal shape: fx.store(db).insert(notes) so the
167
+ // same table-name inference that produced "sql:notes" earlier fires here.
168
+ const db = {} as any;
169
+ const notes = {} as any;
170
+ export const create = on(
171
+ http.post("/notes").gate(gate.public),
172
+ flow("notes.create", {
173
+ do: async (input, fx) => {
174
+ await fx.store(db).insert(notes).values({ id: input.id, title: input.title, createdAt: 1 });
175
+ return { ok: true };
176
+ },
177
+ }),
178
+ );
179
+ `,
180
+ );
181
+
182
+ const db = store.sql("app", { schema: { notes } });
183
+ const create = buildUnannotatedCreateFlow(db);
184
+ const app = oke({ name: "stamp-rootdir", gate: { policies: [gate.public] } }).adopt({
185
+ create,
186
+ });
187
+ Object.assign(app.$options, { stores: [db] });
188
+ await createTestApp(app, { boot: { rootDir: dir } });
189
+
190
+ const res = await app.fetch(
191
+ new Request("http://localhost/notes", {
192
+ method: "POST",
193
+ headers: { "content-type": "application/json" },
194
+ body: JSON.stringify({ id: "x1", title: "hi" }),
195
+ }),
196
+ );
197
+ const body = (await res.json()) as { data: unknown; error: { message?: string } | null };
198
+ expect(res.status, body.error?.message ?? "").toBe(200);
199
+ expect(body.data).toEqual({ ok: true });
200
+ } finally {
201
+ await rm(dir, { recursive: true, force: true });
202
+ }
203
+ });
204
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Module-evaluation registries for `store.sql` / `store.files`, `vault.secret`,
3
+ * `signal()`, and `channel.<medium>().template()` — the plain arrays behind
4
+ * each declare module's `listX()` / `resetX()` pair (mirrors `on.ts`'s
5
+ * trigger-drain `bindings` array).
6
+ *
7
+ * Lives here, not in the declare modules themselves, so {@link oke} can read
8
+ * every registry with one lightweight import instead of statically pulling
9
+ * in all four element `declare.ts` modules (and every unrelated export they
10
+ * carry) into every app's bundle — see the `oke()` Store-only bundle-size
11
+ * budget in `boot.test.ts`. Only type imports cross back to the declare
12
+ * modules, so this file has zero runtime dependencies of its own.
13
+ */
14
+
15
+ import type { StoreDecl } from "../elements/store/declare.ts";
16
+ import type { VaultSecretDecl } from "../elements/vault/declare.ts";
17
+ import type { SignalDecl } from "../elements/signal/declare.ts";
18
+ import type { ChannelTemplateDecl } from "../elements/channel/declare.ts";
19
+
20
+ /** `store.sql` / `store.files` declarations since the last reset. */
21
+ export const storeRegistry: StoreDecl[] = [];
22
+ /** `vault.secret` declarations since the last reset. */
23
+ export const secretRegistry: VaultSecretDecl[] = [];
24
+ /** `signal()` declarations since the last reset. */
25
+ export const signalRegistry: SignalDecl[] = [];
26
+ /** Medium-binder `.template()` declarations since the last reset. */
27
+ export const channelTemplateRegistry: ChannelTemplateDecl[] = [];
@@ -142,6 +142,28 @@ export const OKE_ERRORS = {
142
142
  cause: 'Flow "{flow}" calls "{resource}" without declaring it.',
143
143
  fix: 'Add "{resource}" to this flow\'s effects.calls.',
144
144
  },
145
+ /**
146
+ * Flow has no declared `effects` and no Manifest-derived effects were
147
+ * available to stamp at boot (docker / prod — never a silent open token).
148
+ */
149
+ NO_EFFECTS_DECLARED: {
150
+ code: 1008,
151
+ cause: 'Flow "{flow}" has no declared effects and no Manifest to derive them from.',
152
+ fix:
153
+ "Add explicit `effects` to this flow, or boot with a Manifest (`oke build`) / " +
154
+ "`rootDir` so effects can be derived. docker/prod refuse an open capability token.",
155
+ },
156
+ /**
157
+ * A `src/flows/<unit>` folder exists on disk but no adopted flow carries
158
+ * that unit — the generated `.adopt()` barrel (`src/flows/generated.ts`)
159
+ * is stale or was hand-edited. docker/prod — never a silently-incomplete
160
+ * route table in a deploy-shaped environment.
161
+ */
162
+ ADOPT_BARREL_STALE: {
163
+ code: 1009,
164
+ cause: "src/flows/{unit} exists on disk but adopted no flows — the .adopt() barrel is stale.",
165
+ fix: "Run `oke dev` or `oke build` to regenerate `src/flows/generated.ts`.",
166
+ },
145
167
  /**
146
168
  * Emit target has no subscriber (unified-theory §21).
147
169
  * Thrown at emit when `optional` is false and nobody is subscribed.
@@ -11,8 +11,7 @@ beforeEach(() => {
11
11
 
12
12
  describe("flow — one species", () => {
13
13
  test("flow() returns a branded FlowDef", () => {
14
- const f = flow({
15
- name: "ping",
14
+ const f = flow("ping", {
16
15
  do: () => ({ ok: true }),
17
16
  });
18
17
  expect(isFlow(f)).toBe(true);
@@ -22,7 +21,7 @@ describe("flow — one species", () => {
22
21
  });
23
22
 
24
23
  test("on() returns the same flow object and records the trigger", () => {
25
- const f = flow({ name: "create", do: () => 1 });
24
+ const f = flow("create", { do: () => 1 });
26
25
  const bound = on(http.post("/notes"), f);
27
26
  // Same runtime object; trigger stamp is type-only so identities differ at the type level.
28
27
  expect(bound as object).toBe(f);
@@ -33,8 +32,7 @@ describe("flow — one species", () => {
33
32
 
34
33
  test("the same flow object is reachable from HTTP and signal triggers", async () => {
35
34
  const calls: string[] = [];
36
- const shared = flow({
37
- name: "shared.work",
35
+ const shared = flow("shared.work", {
38
36
  do: (input: { n: number }) => {
39
37
  calls.push(`n=${input.n}`);
40
38
  return { n: input.n * 2 };
@@ -65,8 +63,7 @@ describe("five trigger kinds", () => {
65
63
 
66
64
  const httpFlow = on(
67
65
  http.get("/:code"),
68
- flow({
69
- name: "t.http",
66
+ flow("t.http", {
70
67
  do: ({ code }: { code: string }) => {
71
68
  seen.push(`http:${code}`);
72
69
  return { code };
@@ -76,8 +73,7 @@ describe("five trigger kinds", () => {
76
73
 
77
74
  on(
78
75
  every("1h"),
79
- flow({
80
- name: "t.every",
76
+ flow("t.every", {
81
77
  do: () => {
82
78
  seen.push("every");
83
79
  },
@@ -86,8 +82,7 @@ describe("five trigger kinds", () => {
86
82
 
87
83
  on(
88
84
  { name: "link-clicked", delivery: "once" },
89
- flow({
90
- name: "t.signal",
85
+ flow("t.signal", {
91
86
  do: ({ code }: { code: string }) => {
92
87
  seen.push(`signal:${code}`);
93
88
  },
@@ -96,8 +91,7 @@ describe("five trigger kinds", () => {
96
91
 
97
92
  on(
98
93
  table("orders").changed("status"),
99
- flow({
100
- name: "t.cdc",
94
+ flow("t.cdc", {
101
95
  do: (p: { before: { status: string }; after: { status: string } }) => {
102
96
  seen.push(`cdc:${p.before.status}->${p.after.status}`);
103
97
  },
@@ -106,8 +100,7 @@ describe("five trigger kinds", () => {
106
100
 
107
101
  on(
108
102
  internal,
109
- flow({
110
- name: "t.internal",
103
+ flow("t.internal", {
111
104
  do: () => {
112
105
  seen.push("internal");
113
106
  return { ok: true };
@@ -38,10 +38,6 @@ export type FlowHandler<I = unknown, O = unknown> = (
38
38
 
39
39
  /** Options for {@link flow}. */
40
40
  export interface FlowOptions<I = unknown, O = unknown, E extends FlowErrorMap = FlowErrorMap> {
41
- /** Optional stable name (used by `fx.call` and the Manifest). */
42
- readonly name?: string;
43
- /** Optional unit scope for unit-level hooks. */
44
- readonly unit?: string;
45
41
  /** Input schema (Standard Schema when present). */
46
42
  readonly in?: SchemaInput;
47
43
  /** Output schema (Standard Schema when present). */
@@ -155,7 +151,7 @@ export interface FlowDef<
155
151
  readonly [triggerPhantom]?: T;
156
152
  /** Stable name (auto-assigned when omitted). */
157
153
  readonly name: string;
158
- /** Optional unit scope. */
154
+ /** Unit scope — derived from `name`'s first dot segment (e.g. `"auth.refresh"` → `"auth"`). */
159
155
  readonly unit: string | undefined;
160
156
  /** Input schema. */
161
157
  readonly in: SchemaInput | undefined;
@@ -231,28 +227,30 @@ export interface FlowDef<
231
227
  /** Unique brand symbol for {@link FlowDef}. */
232
228
  export const flowBrand: unique symbol = Symbol("oke.flow");
233
229
 
234
- let flowSeq = 0;
235
-
236
230
  /**
237
231
  * Define a Flow — the one species of backend behavior.
238
232
  *
239
233
  * Input / output types are inferred from Standard Schema `in` / `out` when
240
234
  * present; otherwise from the `do` handler signature.
241
235
  *
236
+ * @param name - Stable name (used by `fx.call` and the Manifest)
242
237
  * @param options - Contract and handler
243
238
  */
244
239
  export function flow<Opts extends FlowOptions<any, any, any>>(
245
- options: Opts,
240
+ name: string,
241
+ options: Opts & { readonly name?: never },
246
242
  ): FlowDef<InferFlowIn<Opts>, InferFlowOut<Opts>, InferFlowErrors<Opts>> {
247
- const name = options.name ?? `flow_${++flowSeq}`;
248
243
  const triggers: Trigger[] = [];
249
244
  const hooks: Partial<Record<HookStage, HookFn[]>> = {};
250
245
  const pendingPlugins: PluginDef[] = [];
251
246
 
247
+ const dot = name.indexOf(".");
248
+ const unit = dot > 0 ? name.slice(0, dot) : undefined;
249
+
252
250
  const def: FlowDef<InferFlowIn<Opts>, InferFlowOut<Opts>, InferFlowErrors<Opts>> = {
253
251
  [flowBrand]: true,
254
252
  name,
255
- unit: options.unit,
253
+ unit,
256
254
  in: options.in,
257
255
  out: options.out,
258
256
  errors: (options.errors ?? undefined) as InferFlowErrors<Opts> | undefined,
@@ -316,10 +314,10 @@ export function isFlow(value: unknown): value is AnyFlowDef {
316
314
  }
317
315
 
318
316
  /**
319
- * Reset the auto-name sequence (tests only).
317
+ * No-op. `name` is a required positional argument now, so there is no
318
+ * auto-name sequence left to reset — kept so existing test `beforeEach`
319
+ * blocks don't need touching.
320
320
  *
321
321
  * @internal
322
322
  */
323
- export function resetFlowSeq(): void {
324
- flowSeq = 0;
325
- }
323
+ export function resetFlowSeq(): void {}
package/src/kernel/fx.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { Effects, ResourceRef } from "../manifest/types.ts";
13
+ import { schemaTableName, sqlTableRef } from "../manifest/sql-resource.ts";
13
14
  import type {
14
15
  FilesStoreDecl,
15
16
  FilesStoreFxHandle,
@@ -852,6 +853,34 @@ export function createFxContext(options: CreateFxOptions): FxContext {
852
853
  );
853
854
  }
854
855
  };
856
+ /**
857
+ * Gate a table-scoped SQL operation. Prefers the precise `sql:<table>`
858
+ * ref (matches what the compiler's AST inference derives from the same
859
+ * call site — {@link "../manifest/sql-resource.ts"}); falls back to the
860
+ * store-level ref when the table ref isn't declared — every flow that
861
+ * hand-declared the older `effects: { writes: ["sql:<store>"] }`
862
+ * convention (every existing template, `upsert-app.test.ts`, …) must
863
+ * keep working unchanged. Ledger / journal record whichever ref the
864
+ * capability check actually matched, not always the coarser one.
865
+ *
866
+ * @param kind - Effect kind
867
+ * @param table - Table argument passed to a `SqlStoreHandle` method
868
+ * @param body - Work to run under the gate
869
+ */
870
+ const gatedTable = <T>(
871
+ kind: Parameters<CapabilityToken["assert"]>[0],
872
+ table: unknown,
873
+ body: () => T | Promise<T>,
874
+ ): Promise<T> => {
875
+ const name = schemaTableName(table);
876
+ if (name !== undefined) {
877
+ const perTable = sqlTableRef(name);
878
+ if (perTable !== ref && capability.allows(kind, perTable)) {
879
+ return gated(kind, perTable, body);
880
+ }
881
+ }
882
+ return gated(kind, ref, body);
883
+ };
855
884
 
856
885
  return {
857
886
  ref,
@@ -870,7 +899,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
870
899
  limit?: number;
871
900
  offset?: number;
872
901
  }): Promise<SqlRow[]> =>
873
- gated("read", ref, async () => {
902
+ gatedTable("read", table, async () => {
874
903
  const h = await ensure();
875
904
  const from = h.select(columns).from(table);
876
905
  const filtered = plan.where === undefined ? from : from.where(plan.where);
@@ -920,14 +949,14 @@ export function createFxContext(options: CreateFxOptions): FxContext {
920
949
  return {
921
950
  values(row) {
922
951
  const runExecute = () =>
923
- gated("write", ref, async () => {
952
+ gatedTable("write", table, async () => {
924
953
  refuseDryRunWrite();
925
954
  const h = await ensure();
926
955
  await h.insert(table).values(row).execute();
927
956
  });
928
957
  return {
929
958
  returning() {
930
- return gated("write", ref, async () => {
959
+ return gatedTable("write", table, async () => {
931
960
  refuseDryRunWrite();
932
961
  const h = await ensure();
933
962
  return h.insert(table).values(row).returning();
@@ -946,7 +975,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
946
975
  set(row) {
947
976
  return {
948
977
  where(where) {
949
- return gated("write", ref, async () => {
978
+ return gatedTable("write", table, async () => {
950
979
  refuseDryRunWrite();
951
980
  const h = await ensure();
952
981
  return h.update(table).set(row).where(where);
@@ -957,14 +986,14 @@ export function createFxContext(options: CreateFxOptions): FxContext {
957
986
  };
958
987
  },
959
988
  findById(table, id) {
960
- return gated("read", ref, async () => {
989
+ return gatedTable("read", table, async () => {
961
990
  const h = await ensure();
962
991
  return h.findById(table, id);
963
992
  });
964
993
  },
965
994
  delete(table: Parameters<SqlStoreHandle["delete"]>[0], id?: string) {
966
995
  if (id !== undefined) {
967
- return gated("write", ref, async () => {
996
+ return gatedTable("write", table, async () => {
968
997
  refuseDryRunWrite();
969
998
  const h = await ensure();
970
999
  return h.delete(table, id);
@@ -972,7 +1001,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
972
1001
  }
973
1002
  return {
974
1003
  where(where: unknown) {
975
- return gated("write", ref, async () => {
1004
+ return gatedTable("write", table, async () => {
976
1005
  refuseDryRunWrite();
977
1006
  const h = await ensure();
978
1007
  return h.delete(table).where(where);
@@ -981,20 +1010,20 @@ export function createFxContext(options: CreateFxOptions): FxContext {
981
1010
  };
982
1011
  },
983
1012
  exists(table, idOrWhere) {
984
- return gated("read", ref, async () => {
1013
+ return gatedTable("read", table, async () => {
985
1014
  const h = await ensure();
986
1015
  return h.exists(table, idOrWhere);
987
1016
  });
988
1017
  },
989
1018
  upsert(table, matchOn, values, upsertOptions) {
990
- return gated("write", ref, async () => {
1019
+ return gatedTable("write", table, async () => {
991
1020
  refuseDryRunWrite();
992
1021
  const h = await ensure();
993
1022
  return h.upsert(table, matchOn, values, upsertOptions);
994
1023
  });
995
1024
  },
996
1025
  increment(table, id, column, by) {
997
- return gated("write", ref, async () => {
1026
+ return gatedTable("write", table, async () => {
998
1027
  refuseDryRunWrite();
999
1028
  const h = await ensure();
1000
1029
  return h.increment(table, id, column, by);
@@ -1007,19 +1036,19 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1007
1036
  });
1008
1037
  },
1009
1038
  count(table, where) {
1010
- return gated("read", ref, async () => {
1039
+ return gatedTable("read", table, async () => {
1011
1040
  const h = await ensure();
1012
1041
  return h.count(table, where);
1013
1042
  });
1014
1043
  },
1015
1044
  page(table, pageOptions) {
1016
- return gated("read", ref, async () => {
1045
+ return gatedTable("read", table, async () => {
1017
1046
  const h = await ensure();
1018
1047
  return h.page(table, pageOptions);
1019
1048
  });
1020
1049
  },
1021
1050
  ensureTable(table) {
1022
- return gated("write", ref, async () => {
1051
+ return gatedTable("write", table, async () => {
1023
1052
  refuseDryRunWrite();
1024
1053
  const h = await ensure();
1025
1054
  return h.ensureTable(table);
@@ -27,7 +27,7 @@ describe("hook timing", () => {
27
27
  });
28
28
  const ctx: InvocationContext = {
29
29
  trigger: { kind: "internal" },
30
- flow: flow({ name: "t", do: () => ({ ok: true }) }),
30
+ flow: flow("t", { do: () => ({ ok: true }) }),
31
31
  input: undefined,
32
32
  params: {},
33
33
  state: {},
@@ -56,7 +56,7 @@ describe("hook timing", () => {
56
56
  const hooks = registry.hooksAt("app", undefined, "x");
57
57
  const ctx: InvocationContext = {
58
58
  trigger: { kind: "internal" },
59
- flow: flow({ name: "x", do: () => undefined }),
59
+ flow: flow("x", { do: () => undefined }),
60
60
  input: undefined,
61
61
  params: {},
62
62
  state: {},