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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/site/content/docs/elements/clock.mdx +2 -2
- package/site/content/docs/elements/flow.mdx +10 -10
- package/site/content/docs/elements/gate.mdx +4 -4
- package/site/content/docs/elements/signal.mdx +4 -7
- package/site/content/docs/elements/store.mdx +1 -1
- package/site/content/docs/elements/vault.mdx +1 -1
- package/site/content/docs/get-started/basic-usage.mdx +1 -3
- package/site/content/docs/get-started/introduction.mdx +1 -1
- package/site/content/docs/reference/cli.md +12 -12
- package/site/content/docs/reference/fx.mdx +3 -4
- package/src/auth/auth.test.ts +1 -2
- package/src/auth/bindings.ts +5 -15
- package/src/auth/gate-auth.test.ts +2 -2
- package/src/cli/ai-setup/apply.ts +13 -0
- package/src/cli/build.test.ts +67 -0
- package/src/cli/build.ts +34 -0
- package/src/cli/db-seed.ts +1 -1
- package/src/cli/dev-app-runner.ts +6 -0
- package/src/cli/dev-controls.test.ts +43 -48
- package/src/cli/dev-controls.ts +23 -172
- package/src/cli/dev.test.ts +2 -4
- package/src/cli/dev.ts +63 -16
- package/src/cli/docker-cli.test.ts +15 -0
- package/src/cli/start.ts +12 -1
- package/src/client/notes-contract.test.ts +3 -4
- package/src/compiler/aot.test.ts +1 -2
- package/src/compiler/effects-infer.ts +37 -8
- package/src/compiler/extract.test.ts +186 -6
- package/src/compiler/extract.ts +31 -6
- package/src/compiler/fixtures/raw/raw-unannotated.ts +2 -4
- package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +4 -8
- package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +1 -2
- package/src/compiler/fixtures/skyport/src/flows/support/index.ts +1 -2
- package/src/compiler/fixtures/skyport.expected.json +3 -3
- package/src/compiler/fixtures/triggers/five-triggers.ts +5 -10
- package/src/compiler/generate-adopt.test.ts +85 -0
- package/src/compiler/generate-adopt.ts +85 -0
- package/src/console/server/dry-run.audit.test.ts +2 -2
- package/src/console/server/flows.ts +50 -150
- package/src/docker/compose.ts +13 -3
- package/src/docker/recipes/llama-cpp.ts +10 -1
- package/src/docker/stack-id.ts +1 -0
- package/src/elements/channel/declare.ts +27 -1
- package/src/elements/clock/chaos-child.ts +3 -6
- package/src/elements/clock/chaos.test.ts +1 -2
- package/src/elements/clock.test.ts +2 -4
- package/src/elements/signal/declare.ts +25 -1
- package/src/elements/store/cache.test.ts +28 -0
- package/src/elements/store/declare.ts +27 -1
- package/src/elements/store/resource.ts +6 -11
- package/src/elements/store/upsert-app.test.ts +1 -2
- package/src/elements/vault/declare.ts +29 -1
- package/src/kernel/adopt-barrel-fresh.test.ts +103 -0
- package/src/kernel/app.ts +80 -9
- package/src/kernel/auto-registry.test.ts +198 -0
- package/src/kernel/boot-bind/honor-config.test.ts +5 -5
- package/src/kernel/boot.test.ts +19 -15
- package/src/kernel/boot.ts +186 -8
- package/src/kernel/call.test.ts +5 -10
- package/src/kernel/compensate.test.ts +3 -6
- package/src/kernel/concurrency.test.ts +3 -5
- package/src/kernel/correlation.test.ts +4 -8
- package/src/kernel/edge.test.ts +1 -1
- package/src/kernel/effects-stamping.test.ts +204 -0
- package/src/kernel/element-registries.ts +27 -0
- package/src/kernel/errors.ts +22 -0
- package/src/kernel/flow.test.ts +8 -15
- package/src/kernel/flow.ts +12 -14
- package/src/kernel/fx.ts +42 -13
- package/src/kernel/hook-timing.test.ts +2 -2
- package/src/kernel/hooks.test.ts +5 -10
- package/src/kernel/horizontal-child.ts +5 -10
- package/src/kernel/journal-boot.test.ts +2 -4
- package/src/kernel/pipeline.test.ts +6 -12
- package/src/kernel/plugin/capabilities.test.ts +1 -1
- package/src/kernel/plugin/decorate.test.ts +3 -8
- package/src/kernel/plugin/scoping.test.ts +6 -16
- package/src/kernel/plugin-elements.test.ts +1 -1
- package/src/kernel/plugin-needs.test.ts +1 -1
- package/src/kernel/ready.test.ts +1 -2
- package/src/kernel/registry-isolation.test.ts +5 -5
- package/src/kernel/triggers.ts +1 -1
- package/src/manifest/sql-resource.ts +41 -0
- package/src/plugins/anonymous.ts +1 -3
- package/src/plugins/compression.test.ts +6 -8
- package/src/plugins/config-source.test.ts +1 -1
- package/src/plugins/config-source.ts +1 -2
- package/src/plugins/cors.test.ts +10 -10
- package/src/plugins/csrf.test.ts +2 -2
- package/src/plugins/headers.test.ts +14 -15
- package/src/plugins/ip-allowlist.test.ts +9 -9
- package/src/plugins/magic-link.ts +2 -6
- package/src/plugins/maintenance-mode.test.ts +6 -6
- package/src/plugins/otp.ts +5 -15
- package/src/plugins/passkey.ts +4 -12
- package/src/plugins/two-factor.ts +3 -9
- package/src/plugins/username.ts +2 -6
- package/src/release/measure.ts +1 -1
- package/src/runs/runs.test.ts +3 -5
- package/src/runtime/serve.test.ts +2 -4
- package/src/test/create-test-app.test.ts +3 -7
- package/src/test/provisions.integration.test.ts +4 -9
- 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, {});
|
package/src/kernel/edge.test.ts
CHANGED
|
@@ -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(
|
|
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[] = [];
|
package/src/kernel/errors.ts
CHANGED
|
@@ -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.
|
package/src/kernel/flow.test.ts
CHANGED
|
@@ -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(
|
|
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 };
|
package/src/kernel/flow.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
59
|
+
flow: flow("x", { do: () => undefined }),
|
|
60
60
|
input: undefined,
|
|
61
61
|
params: {},
|
|
62
62
|
state: {},
|