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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `oke({...})` auto-populates `stores` / `secrets` / `signals` /
|
|
3
|
+
* `channel.templates` from the same kind of module-evaluation registry
|
|
4
|
+
* already proven for `on()`'s trigger drain (`registry-isolation.test.ts`).
|
|
5
|
+
*
|
|
6
|
+
* `store.sql` / `store.files`, `vault.secret`, `signal()`, and medium-binder
|
|
7
|
+
* `.template()` push into a shared registry (`src/kernel/element-registries.ts`)
|
|
8
|
+
* at call time — zero change to how developers call them. `oke()` drains it
|
|
9
|
+
* at construction under the same `registry: "consume" | "keep" | "ignore"`
|
|
10
|
+
* switch that already governs bindings.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from "bun:test";
|
|
14
|
+
import { channel } from "../elements/channel/declare.ts";
|
|
15
|
+
import { signal } from "../elements/signal/declare.ts";
|
|
16
|
+
import { store } from "../elements/store/declare.ts";
|
|
17
|
+
import { vault } from "../elements/vault/declare.ts";
|
|
18
|
+
import { oke } from "./app.ts";
|
|
19
|
+
import { flow } from "./flow.ts";
|
|
20
|
+
import { on, resetBindings } from "./on.ts";
|
|
21
|
+
import { http } from "./triggers.ts";
|
|
22
|
+
|
|
23
|
+
describe("oke() auto-registry — stores/secrets/signals/channel.templates", () => {
|
|
24
|
+
test("before/after: zero explicit arrays boots identically to the explicit-array form", async () => {
|
|
25
|
+
resetBindings();
|
|
26
|
+
|
|
27
|
+
// "before" — today's explicit-array form (registry: "ignore" so this
|
|
28
|
+
// app cannot see anything auto-drained; proves the old path is unchanged).
|
|
29
|
+
const dbBefore = store.sql("before-app", { schema: {} });
|
|
30
|
+
const filesBefore = store.files("before-uploads");
|
|
31
|
+
const secretBefore = vault.secret("BEFORE_WEBHOOK_SECRET", { dev: "dev-secret-before" });
|
|
32
|
+
const signalBefore = signal("before-note-created", { delivery: "once", optional: true });
|
|
33
|
+
const mailBefore = channel.email({ from: "Before <before@localhost>" });
|
|
34
|
+
const templateBefore = mailBefore.template("before-note-created");
|
|
35
|
+
|
|
36
|
+
const beforeFlow = flow("before.onCreated", {
|
|
37
|
+
effects: {
|
|
38
|
+
secrets: ["BEFORE_WEBHOOK_SECRET"],
|
|
39
|
+
emits: ["before-note-created"],
|
|
40
|
+
sends: ["before-note-created"],
|
|
41
|
+
},
|
|
42
|
+
do: async (_input, fx) => {
|
|
43
|
+
const secret = fx.vault(secretBefore);
|
|
44
|
+
await fx.emit(signalBefore, {});
|
|
45
|
+
await fx.send(templateBefore, { to: "you@localhost", data: {} });
|
|
46
|
+
return { secret };
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
on(http.post("/before"), beforeFlow);
|
|
50
|
+
|
|
51
|
+
const appBefore = oke({
|
|
52
|
+
name: "before-app",
|
|
53
|
+
registry: "ignore",
|
|
54
|
+
bindings: [{ trigger: http.post("/before"), flow: beforeFlow }],
|
|
55
|
+
gate: { unguardedHttp: "allow" },
|
|
56
|
+
vault: { allowDevFallbacks: true },
|
|
57
|
+
stores: [dbBefore, filesBefore],
|
|
58
|
+
secrets: [secretBefore],
|
|
59
|
+
signals: [signalBefore],
|
|
60
|
+
channel: { templates: [templateBefore] },
|
|
61
|
+
startScheduler: false,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// "after" — same shape, zero explicit arrays; registry auto-populates.
|
|
65
|
+
// Declared (and registered) for their side effect only — never passed
|
|
66
|
+
// to `oke()` directly, proving the registry alone carries them.
|
|
67
|
+
store.sql("after-app", { schema: {} });
|
|
68
|
+
store.files("after-uploads");
|
|
69
|
+
const secretAfter = vault.secret("AFTER_WEBHOOK_SECRET", { dev: "dev-secret-after" });
|
|
70
|
+
const signalAfter = signal("after-note-created", { delivery: "once", optional: true });
|
|
71
|
+
const mailAfter = channel.email({ from: "After <after@localhost>" });
|
|
72
|
+
const templateAfter = mailAfter.template("after-note-created");
|
|
73
|
+
|
|
74
|
+
const afterFlow = flow("after.onCreated", {
|
|
75
|
+
effects: {
|
|
76
|
+
secrets: ["AFTER_WEBHOOK_SECRET"],
|
|
77
|
+
emits: ["after-note-created"],
|
|
78
|
+
sends: ["after-note-created"],
|
|
79
|
+
},
|
|
80
|
+
do: async (_input, fx) => {
|
|
81
|
+
const secret = fx.vault(secretAfter);
|
|
82
|
+
await fx.emit(signalAfter, {});
|
|
83
|
+
await fx.send(templateAfter, { to: "you@localhost", data: {} });
|
|
84
|
+
return { secret };
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
resetBindings();
|
|
88
|
+
on(http.post("/after"), afterFlow);
|
|
89
|
+
|
|
90
|
+
const appAfter = oke({
|
|
91
|
+
name: "after-app",
|
|
92
|
+
gate: { unguardedHttp: "allow" },
|
|
93
|
+
vault: { allowDevFallbacks: true },
|
|
94
|
+
startScheduler: false,
|
|
95
|
+
// No stores / secrets / signals / channel — registry supplies them.
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await appBefore.boot({ env: "test", unguardedHttp: "allow" });
|
|
99
|
+
await appAfter.boot({ env: "test", unguardedHttp: "allow" });
|
|
100
|
+
|
|
101
|
+
// Both boots resolved the same shape from stores → secrets → signals → channel.
|
|
102
|
+
expect(appBefore.bootResult?.store?.declarations.has("sql:before-app")).toBe(true);
|
|
103
|
+
expect(appBefore.bootResult?.store?.declarations.has("files:before-uploads")).toBe(true);
|
|
104
|
+
expect(appAfter.bootResult?.store?.declarations.has("sql:after-app")).toBe(true);
|
|
105
|
+
expect(appAfter.bootResult?.store?.declarations.has("files:after-uploads")).toBe(true);
|
|
106
|
+
|
|
107
|
+
expect(appBefore.bootResult?.vault?.contracts.has("BEFORE_WEBHOOK_SECRET")).toBe(true);
|
|
108
|
+
expect(appAfter.bootResult?.vault?.contracts.has("AFTER_WEBHOOK_SECRET")).toBe(true);
|
|
109
|
+
expect(appBefore.bootResult?.vault?.read("BEFORE_WEBHOOK_SECRET")).toBe("dev-secret-before");
|
|
110
|
+
expect(appAfter.bootResult?.vault?.read("AFTER_WEBHOOK_SECRET")).toBe("dev-secret-after");
|
|
111
|
+
|
|
112
|
+
expect(appBefore.bootResult?.signal?.declarations.has("before-note-created")).toBe(true);
|
|
113
|
+
expect(appAfter.bootResult?.signal?.declarations.has("after-note-created")).toBe(true);
|
|
114
|
+
|
|
115
|
+
expect(appBefore.bootResult?.channel?.templates.has("before-note-created")).toBe(true);
|
|
116
|
+
expect(appAfter.bootResult?.channel?.templates.has("after-note-created")).toBe(true);
|
|
117
|
+
|
|
118
|
+
// Same flow execution shape through the real HTTP path either way —
|
|
119
|
+
// both resolve, emit, and send successfully; the secret value itself
|
|
120
|
+
// comes back redacted (fx output redaction), same on both sides.
|
|
121
|
+
const resBefore = await appBefore.fetch(
|
|
122
|
+
new Request("http://localhost/before", { method: "POST" }),
|
|
123
|
+
);
|
|
124
|
+
const resAfter = await appAfter.fetch(
|
|
125
|
+
new Request("http://localhost/after", { method: "POST" }),
|
|
126
|
+
);
|
|
127
|
+
expect(resBefore.status).toBe(200);
|
|
128
|
+
expect(resAfter.status).toBe(200);
|
|
129
|
+
const bodyBefore = (await resBefore.json()) as { data: { secret: string } };
|
|
130
|
+
const bodyAfter = (await resAfter.json()) as { data: { secret: string } };
|
|
131
|
+
expect(bodyBefore.data.secret).toBe(bodyAfter.data.secret);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("explicit passing is additive, never silently ignored — same object via both paths is deduped, not doubled", async () => {
|
|
135
|
+
const db = store.sql("dedup-app", { schema: {} });
|
|
136
|
+
|
|
137
|
+
// `db` is already sitting in the registry from the call above *and*
|
|
138
|
+
// passed explicitly — must not double-register / conflict.
|
|
139
|
+
const app = oke({
|
|
140
|
+
name: "dedup-app",
|
|
141
|
+
autoBoot: false,
|
|
142
|
+
stores: [db],
|
|
143
|
+
startScheduler: false,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await app.boot({ env: "test", unguardedHttp: "allow" });
|
|
147
|
+
expect(app.bootResult?.store?.declarations.has("sql:dedup-app")).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('registry: "ignore" does not auto-populate', async () => {
|
|
151
|
+
vault.secret("IGNORED_SECRET", { description: "never passed, never auto-read" });
|
|
152
|
+
|
|
153
|
+
const app = oke({
|
|
154
|
+
name: "ignore-mode-app",
|
|
155
|
+
registry: "ignore",
|
|
156
|
+
startScheduler: false,
|
|
157
|
+
});
|
|
158
|
+
// Nothing requires the secret, so boot succeeds — but it must not have
|
|
159
|
+
// silently picked up IGNORED_SECRET from the registry either way.
|
|
160
|
+
await app.boot({ env: "test", unguardedHttp: "allow" });
|
|
161
|
+
expect(app.bootResult?.vault?.contracts.has("IGNORED_SECRET")).toBeFalsy();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('registry: "consume" (default) drains stores/secrets/signals/channel.templates — a later app does not inherit them', async () => {
|
|
165
|
+
store.sql("leak-app", { schema: {} });
|
|
166
|
+
vault.secret("LEAK_SECRET", { dev: "dev-leak" });
|
|
167
|
+
signal("leak-signal", { delivery: "once" });
|
|
168
|
+
channel.email().template("leak-template");
|
|
169
|
+
|
|
170
|
+
const appA = oke({
|
|
171
|
+
name: "leak-app-a",
|
|
172
|
+
autoBoot: false,
|
|
173
|
+
vault: { allowDevFallbacks: true },
|
|
174
|
+
startScheduler: false,
|
|
175
|
+
});
|
|
176
|
+
await appA.boot({ env: "test", unguardedHttp: "allow" });
|
|
177
|
+
expect(appA.bootResult?.store?.declarations.has("sql:leak-app")).toBe(true);
|
|
178
|
+
expect(appA.bootResult?.vault?.contracts.has("LEAK_SECRET")).toBe(true);
|
|
179
|
+
|
|
180
|
+
// Second app, constructed after the first drained the registry.
|
|
181
|
+
const appB = oke({
|
|
182
|
+
name: "leak-app-b",
|
|
183
|
+
autoBoot: false,
|
|
184
|
+
startScheduler: false,
|
|
185
|
+
});
|
|
186
|
+
await appB.boot({ env: "test", unguardedHttp: "allow" });
|
|
187
|
+
// Nothing was passed and nothing was left in the registry (App A drained
|
|
188
|
+
// it) — App B needed store/vault/signal not at all, so no runtime was
|
|
189
|
+
// built for them. Channel boots unconditionally regardless of registry
|
|
190
|
+
// content (pre-existing, unrelated to this feature — `resolveElementNeeds`
|
|
191
|
+
// gates channel on object *presence*, not template count) — assert its
|
|
192
|
+
// template map, not runtime absence.
|
|
193
|
+
expect(appB.bootResult?.store).toBeUndefined();
|
|
194
|
+
expect(appB.bootResult?.vault).toBeUndefined();
|
|
195
|
+
expect(appB.bootResult?.signal).toBeUndefined();
|
|
196
|
+
expect(appB.bootResult?.channel?.templates.has("leak-template") ?? false).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -157,7 +157,7 @@ describe("boot binders honour drivers.* config", () => {
|
|
|
157
157
|
const result = await bootApplication({
|
|
158
158
|
env: "local",
|
|
159
159
|
startScheduler: false,
|
|
160
|
-
flows: [flow(
|
|
160
|
+
flows: [flow("charge", { durable: true, do: () => ({ ok: true }) })],
|
|
161
161
|
});
|
|
162
162
|
try {
|
|
163
163
|
expect(result.journal?.driverId).toBe("memory");
|
|
@@ -172,7 +172,7 @@ describe("boot binders honour drivers.* config", () => {
|
|
|
172
172
|
const result = await bootApplication({
|
|
173
173
|
env: "local",
|
|
174
174
|
startScheduler: false,
|
|
175
|
-
flows: [flow(
|
|
175
|
+
flows: [flow("plain", { do: () => ({ ok: true }) })],
|
|
176
176
|
});
|
|
177
177
|
try {
|
|
178
178
|
expect(result.journal).toBeUndefined();
|
|
@@ -187,7 +187,7 @@ describe("boot binders honour drivers.* config", () => {
|
|
|
187
187
|
const result = await bootApplication({
|
|
188
188
|
env: "local",
|
|
189
189
|
startScheduler: false,
|
|
190
|
-
flows: [flow(
|
|
190
|
+
flows: [flow("charge", { durable: true, do: () => ({ ok: true }) })],
|
|
191
191
|
config: {
|
|
192
192
|
drivers: {
|
|
193
193
|
journal: { local: "file", docker: "postgres", test: "memory", prod: "postgres" },
|
|
@@ -211,7 +211,7 @@ describe("boot binders honour drivers.* config", () => {
|
|
|
211
211
|
bootApplication({
|
|
212
212
|
env: "local",
|
|
213
213
|
startScheduler: false,
|
|
214
|
-
flows: [flow(
|
|
214
|
+
flows: [flow("charge", { durable: true, do: () => ({ ok: true }) })],
|
|
215
215
|
config: {
|
|
216
216
|
drivers: {
|
|
217
217
|
journal: { local: "postgres" },
|
|
@@ -232,7 +232,7 @@ describe("boot binders honour drivers.* config", () => {
|
|
|
232
232
|
bootApplication({
|
|
233
233
|
env: "local",
|
|
234
234
|
startScheduler: false,
|
|
235
|
-
flows: [flow(
|
|
235
|
+
flows: [flow("charge", { durable: true, do: () => ({ ok: true }) })],
|
|
236
236
|
config: {
|
|
237
237
|
drivers: {
|
|
238
238
|
journal: { local: "neon" },
|
package/src/kernel/boot.test.ts
CHANGED
|
@@ -79,7 +79,7 @@ describe("boot — lazy element needs", () => {
|
|
|
79
79
|
expect(needs.signal).toBe(false);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
test("oke() Store-only graph stays under the prior 54 kB baseline", async () => {
|
|
82
|
+
test("oke() Store-only graph stays under the prior 54.3 kB baseline", async () => {
|
|
83
83
|
const dir = await mkdtemp(join(tmpdir(), "oke-store-only-"));
|
|
84
84
|
const entry = join(dir, "entry.ts");
|
|
85
85
|
const appPath = join(import.meta.dir, "app.ts");
|
|
@@ -120,8 +120,18 @@ describe("boot — lazy element needs", () => {
|
|
|
120
120
|
// gzip), durable-journal lease surface (~51.2 kB), fx.deliverOtp +
|
|
121
121
|
// unified otp() (~50.9 kB → 53 kB), then fx.runs / SLO window on the
|
|
122
122
|
// shared surface (~53.3 kB → 54 kB cap). Clock/channel/journal drivers
|
|
123
|
-
// stay lazy-bound; far below eager bind.
|
|
124
|
-
|
|
123
|
+
// stay lazy-bound; far below eager bind. Rebased again for the
|
|
124
|
+
// store/vault/signal/channel auto-registry drain (`element-registries.ts`
|
|
125
|
+
// + the `oke()` merge/dedup logic) — every app now carries this
|
|
126
|
+
// regardless of which elements it uses, since the drain must run
|
|
127
|
+
// synchronously at construction (same isolation guarantee as the
|
|
128
|
+
// `on()` binding registry) and can't be deferred behind a lazy import
|
|
129
|
+
// (~54.0 kB → 54.3 kB cap). Rebased again for `assertAdoptBarrelFresh`
|
|
130
|
+
// + `tryListFlowsUnits` in `boot.ts` (`.adopt()` barrel staleness
|
|
131
|
+
// check, OKE1009) — part of the already-lazily-loaded boot graph, not
|
|
132
|
+
// the `oke()` construction path, but `Bun.build` still traces and
|
|
133
|
+
// sums the dynamic-import chunk (~54.3 kB → 54.7 kB cap).
|
|
134
|
+
expect(total).toBeLessThan(54_700);
|
|
125
135
|
} finally {
|
|
126
136
|
await rm(dir, { recursive: true, force: true });
|
|
127
137
|
}
|
|
@@ -130,8 +140,7 @@ describe("boot — lazy element needs", () => {
|
|
|
130
140
|
|
|
131
141
|
describe("boot — capabilities from Manifest effects", () => {
|
|
132
142
|
test("tokens cover declared effects only", async () => {
|
|
133
|
-
const charge = flow({
|
|
134
|
-
name: "payments.charge",
|
|
143
|
+
const charge = flow("payments.charge", {
|
|
135
144
|
effects: { secrets: ["STRIPE_KEY"], writes: ["sql:orders"] },
|
|
136
145
|
do: () => ({ ok: true }),
|
|
137
146
|
});
|
|
@@ -158,8 +167,7 @@ describe("boot — cron fires without manual dispatch", () => {
|
|
|
158
167
|
let ran = 0;
|
|
159
168
|
on(
|
|
160
169
|
every("1h"),
|
|
161
|
-
flow({
|
|
162
|
-
name: "cleanup.expire",
|
|
170
|
+
flow("cleanup.expire", {
|
|
163
171
|
do: () => {
|
|
164
172
|
ran += 1;
|
|
165
173
|
},
|
|
@@ -200,8 +208,7 @@ describe("boot — HTTP gate wiring on the default path", () => {
|
|
|
200
208
|
resetFlowSeq();
|
|
201
209
|
on(
|
|
202
210
|
http.get("/ping"),
|
|
203
|
-
flow({
|
|
204
|
-
name: "ping",
|
|
211
|
+
flow("ping", {
|
|
205
212
|
do: () => ({ ok: true }),
|
|
206
213
|
}),
|
|
207
214
|
);
|
|
@@ -217,8 +224,7 @@ describe("boot — HTTP gate wiring on the default path", () => {
|
|
|
217
224
|
const { GateBootError } = await import("../elements/gate/boot.ts");
|
|
218
225
|
on(
|
|
219
226
|
http.get("/ping"),
|
|
220
|
-
flow({
|
|
221
|
-
name: "ping",
|
|
227
|
+
flow("ping", {
|
|
222
228
|
do: () => ({ ok: true }),
|
|
223
229
|
}),
|
|
224
230
|
);
|
|
@@ -235,8 +241,7 @@ describe("boot — HTTP gate wiring on the default path", () => {
|
|
|
235
241
|
const { gate } = await import("../elements/gate.ts");
|
|
236
242
|
on(
|
|
237
243
|
http.get("/ping").gate(gate.public),
|
|
238
|
-
flow({
|
|
239
|
-
name: "ping-public",
|
|
244
|
+
flow("ping-public", {
|
|
240
245
|
do: () => ({ ok: true }),
|
|
241
246
|
}),
|
|
242
247
|
);
|
|
@@ -261,8 +266,7 @@ describe("boot — cron autostart via real timer loop", () => {
|
|
|
261
266
|
let ran = 0;
|
|
262
267
|
on(
|
|
263
268
|
every("10m"),
|
|
264
|
-
flow({
|
|
265
|
-
name: "cleanup.autostart",
|
|
269
|
+
flow("cleanup.autostart", {
|
|
266
270
|
do: () => {
|
|
267
271
|
ran += 1;
|
|
268
272
|
},
|
package/src/kernel/boot.ts
CHANGED
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
* Element runtimes are loaded via `new URL("./boot-bind/"+name+".ts", …)` so
|
|
18
18
|
* unused binders stay out of the `oke()` bundle (semantic tree-shaking).
|
|
19
19
|
* Vault still loads whenever secrets are declared and lists every gap in one
|
|
20
|
-
* failure; capability minting uses flow effect refs only — no element modules
|
|
20
|
+
* failure; capability minting uses flow effect refs only — no element modules,
|
|
21
|
+
* except a lazy `extractManifest` import when a flow has no hand-declared
|
|
22
|
+
* `effects` and neither {@link BootOptions.manifest} nor
|
|
23
|
+
* {@link BootOptions.rootDir} / `OKE_ROOT_DIR` are set to derive one.
|
|
21
24
|
*
|
|
22
25
|
* The scheduler reads the effective state from the Store after reconciliation,
|
|
23
26
|
* never the code directly (console §5).
|
|
@@ -38,8 +41,11 @@ import type {
|
|
|
38
41
|
import type { JournalRuntime } from "./boot-bind/journal.ts";
|
|
39
42
|
import type { CreateRunsRuntimeOptions, RunsRuntime } from "../runs/index.ts";
|
|
40
43
|
import { createCapabilityToken, type CapabilityToken } from "./capability.ts";
|
|
44
|
+
import { throwOke } from "./errors.ts";
|
|
41
45
|
import type { AnyFlowDef } from "./flow.ts";
|
|
42
46
|
import type { Binding } from "./on.ts";
|
|
47
|
+
import type { Manifest } from "../manifest/types.ts";
|
|
48
|
+
import { emitBootWarn } from "../runtime/boot-warn.ts";
|
|
43
49
|
|
|
44
50
|
/** Pre-built or partially-built element runtimes. */
|
|
45
51
|
export interface ElementRuntimes {
|
|
@@ -104,6 +110,21 @@ export interface BootOptions {
|
|
|
104
110
|
readonly bindings?: readonly Binding[];
|
|
105
111
|
/** Flows known to the app (for capability minting). */
|
|
106
112
|
readonly flows?: readonly AnyFlowDef[];
|
|
113
|
+
/**
|
|
114
|
+
* Compiled Manifest to derive capability tokens from for flows with no
|
|
115
|
+
* hand-declared `effects` (highest priority — build tooling / bundler
|
|
116
|
+
* import, never a filesystem read the kernel performs itself).
|
|
117
|
+
*/
|
|
118
|
+
readonly manifest?: Manifest;
|
|
119
|
+
/**
|
|
120
|
+
* Project root for a lazy, best-effort `extractManifest` when a flow has
|
|
121
|
+
* no hand-declared `effects` and no {@link manifest} was given. Explicit
|
|
122
|
+
* opt-in only — never defaults to `process.cwd()` — so the existing test
|
|
123
|
+
* suite (hundreds of boots against synthetic flows, not a real source
|
|
124
|
+
* tree) never pays extraction cost. Falls back to `OKE_ROOT_DIR` when
|
|
125
|
+
* unset (the CLI sets this for `oke dev` / `oke start`).
|
|
126
|
+
*/
|
|
127
|
+
readonly rootDir?: string;
|
|
107
128
|
/**
|
|
108
129
|
* Dispatch a cron / every interval when the scheduler fires.
|
|
109
130
|
*
|
|
@@ -264,7 +285,7 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
|
|
|
264
285
|
input.docker === true || (input.docker !== false && process.env.OKE_DOCKER === "1");
|
|
265
286
|
// `-d` always selects the `docker` driver profile — not a mix of local/test +
|
|
266
287
|
// prod store overrides when `$options.env` is unset.
|
|
267
|
-
const env: ConfigEnv = docker ? "docker" :
|
|
288
|
+
const env: ConfigEnv = input.env ?? (docker ? "docker" : "local");
|
|
268
289
|
let config = input.config;
|
|
269
290
|
if (config === undefined) {
|
|
270
291
|
try {
|
|
@@ -457,8 +478,16 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
|
|
|
457
478
|
}
|
|
458
479
|
}
|
|
459
480
|
|
|
460
|
-
// 8. Caps — effect refs only; no element modules.
|
|
461
|
-
const capabilities = mintCapabilities(options.flows ?? []
|
|
481
|
+
// 8. Caps — effect refs only; no element modules (unless a lazy extract is needed).
|
|
482
|
+
const capabilities = await mintCapabilities(options.flows ?? [], env, {
|
|
483
|
+
manifest: options.manifest,
|
|
484
|
+
rootDir: options.rootDir,
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// 9. `.adopt()` barrel freshness — opt-in only (same `rootDir` gate as
|
|
488
|
+
// capability minting above; never a filesystem read the kernel performs
|
|
489
|
+
// on its own).
|
|
490
|
+
await assertAdoptBarrelFresh(options.flows ?? [], env, options.rootDir);
|
|
462
491
|
|
|
463
492
|
return {
|
|
464
493
|
vault,
|
|
@@ -491,20 +520,169 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
|
|
|
491
520
|
};
|
|
492
521
|
}
|
|
493
522
|
|
|
523
|
+
/** Per-process guard so the "no effects" boot warning fires once, not per-flow. */
|
|
524
|
+
let noEffectsWarned = false;
|
|
525
|
+
|
|
526
|
+
/** Reset the once-per-process "no effects" warn latch (tests only). */
|
|
527
|
+
export function resetNoEffectsWarnForTests(): void {
|
|
528
|
+
noEffectsWarned = false;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Best-effort AoT extraction from `rootDir` — mirrors the CLI's own lazy,
|
|
533
|
+
* defensive `extractManifest` use (`oke dev`'s `tryLoadProjectManifest`).
|
|
534
|
+
* Never throws: a broken or mid-edit source tree just means "no Manifest
|
|
535
|
+
* available," handled by the caller like any other missing Manifest.
|
|
536
|
+
*
|
|
537
|
+
* `new URL` (not a literal specifier) so `oxc-parser` / the whole compiler
|
|
538
|
+
* never enters the bundler graph for apps that never hit this path — same
|
|
539
|
+
* trick as {@link loadBind} for the element binders.
|
|
540
|
+
*
|
|
541
|
+
* @param rootDir - Project root to extract from
|
|
542
|
+
*/
|
|
543
|
+
async function tryAutoExtractManifest(rootDir: string): Promise<Manifest | undefined> {
|
|
544
|
+
try {
|
|
545
|
+
const url = new URL("../compiler/extract.ts", import.meta.url);
|
|
546
|
+
const { extractManifest } = (await import(url.href)) as typeof import("../compiler/extract.ts");
|
|
547
|
+
return await extractManifest({ rootDir });
|
|
548
|
+
} catch {
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
494
553
|
/**
|
|
495
|
-
* Mint capability tokens from each flow's declared effects
|
|
496
|
-
*
|
|
554
|
+
* Mint capability tokens from each flow's declared effects, falling back to
|
|
555
|
+
* a Manifest-derived stamp when a flow declares no `effects` of its own —
|
|
556
|
+
* an explicit {@link BootOptions.manifest}, or a lazy AoT extract from
|
|
557
|
+
* {@link BootOptions.rootDir} / `OKE_ROOT_DIR`.
|
|
558
|
+
*
|
|
559
|
+
* When neither is available: `local` / `test` stay open (today's dev-loop
|
|
560
|
+
* behavior, unbroken) with a once-per-process `oke boot:` warning; `docker`
|
|
561
|
+
* / `prod` fail loud (`OKE1008`) — docker mirrors prod's posture, never a
|
|
562
|
+
* silent open door in a deploy-shaped environment.
|
|
497
563
|
*
|
|
498
564
|
* @param flows - Adopted flows
|
|
565
|
+
* @param env - Resolved {@link ConfigEnv}
|
|
566
|
+
* @param options - Manifest / rootDir sources for the fallback stamp
|
|
499
567
|
*/
|
|
500
|
-
export function mintCapabilities(
|
|
568
|
+
export async function mintCapabilities(
|
|
569
|
+
flows: readonly AnyFlowDef[],
|
|
570
|
+
env: ConfigEnv = "local",
|
|
571
|
+
options: { readonly manifest?: Manifest; readonly rootDir?: string } = {},
|
|
572
|
+
): Promise<Map<string, CapabilityToken>> {
|
|
501
573
|
const map = new Map<string, CapabilityToken>();
|
|
574
|
+
|
|
575
|
+
let manifest = options.manifest;
|
|
576
|
+
const needsManifest = manifest === undefined && flows.some((f) => f.effects === undefined);
|
|
577
|
+
if (needsManifest) {
|
|
578
|
+
const rootDir = options.rootDir ?? process.env["OKE_ROOT_DIR"];
|
|
579
|
+
if (rootDir) manifest = await tryAutoExtractManifest(rootDir);
|
|
580
|
+
}
|
|
581
|
+
|
|
502
582
|
for (const f of flows) {
|
|
503
|
-
|
|
583
|
+
if (f.effects !== undefined) {
|
|
584
|
+
map.set(f.name, createCapabilityToken(f.name, f.effects));
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const stamped = manifest?.flows?.[f.name]?.effects;
|
|
589
|
+
if (stamped !== undefined) {
|
|
590
|
+
map.set(f.name, createCapabilityToken(f.name, stamped));
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (env === "docker" || env === "prod") {
|
|
595
|
+
throwOke("NO_EFFECTS_DECLARED", { flow: f.name });
|
|
596
|
+
}
|
|
597
|
+
if (!noEffectsWarned) {
|
|
598
|
+
noEffectsWarned = true;
|
|
599
|
+
emitBootWarn(
|
|
600
|
+
`oke boot: flow "${f.name}" (and possibly others) has no declared effects and no ` +
|
|
601
|
+
"Manifest-derived effects — running with an OPEN capability token (every access " +
|
|
602
|
+
"allowed, ledgered but not gated). Run `oke build`, or boot with `manifest` / " +
|
|
603
|
+
"`rootDir`, before deploying — docker/prod refuse to boot this way.",
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
map.set(f.name, createCapabilityToken(f.name, undefined));
|
|
504
607
|
}
|
|
505
608
|
return map;
|
|
506
609
|
}
|
|
507
610
|
|
|
611
|
+
/** Per-process guard so the stale-barrel boot warning fires once. */
|
|
612
|
+
let staleAdoptBarrelWarned = false;
|
|
613
|
+
|
|
614
|
+
/** Reset the once-per-process stale-barrel warn latch (tests only). */
|
|
615
|
+
export function resetStaleAdoptBarrelWarnForTests(): void {
|
|
616
|
+
staleAdoptBarrelWarned = false;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Best-effort disk scan for `<rootDir>/src/flows/*` unit folders — mirrors
|
|
621
|
+
* {@link tryAutoExtractManifest}'s lazy, defensive, never-throws posture and
|
|
622
|
+
* the same `new URL` trick so the generator never enters the bundler graph
|
|
623
|
+
* for apps that never opt into `rootDir`.
|
|
624
|
+
*
|
|
625
|
+
* @param rootDir - Project root to scan
|
|
626
|
+
*/
|
|
627
|
+
async function tryListFlowsUnits(rootDir: string): Promise<readonly string[] | undefined> {
|
|
628
|
+
try {
|
|
629
|
+
const url = new URL("../compiler/generate-adopt.ts", import.meta.url);
|
|
630
|
+
const { generateAdoptBarrel } = (await import(
|
|
631
|
+
url.href
|
|
632
|
+
)) as typeof import("../compiler/generate-adopt.ts");
|
|
633
|
+
return (await generateAdoptBarrel({ rootDir })).units;
|
|
634
|
+
} catch {
|
|
635
|
+
return undefined;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Confirm every `src/flows/<unit>` folder on disk actually reached this
|
|
641
|
+
* boot's adopted flows — the disk-file counterpart of a stale/missing
|
|
642
|
+
* generated `.adopt()` barrel (`src/flows/generated.ts`). A folder present
|
|
643
|
+
* on disk with zero adopted flows under that unit means the barrel wasn't
|
|
644
|
+
* regenerated (or was hand-edited) after the folder was added.
|
|
645
|
+
*
|
|
646
|
+
* Opt-in only via {@link BootOptions.rootDir} / `OKE_ROOT_DIR` (same gate as
|
|
647
|
+
* {@link mintCapabilities}'s Manifest fallback) — never a filesystem read
|
|
648
|
+
* the kernel performs on its own. `local` / `test` warn once per process;
|
|
649
|
+
* `docker` / `prod` fail loud (`OKE1009`) — same class as `NO_EFFECTS_DECLARED`,
|
|
650
|
+
* never a silently-incomplete route table in a deploy-shaped environment.
|
|
651
|
+
*
|
|
652
|
+
* @param flows - Adopted flows
|
|
653
|
+
* @param env - Resolved {@link ConfigEnv}
|
|
654
|
+
* @param rootDir - Explicit project root (falls back to `OKE_ROOT_DIR`)
|
|
655
|
+
*/
|
|
656
|
+
export async function assertAdoptBarrelFresh(
|
|
657
|
+
flows: readonly AnyFlowDef[],
|
|
658
|
+
env: ConfigEnv = "local",
|
|
659
|
+
rootDir?: string,
|
|
660
|
+
): Promise<void> {
|
|
661
|
+
const dir = rootDir ?? process.env["OKE_ROOT_DIR"];
|
|
662
|
+
if (!dir) return;
|
|
663
|
+
|
|
664
|
+
const diskUnits = await tryListFlowsUnits(dir);
|
|
665
|
+
if (diskUnits === undefined || diskUnits.length === 0) return;
|
|
666
|
+
|
|
667
|
+
const adoptedUnits = new Set(
|
|
668
|
+
flows.map((f) => f.unit).filter((u): u is string => u !== undefined),
|
|
669
|
+
);
|
|
670
|
+
const missing = diskUnits.filter((u) => !adoptedUnits.has(u));
|
|
671
|
+
if (missing.length === 0) return;
|
|
672
|
+
|
|
673
|
+
if (env === "docker" || env === "prod") {
|
|
674
|
+
throwOke("ADOPT_BARREL_STALE", { unit: missing[0]! });
|
|
675
|
+
}
|
|
676
|
+
if (!staleAdoptBarrelWarned) {
|
|
677
|
+
staleAdoptBarrelWarned = true;
|
|
678
|
+
emitBootWarn(
|
|
679
|
+
`oke boot: src/flows/${missing[0]} exists on disk but adopted no flows — the ` +
|
|
680
|
+
'.adopt() barrel ("src/flows/generated.ts") is stale. Run `oke dev` or `oke build` ' +
|
|
681
|
+
"to regenerate it — docker/prod refuse to boot this way.",
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
508
686
|
function isRunsRuntime(value: RunsRuntime | CreateRunsRuntimeOptions): value is RunsRuntime {
|
|
509
687
|
return (
|
|
510
688
|
typeof value === "object" &&
|
package/src/kernel/call.test.ts
CHANGED
|
@@ -11,15 +11,13 @@ beforeEach(() => {
|
|
|
11
11
|
|
|
12
12
|
describe("fx.call — untriggered flows", () => {
|
|
13
13
|
test("untriggered flow is callable via app.call and fx.call", async () => {
|
|
14
|
-
const stats = flow({
|
|
15
|
-
name: "links.stats",
|
|
14
|
+
const stats = flow("links.stats", {
|
|
16
15
|
do: ({ code }: { code: string }) => ({ code, clicks: 7 }),
|
|
17
16
|
});
|
|
18
17
|
|
|
19
18
|
const parent = on(
|
|
20
19
|
http.post("/run"),
|
|
21
|
-
flow({
|
|
22
|
-
name: "links.run",
|
|
20
|
+
flow("links.run", {
|
|
23
21
|
effects: { calls: ["links.stats"] },
|
|
24
22
|
do: async ({ code }: { code: string }, fx) => {
|
|
25
23
|
const result = await fx.call("links.stats", { code });
|
|
@@ -54,8 +52,7 @@ describe("fx.call — untriggered flows", () => {
|
|
|
54
52
|
});
|
|
55
53
|
|
|
56
54
|
test("fx.call propagates fx.principal without filling fx.auth", async () => {
|
|
57
|
-
const audit = flow({
|
|
58
|
-
name: "audit.log",
|
|
55
|
+
const audit = flow("audit.log", {
|
|
59
56
|
do: (_input: { event: string }, fx) => ({
|
|
60
57
|
authUserId: fx.auth.userId,
|
|
61
58
|
principalUserId: fx.principal.userId,
|
|
@@ -65,8 +62,7 @@ describe("fx.call — untriggered flows", () => {
|
|
|
65
62
|
|
|
66
63
|
const act = on(
|
|
67
64
|
http.post("/act"),
|
|
68
|
-
flow({
|
|
69
|
-
name: "act",
|
|
65
|
+
flow("act", {
|
|
70
66
|
effects: { calls: ["audit.log"] },
|
|
71
67
|
do: async (_input: Record<string, never>, fx) => {
|
|
72
68
|
return fx.call("audit.log", { event: "act" });
|
|
@@ -99,8 +95,7 @@ describe("fx.call — untriggered flows", () => {
|
|
|
99
95
|
|
|
100
96
|
test("http and signal invocations of the same flow execute identically", async () => {
|
|
101
97
|
let runs = 0;
|
|
102
|
-
const work = flow({
|
|
103
|
-
name: "work",
|
|
98
|
+
const work = flow("work", {
|
|
104
99
|
do: (input: { v: number }) => {
|
|
105
100
|
runs += 1;
|
|
106
101
|
return { out: input.v + 1 };
|
|
@@ -24,8 +24,7 @@ describe("durable compensate", () => {
|
|
|
24
24
|
const store = createMemoryJournalStore();
|
|
25
25
|
const undos: string[] = [];
|
|
26
26
|
|
|
27
|
-
const pay = flow({
|
|
28
|
-
name: "pay.charge",
|
|
27
|
+
const pay = flow("pay.charge", {
|
|
29
28
|
durable: true,
|
|
30
29
|
do: async (_input: { amount: number }, fx) => {
|
|
31
30
|
await fx.step("reserve", async () => "reserved");
|
|
@@ -72,8 +71,7 @@ describe("durable compensate", () => {
|
|
|
72
71
|
test("compensate does not run on successful path", async () => {
|
|
73
72
|
let compensated = 0;
|
|
74
73
|
const store = createMemoryJournalStore();
|
|
75
|
-
const ok = flow({
|
|
76
|
-
name: "pay.ok",
|
|
74
|
+
const ok = flow("pay.ok", {
|
|
77
75
|
durable: true,
|
|
78
76
|
do: async (_input, fx) => {
|
|
79
77
|
await fx.step("a", () => "done");
|
|
@@ -103,8 +101,7 @@ describe("durable compensate", () => {
|
|
|
103
101
|
await runs.open();
|
|
104
102
|
const store = createMemoryJournalStore();
|
|
105
103
|
|
|
106
|
-
const boom = flow({
|
|
107
|
-
name: "pay.boom",
|
|
104
|
+
const boom = flow("pay.boom", {
|
|
108
105
|
durable: true,
|
|
109
106
|
do: async () => {
|
|
110
107
|
throw new Error("boom");
|