okengine 0.10.0 → 0.10.1
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/package.json +1 -1
- package/src/cli/dev-app-runner.ts +6 -0
- package/src/cli/dev.ts +26 -0
- package/src/cli/docker-cli.test.ts +15 -0
- package/src/cli/start.ts +12 -1
- package/src/compiler/effects-infer.ts +37 -8
- package/src/compiler/extract.test.ts +125 -0
- package/src/compiler/extract.ts +28 -3
- package/src/elements/store/cache.test.ts +28 -0
- package/src/kernel/app.ts +14 -0
- package/src/kernel/boot.ts +105 -7
- package/src/kernel/effects-stamping.test.ts +207 -0
- package/src/kernel/errors.ts +11 -0
- package/src/kernel/fx.ts +42 -13
- package/src/manifest/sql-resource.ts +41 -0
package/package.json
CHANGED
|
@@ -51,6 +51,12 @@ const consoleUrl = Bun.env["OKE_DEV_HERO_CONSOLE"];
|
|
|
51
51
|
const mcpUrl = Bun.env["OKE_DEV_HERO_MCP"];
|
|
52
52
|
const heroMeta = decodeHeroSnapshot(Bun.env["OKE_DEV_HERO_META"]);
|
|
53
53
|
|
|
54
|
+
// Signal to boot() where to lazily extract effects from when a flow has no
|
|
55
|
+
// hand-declared `effects` — kernel/boot.ts's mintCapabilities() reads this
|
|
56
|
+
// (explicit opt-in only; the kernel itself never defaults to cwd). `cwd` is
|
|
57
|
+
// already the project root here (Bun.spawn's `cwd` option in dev.ts).
|
|
58
|
+
process.env["OKE_ROOT_DIR"] ??= process.cwd();
|
|
59
|
+
|
|
54
60
|
const absoluteEntry = resolve(entry);
|
|
55
61
|
const mod = (await import(pathToFileURL(absoluteEntry).href)) as {
|
|
56
62
|
app?: BootableApp;
|
package/src/cli/dev.ts
CHANGED
|
@@ -233,6 +233,21 @@ export interface DevOptions {
|
|
|
233
233
|
readonly env: Record<string, string>;
|
|
234
234
|
readonly onUpdate?: (service: string, status: DevStatus) => void;
|
|
235
235
|
}) => Promise<Map<string, DevStatus>>;
|
|
236
|
+
/**
|
|
237
|
+
* Injectable `docker compose ps` runner for live health polls
|
|
238
|
+
* ({@link startComposeHealthWatch} / keyboard refresh). Default: real
|
|
239
|
+
* `docker`. When {@link DevOptions.composeHealth} is set and this is
|
|
240
|
+
* omitted, live polls return empty (unit tests never shell out).
|
|
241
|
+
*
|
|
242
|
+
* @param args - Args after `docker` (e.g. `compose -f … ps -a …`)
|
|
243
|
+
* @param cwd - Compose directory
|
|
244
|
+
* @param env - Stack env merged into the process env
|
|
245
|
+
*/
|
|
246
|
+
readonly composeHealthRun?: (
|
|
247
|
+
args: readonly string[],
|
|
248
|
+
cwd: string,
|
|
249
|
+
env: Record<string, string>,
|
|
250
|
+
) => Promise<string>;
|
|
236
251
|
/**
|
|
237
252
|
* Regenerate client types (injectable).
|
|
238
253
|
*
|
|
@@ -298,6 +313,14 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
298
313
|
/** Reassigned to {@link createAnchoredBoard} wrap after the status board paints. */
|
|
299
314
|
let write: (text: string) => void = options.write ?? ((t) => process.stdout.write(t));
|
|
300
315
|
const chromeWrite = (t: string) => write(formatCliChrome(t));
|
|
316
|
+
/**
|
|
317
|
+
* `docker compose ps` runner for live / default health polls.
|
|
318
|
+
* Explicit inject wins; otherwise a `composeHealth` inject implies tests —
|
|
319
|
+
* never shell out to a real `docker` binary.
|
|
320
|
+
*/
|
|
321
|
+
const composeHealthRun =
|
|
322
|
+
options.composeHealthRun ??
|
|
323
|
+
(options.composeHealth ? async (): Promise<string> => "[]" : undefined);
|
|
301
324
|
const previousWarn = console.warn;
|
|
302
325
|
console.warn = (...args: unknown[]) => {
|
|
303
326
|
const msg = args.map(String).join(" ");
|
|
@@ -656,6 +679,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
656
679
|
files: composeFiles,
|
|
657
680
|
cwd: dockerOut,
|
|
658
681
|
env: dockerStarted.env,
|
|
682
|
+
run: composeHealthRun,
|
|
659
683
|
timeoutMs: 20_000,
|
|
660
684
|
isDone: (map) => {
|
|
661
685
|
// Empty ps (compose just-created / injectable gap): do not spin 20s.
|
|
@@ -952,6 +976,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
952
976
|
files: started.files,
|
|
953
977
|
cwd: started.cwd,
|
|
954
978
|
env: started.env,
|
|
979
|
+
run: composeHealthRun,
|
|
955
980
|
intervalMs: 2_000,
|
|
956
981
|
onChange: (map) => {
|
|
957
982
|
liveComposeHealth = map;
|
|
@@ -1325,6 +1350,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
1325
1350
|
files: started.files,
|
|
1326
1351
|
cwd: started.cwd,
|
|
1327
1352
|
env: started.env,
|
|
1353
|
+
run: composeHealthRun,
|
|
1328
1354
|
});
|
|
1329
1355
|
if (liveComposeHealth.get("ai") === "error") heroAiStatus = "error";
|
|
1330
1356
|
repaintBoard();
|
|
@@ -207,6 +207,21 @@ describe("oke start", () => {
|
|
|
207
207
|
expect(code).toBe(0);
|
|
208
208
|
expect(ran).toBe(entry);
|
|
209
209
|
});
|
|
210
|
+
|
|
211
|
+
test("sets OKE_ROOT_DIR so boot() can lazily derive effects for undeclared flows", async () => {
|
|
212
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-cli-start-rootdir-"));
|
|
213
|
+
await Bun.write(join(dir, "src/app.ts"), "export {}\n");
|
|
214
|
+
let seenRootDir: string | undefined;
|
|
215
|
+
const code = await runStart({
|
|
216
|
+
cwd: dir,
|
|
217
|
+
runEntry: async (_e, env) => {
|
|
218
|
+
seenRootDir = env.OKE_ROOT_DIR;
|
|
219
|
+
},
|
|
220
|
+
write: () => {},
|
|
221
|
+
});
|
|
222
|
+
expect(code).toBe(0);
|
|
223
|
+
expect(seenRootDir).toBe(dir);
|
|
224
|
+
});
|
|
210
225
|
});
|
|
211
226
|
|
|
212
227
|
describe("oke vault", () => {
|
package/src/cli/start.ts
CHANGED
|
@@ -55,7 +55,18 @@ export async function runStart(options: StartOptions = {}): Promise<number> {
|
|
|
55
55
|
try {
|
|
56
56
|
const entry = await resolveStartEntry(cwd, options.entry);
|
|
57
57
|
const port = String(options.port ?? Number(Bun.env.PORT ?? APP_PORT));
|
|
58
|
-
const env = {
|
|
58
|
+
const env = {
|
|
59
|
+
...process.env,
|
|
60
|
+
NODE_ENV: "production",
|
|
61
|
+
PORT: port,
|
|
62
|
+
// Signal to boot() where to lazily extract effects from when a flow
|
|
63
|
+
// has no hand-declared `effects` — kernel/boot.ts's mintCapabilities()
|
|
64
|
+
// reads this (explicit opt-in only). docker/prod hard-fail (OKE1008)
|
|
65
|
+
// rather than silently open when neither this nor an explicit
|
|
66
|
+
// `manifest` resolves anything — this just gives that resolution a
|
|
67
|
+
// real chance to succeed instead of failing every time.
|
|
68
|
+
OKE_ROOT_DIR: process.env["OKE_ROOT_DIR"] ?? cwd,
|
|
69
|
+
} as Record<string, string>;
|
|
59
70
|
write(`oke start: ${entry} (port ${port})\n`);
|
|
60
71
|
if (options.runEntry) {
|
|
61
72
|
await options.runEntry(entry, env);
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
TemplateRef,
|
|
16
16
|
FlowRef,
|
|
17
17
|
} from "../manifest/types.ts";
|
|
18
|
+
import { sqlTableRef } from "../manifest/sql-resource.ts";
|
|
18
19
|
|
|
19
20
|
/** Minimal ESTree-shaped nodes produced by oxc-parser. */
|
|
20
21
|
export interface AstNode {
|
|
@@ -54,6 +55,7 @@ export interface InferBinding {
|
|
|
54
55
|
| "template"
|
|
55
56
|
| "flow"
|
|
56
57
|
| "embed"
|
|
58
|
+
| "table"
|
|
57
59
|
| "unknown";
|
|
58
60
|
/** Resolved resource / name. */
|
|
59
61
|
readonly ref: string;
|
|
@@ -103,9 +105,20 @@ const READ_METHODS = new Set([
|
|
|
103
105
|
"findMany",
|
|
104
106
|
"findFirst",
|
|
105
107
|
"find",
|
|
108
|
+
"list",
|
|
106
109
|
]);
|
|
107
110
|
|
|
108
|
-
const WRITE_METHODS = new Set([
|
|
111
|
+
const WRITE_METHODS = new Set([
|
|
112
|
+
"insert",
|
|
113
|
+
"set",
|
|
114
|
+
"delete",
|
|
115
|
+
"increment",
|
|
116
|
+
"update",
|
|
117
|
+
"upsert",
|
|
118
|
+
"log",
|
|
119
|
+
"put",
|
|
120
|
+
"putImage",
|
|
121
|
+
]);
|
|
109
122
|
|
|
110
123
|
/** Methods whose first argument is a table / collection identifier. */
|
|
111
124
|
const TABLE_ARG_METHODS = new Set([
|
|
@@ -203,7 +216,7 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
|
|
|
203
216
|
// Skip incomplete chains (`select` before `.from`, `insert` before table) —
|
|
204
217
|
// the sibling call that carries the table arg records the real resource.
|
|
205
218
|
const leaf = resolved.methods[resolved.methods.length - 1]!;
|
|
206
|
-
const hasTable = tableFromStoreChain(call) !== undefined;
|
|
219
|
+
const hasTable = tableFromStoreChain(call, options.bindings) !== undefined;
|
|
207
220
|
if (!hasTable && (leaf === "select" || leaf === "insert" || leaf === "update")) {
|
|
208
221
|
continue;
|
|
209
222
|
}
|
|
@@ -352,11 +365,14 @@ export function storeResourceFromCall(
|
|
|
352
365
|
const storeBinding = resolveBinding(storeArg, bindings);
|
|
353
366
|
const facet = storeBinding?.facet ?? "sql";
|
|
354
367
|
|
|
355
|
-
const table = tableFromStoreChain(call);
|
|
368
|
+
const table = tableFromStoreChain(call, bindings);
|
|
356
369
|
|
|
357
370
|
if (table) {
|
|
358
371
|
return {
|
|
359
|
-
|
|
372
|
+
// sql:<table> is the shared naming convention with the kernel's
|
|
373
|
+
// runtime capability gate (see ../manifest/sql-resource.ts) — table
|
|
374
|
+
// args only ever occur on sql-facet methods in practice.
|
|
375
|
+
resource: (facet === "sql" ? sqlTableRef(table) : `${facet}:${table}`) as ResourceRef,
|
|
360
376
|
methods: chain.methods,
|
|
361
377
|
};
|
|
362
378
|
}
|
|
@@ -382,11 +398,21 @@ export function storeResourceFromCall(
|
|
|
382
398
|
}
|
|
383
399
|
|
|
384
400
|
/**
|
|
385
|
-
* Walk an `fx.store(…).a().b(table)` chain and return the table
|
|
401
|
+
* Walk an `fx.store(…).a().b(table)` chain and return the declared table
|
|
402
|
+
* name — resolved through a registered `table` binding (the real string
|
|
403
|
+
* passed to `store.schema.table(name, …)`) when the argument is one, so a
|
|
404
|
+
* JS binding named differently from its declared table (`const notesTable
|
|
405
|
+
* = store.schema.table("notes", …)`) still resolves to `"notes"`, matching
|
|
406
|
+
* what the kernel reads off the live table object at call time. Falls back
|
|
407
|
+
* to the raw identifier text otherwise.
|
|
386
408
|
*
|
|
387
409
|
* @param call - Any call in the chain
|
|
410
|
+
* @param bindings - Scope bindings
|
|
388
411
|
*/
|
|
389
|
-
function tableFromStoreChain(
|
|
412
|
+
function tableFromStoreChain(
|
|
413
|
+
call: CallExpression,
|
|
414
|
+
bindings: ReadonlyMap<string, InferBinding>,
|
|
415
|
+
): string | undefined {
|
|
390
416
|
let current: AstNode | undefined = call;
|
|
391
417
|
while (current && current.type === "CallExpression") {
|
|
392
418
|
const c = current as CallExpression;
|
|
@@ -394,8 +420,11 @@ function tableFromStoreChain(call: CallExpression): string | undefined {
|
|
|
394
420
|
if (!link || link.rootMethod !== "store") break;
|
|
395
421
|
const leaf = link.methods[link.methods.length - 1]!;
|
|
396
422
|
if (TABLE_ARG_METHODS.has(leaf)) {
|
|
397
|
-
const
|
|
398
|
-
if (
|
|
423
|
+
const id = identifierName(c.arguments[0]);
|
|
424
|
+
if (id) {
|
|
425
|
+
const binding = bindings.get(id);
|
|
426
|
+
return binding?.kind === "table" ? binding.ref : id;
|
|
427
|
+
}
|
|
399
428
|
}
|
|
400
429
|
const callee = c.callee;
|
|
401
430
|
if (callee.type === "MemberExpression") {
|
|
@@ -344,3 +344,128 @@ export const remove = mounted.remove;
|
|
|
344
344
|
expect(manifest.flows?.list?.breaking).toBe(true);
|
|
345
345
|
});
|
|
346
346
|
});
|
|
347
|
+
|
|
348
|
+
describe("extractManifest — files-store write methods", () => {
|
|
349
|
+
test("fx.store(files).put(...) infers a write, unannotated", async () => {
|
|
350
|
+
const source = `
|
|
351
|
+
import { on, flow, http, gate, store } from "okengine";
|
|
352
|
+
|
|
353
|
+
export const files = store.files("uploads");
|
|
354
|
+
|
|
355
|
+
export const attach = on(
|
|
356
|
+
http.post("/attach").gate(gate.public),
|
|
357
|
+
flow({
|
|
358
|
+
name: "notes.attach",
|
|
359
|
+
unit: "notes",
|
|
360
|
+
do: async (input, fx) => {
|
|
361
|
+
await fx.store(files).put("key", input.text);
|
|
362
|
+
return { ok: true };
|
|
363
|
+
},
|
|
364
|
+
}),
|
|
365
|
+
);
|
|
366
|
+
`;
|
|
367
|
+
const manifest = await extractFromSources({ "src/flows/attach.ts": source });
|
|
368
|
+
|
|
369
|
+
expect(manifest.flows?.["notes.attach"]?.effects?.writes).toEqual(["files:uploads"]);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("fx.store(files).putImage(...) infers a write, unannotated", async () => {
|
|
373
|
+
const source = `
|
|
374
|
+
import { on, flow, http, gate, store } from "okengine";
|
|
375
|
+
|
|
376
|
+
export const files = store.files("uploads");
|
|
377
|
+
|
|
378
|
+
export const attach = on(
|
|
379
|
+
http.post("/attach").gate(gate.public),
|
|
380
|
+
flow({
|
|
381
|
+
name: "notes.attachImage",
|
|
382
|
+
unit: "notes",
|
|
383
|
+
do: async (input, fx) => {
|
|
384
|
+
await fx.store(files).putImage("key", input.bytes);
|
|
385
|
+
return { ok: true };
|
|
386
|
+
},
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
`;
|
|
390
|
+
const manifest = await extractFromSources({ "src/flows/attach.ts": source });
|
|
391
|
+
|
|
392
|
+
expect(manifest.flows?.["notes.attachImage"]?.effects?.writes).toEqual(["files:uploads"]);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("fx.store(files).list(...) infers a read, unannotated", async () => {
|
|
396
|
+
const source = `
|
|
397
|
+
import { on, flow, every, store } from "okengine";
|
|
398
|
+
|
|
399
|
+
export const files = store.files("uploads");
|
|
400
|
+
|
|
401
|
+
export const sweep = on(
|
|
402
|
+
every("1d"),
|
|
403
|
+
flow({
|
|
404
|
+
name: "notes.sweep",
|
|
405
|
+
unit: "notes",
|
|
406
|
+
do: async (_input, fx) => {
|
|
407
|
+
const keys = await fx.store(files).list();
|
|
408
|
+
return { count: keys.length };
|
|
409
|
+
},
|
|
410
|
+
}),
|
|
411
|
+
);
|
|
412
|
+
`;
|
|
413
|
+
const manifest = await extractFromSources({ "src/flows/sweep.ts": source });
|
|
414
|
+
|
|
415
|
+
expect(manifest.flows?.["notes.sweep"]?.effects?.reads).toEqual(["files:uploads"]);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe("extractManifest — channel medium binder aliasing", () => {
|
|
420
|
+
test("mail.template(...) resolves through `const mail = channel.email(...)`", async () => {
|
|
421
|
+
const source = `
|
|
422
|
+
import { on, flow, http, gate, channel } from "okengine";
|
|
423
|
+
|
|
424
|
+
const mail = channel.email({ from: "Notes <notes@localhost>" });
|
|
425
|
+
|
|
426
|
+
export const noteCreatedMail = mail.template("note-created", {
|
|
427
|
+
locales: ["en", "ar"],
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
export const onCreated = on(
|
|
431
|
+
http.post("/hook").gate(gate.public),
|
|
432
|
+
flow({
|
|
433
|
+
name: "notes.onCreated",
|
|
434
|
+
unit: "notes",
|
|
435
|
+
do: async (payload, fx) => {
|
|
436
|
+
await fx.send(noteCreatedMail, { to: "you@localhost", data: payload });
|
|
437
|
+
},
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
`;
|
|
441
|
+
const manifest = await extractFromSources({ "src/flows/notes.ts": source });
|
|
442
|
+
|
|
443
|
+
expect(manifest.flows?.["notes.onCreated"]?.effects?.sends).toEqual(["note-created"]);
|
|
444
|
+
expect(manifest.channels?.["note-created"]?.medium).toBe("email");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("sms medium binder alias carries its medium (not defaulted to email)", async () => {
|
|
448
|
+
const source = `
|
|
449
|
+
import { on, flow, http, gate, channel } from "okengine";
|
|
450
|
+
|
|
451
|
+
const otp = channel.sms({});
|
|
452
|
+
|
|
453
|
+
export const otpTemplate = otp.template("otp-code", {});
|
|
454
|
+
|
|
455
|
+
export const send = on(
|
|
456
|
+
http.post("/otp").gate(gate.public),
|
|
457
|
+
flow({
|
|
458
|
+
name: "notes.sendOtp",
|
|
459
|
+
unit: "notes",
|
|
460
|
+
do: async (payload, fx) => {
|
|
461
|
+
await fx.send(otpTemplate, { to: "+10000000000", data: payload });
|
|
462
|
+
},
|
|
463
|
+
}),
|
|
464
|
+
);
|
|
465
|
+
`;
|
|
466
|
+
const manifest = await extractFromSources({ "src/flows/otp.ts": source });
|
|
467
|
+
|
|
468
|
+
expect(manifest.flows?.["notes.sendOtp"]?.effects?.sends).toEqual(["otp-code"]);
|
|
469
|
+
expect(manifest.channels?.["otp-code"]?.medium).toBe("sms");
|
|
470
|
+
});
|
|
471
|
+
});
|
package/src/compiler/extract.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
AiModel,
|
|
15
15
|
AiPrompt,
|
|
16
16
|
Channel,
|
|
17
|
+
ChannelMedium,
|
|
17
18
|
Clock,
|
|
18
19
|
DeclaredColumn,
|
|
19
20
|
Effects,
|
|
@@ -94,6 +95,8 @@ interface ProjectScope {
|
|
|
94
95
|
string,
|
|
95
96
|
{ storeName: string; storeRef: string; unit?: string; breaking?: boolean }
|
|
96
97
|
>;
|
|
98
|
+
/** Local binding name → medium, for `const x = channel.email(…)` binders. */
|
|
99
|
+
channelMediumBindings: Map<string, ChannelMedium>;
|
|
97
100
|
}
|
|
98
101
|
|
|
99
102
|
/**
|
|
@@ -123,6 +126,7 @@ export async function extractManifest(options: ExtractManifestOptions = {}): Pro
|
|
|
123
126
|
flows: {},
|
|
124
127
|
flowExports: new Map(),
|
|
125
128
|
resources: new Map(),
|
|
129
|
+
channelMediumBindings: new Map(),
|
|
126
130
|
};
|
|
127
131
|
|
|
128
132
|
const parsed = files.map((file) => {
|
|
@@ -329,7 +333,14 @@ function collectSchemaTable(call: CallExpression, program: AstNode, scope: Proje
|
|
|
329
333
|
const entry = { name: tableName, columns };
|
|
330
334
|
scope.schemaTables.set(tableName, entry);
|
|
331
335
|
const bindingName = enclosingConstName(call, program);
|
|
332
|
-
if (bindingName)
|
|
336
|
+
if (bindingName) {
|
|
337
|
+
scope.schemaTables.set(bindingName, entry);
|
|
338
|
+
// Declared table name, not the JS binding — resolved by
|
|
339
|
+
// tableFromStoreChain so `const notesTable = store.schema.table("notes",
|
|
340
|
+
// …)` still infers "sql:notes", matching what the kernel reads off the
|
|
341
|
+
// live table object (see ../manifest/sql-resource.ts).
|
|
342
|
+
scope.bindings.set(bindingName, { kind: "table", ref: tableName });
|
|
343
|
+
}
|
|
333
344
|
}
|
|
334
345
|
|
|
335
346
|
function attachSchemaOption(
|
|
@@ -584,6 +595,8 @@ function camelToSnakeKey(key: string): string {
|
|
|
584
595
|
.toLowerCase();
|
|
585
596
|
}
|
|
586
597
|
|
|
598
|
+
const CHANNEL_MEDIUM_METHODS = new Set(["email", "sms", "whatsapp", "push"]);
|
|
599
|
+
|
|
587
600
|
function visitDeclarationCall(call: CallExpression, program: AstNode, scope: ProjectScope): void {
|
|
588
601
|
const callee = call.callee;
|
|
589
602
|
|
|
@@ -718,7 +731,17 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
|
|
|
718
731
|
}
|
|
719
732
|
}
|
|
720
733
|
|
|
721
|
-
|
|
734
|
+
// channel.email(…) / .sms(…) / .whatsapp(…) / .push(…) — medium binder,
|
|
735
|
+
// usually held in a local const and called later as `mail.template(…)`.
|
|
736
|
+
if (obj === "channel" && prop && CHANNEL_MEDIUM_METHODS.has(prop)) {
|
|
737
|
+
const bindingName = enclosingConstName(call, program);
|
|
738
|
+
if (bindingName) {
|
|
739
|
+
scope.channelMediumBindings.set(bindingName, prop as ChannelMedium);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const boundMedium = obj ? scope.channelMediumBindings.get(obj) : undefined;
|
|
744
|
+
if ((obj === "channel" && prop === "template") || (prop === "template" && boundMedium)) {
|
|
722
745
|
const templateName = stringArg(call.arguments[0]);
|
|
723
746
|
const opts = objectArg(call.arguments[1]);
|
|
724
747
|
if (templateName) {
|
|
@@ -726,7 +749,9 @@ function visitDeclarationCall(call: CallExpression, program: AstNode, scope: Pro
|
|
|
726
749
|
const locales = stringArrayProp(opts, "locales");
|
|
727
750
|
const description = stringProp(opts, "description");
|
|
728
751
|
scope.channels[templateName] = {
|
|
729
|
-
...(medium
|
|
752
|
+
...(medium
|
|
753
|
+
? { medium: medium as Channel["medium"] }
|
|
754
|
+
: { medium: boundMedium ?? "email" }),
|
|
730
755
|
...(locales ? { locales } : {}),
|
|
731
756
|
...(description ? { description } : {}),
|
|
732
757
|
};
|
|
@@ -43,4 +43,32 @@ describe("tier-1 cache — exact per-resource invalidation (path b)", () => {
|
|
|
43
43
|
expect(cache.invalidateFromEffects({ writes: ["sql:links"] }).keys).toEqual(keys);
|
|
44
44
|
expect(cache.get(keys[0]!)).toBeUndefined();
|
|
45
45
|
});
|
|
46
|
+
|
|
47
|
+
test("write to table A does not invalidate a cached read of table B in the same sql store", () => {
|
|
48
|
+
// Two tables under one `store.sql("app", …)` — the exact shape Direction
|
|
49
|
+
// B's per-table kernel resolution (fx.ts `gatedTable`) now produces:
|
|
50
|
+
// `sql:notes` / `sql:orders`, not the old coarse `sql:app` for both.
|
|
51
|
+
// This precision was already correct in cache.ts before Direction B —
|
|
52
|
+
// it simply never received per-table refs to prove it with. Confirmed
|
|
53
|
+
// here with the exact naming the kernel now emits.
|
|
54
|
+
const cache = createStoreCache();
|
|
55
|
+
const notesRead: Effects = { reads: ["sql:notes"] };
|
|
56
|
+
const keys = tier1KeysForReads(notesRead);
|
|
57
|
+
|
|
58
|
+
cache.set({
|
|
59
|
+
tier: 1,
|
|
60
|
+
key: keys[0]!,
|
|
61
|
+
value: [{ id: "n1" }],
|
|
62
|
+
resources: ["sql:notes"],
|
|
63
|
+
expiresAt: null,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// A write to "orders" — a different table, same store — must not touch it.
|
|
67
|
+
expect(cache.invalidateFromEffects({ writes: ["sql:orders"] }).keys).toEqual([]);
|
|
68
|
+
expect(cache.get(keys[0]!)).toBeDefined();
|
|
69
|
+
|
|
70
|
+
// A write to "notes" itself still invalidates.
|
|
71
|
+
expect(cache.invalidateFromEffects({ writes: ["sql:notes"] }).keys).toEqual(keys);
|
|
72
|
+
expect(cache.get(keys[0]!)).toBeUndefined();
|
|
73
|
+
});
|
|
46
74
|
});
|
package/src/kernel/app.ts
CHANGED
|
@@ -162,6 +162,18 @@ export interface OkeOptions {
|
|
|
162
162
|
readonly docker?: BootOptions["docker"];
|
|
163
163
|
/** Optional `oke.config.ts` document consumed at boot. */
|
|
164
164
|
readonly config?: BootOptions["config"];
|
|
165
|
+
/**
|
|
166
|
+
* Compiled Manifest for flows with no hand-declared `effects` — highest
|
|
167
|
+
* priority source for the boot-time capability stamp (see
|
|
168
|
+
* {@link BootOptions.manifest}).
|
|
169
|
+
*/
|
|
170
|
+
readonly manifest?: BootOptions["manifest"];
|
|
171
|
+
/**
|
|
172
|
+
* Project root for a lazy, best-effort effects extraction when a flow has
|
|
173
|
+
* no hand-declared `effects` and no {@link manifest} was given (see
|
|
174
|
+
* {@link BootOptions.rootDir}).
|
|
175
|
+
*/
|
|
176
|
+
readonly rootDir?: BootOptions["rootDir"];
|
|
165
177
|
/** Pre-built element runtimes (skip construction at boot when present). */
|
|
166
178
|
readonly elements?: BootOptions["elements"];
|
|
167
179
|
/** Vault create options (chain / allowDevFallbacks / secrets). */
|
|
@@ -827,6 +839,8 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
827
839
|
env: bootEnv,
|
|
828
840
|
docker: overrides?.docker ?? options.docker,
|
|
829
841
|
config: overrides?.config ?? options.config,
|
|
842
|
+
manifest: overrides?.manifest ?? options.manifest,
|
|
843
|
+
rootDir: overrides?.rootDir ?? options.rootDir,
|
|
830
844
|
elements: overrides?.elements ?? options.elements,
|
|
831
845
|
secrets: [...baseSecrets, ...pluginSecrets],
|
|
832
846
|
vault: overrides?.vault ?? options.vault,
|
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
|
*
|
|
@@ -457,8 +478,11 @@ 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
|
+
});
|
|
462
486
|
|
|
463
487
|
return {
|
|
464
488
|
vault,
|
|
@@ -491,16 +515,90 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
|
|
|
491
515
|
};
|
|
492
516
|
}
|
|
493
517
|
|
|
518
|
+
/** Per-process guard so the "no effects" boot warning fires once, not per-flow. */
|
|
519
|
+
let noEffectsWarned = false;
|
|
520
|
+
|
|
521
|
+
/** Reset the once-per-process "no effects" warn latch (tests only). */
|
|
522
|
+
export function resetNoEffectsWarnForTests(): void {
|
|
523
|
+
noEffectsWarned = false;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Best-effort AoT extraction from `rootDir` — mirrors the CLI's own lazy,
|
|
528
|
+
* defensive `extractManifest` use (`oke dev`'s `tryLoadProjectManifest`).
|
|
529
|
+
* Never throws: a broken or mid-edit source tree just means "no Manifest
|
|
530
|
+
* available," handled by the caller like any other missing Manifest.
|
|
531
|
+
*
|
|
532
|
+
* `new URL` (not a literal specifier) so `oxc-parser` / the whole compiler
|
|
533
|
+
* never enters the bundler graph for apps that never hit this path — same
|
|
534
|
+
* trick as {@link loadBind} for the element binders.
|
|
535
|
+
*
|
|
536
|
+
* @param rootDir - Project root to extract from
|
|
537
|
+
*/
|
|
538
|
+
async function tryAutoExtractManifest(rootDir: string): Promise<Manifest | undefined> {
|
|
539
|
+
try {
|
|
540
|
+
const url = new URL("../compiler/extract.ts", import.meta.url);
|
|
541
|
+
const { extractManifest } = (await import(url.href)) as typeof import("../compiler/extract.ts");
|
|
542
|
+
return await extractManifest({ rootDir });
|
|
543
|
+
} catch {
|
|
544
|
+
return undefined;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
494
548
|
/**
|
|
495
|
-
* Mint capability tokens from each flow's declared effects
|
|
496
|
-
*
|
|
549
|
+
* Mint capability tokens from each flow's declared effects, falling back to
|
|
550
|
+
* a Manifest-derived stamp when a flow declares no `effects` of its own —
|
|
551
|
+
* an explicit {@link BootOptions.manifest}, or a lazy AoT extract from
|
|
552
|
+
* {@link BootOptions.rootDir} / `OKE_ROOT_DIR`.
|
|
553
|
+
*
|
|
554
|
+
* When neither is available: `local` / `test` stay open (today's dev-loop
|
|
555
|
+
* behavior, unbroken) with a once-per-process `oke boot:` warning; `docker`
|
|
556
|
+
* / `prod` fail loud (`OKE1008`) — docker mirrors prod's posture, never a
|
|
557
|
+
* silent open door in a deploy-shaped environment.
|
|
497
558
|
*
|
|
498
559
|
* @param flows - Adopted flows
|
|
560
|
+
* @param env - Resolved {@link ConfigEnv}
|
|
561
|
+
* @param options - Manifest / rootDir sources for the fallback stamp
|
|
499
562
|
*/
|
|
500
|
-
export function mintCapabilities(
|
|
563
|
+
export async function mintCapabilities(
|
|
564
|
+
flows: readonly AnyFlowDef[],
|
|
565
|
+
env: ConfigEnv = "local",
|
|
566
|
+
options: { readonly manifest?: Manifest; readonly rootDir?: string } = {},
|
|
567
|
+
): Promise<Map<string, CapabilityToken>> {
|
|
501
568
|
const map = new Map<string, CapabilityToken>();
|
|
569
|
+
|
|
570
|
+
let manifest = options.manifest;
|
|
571
|
+
const needsManifest = manifest === undefined && flows.some((f) => f.effects === undefined);
|
|
572
|
+
if (needsManifest) {
|
|
573
|
+
const rootDir = options.rootDir ?? process.env["OKE_ROOT_DIR"];
|
|
574
|
+
if (rootDir) manifest = await tryAutoExtractManifest(rootDir);
|
|
575
|
+
}
|
|
576
|
+
|
|
502
577
|
for (const f of flows) {
|
|
503
|
-
|
|
578
|
+
if (f.effects !== undefined) {
|
|
579
|
+
map.set(f.name, createCapabilityToken(f.name, f.effects));
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const stamped = manifest?.flows?.[f.name]?.effects;
|
|
584
|
+
if (stamped !== undefined) {
|
|
585
|
+
map.set(f.name, createCapabilityToken(f.name, stamped));
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (env === "docker" || env === "prod") {
|
|
590
|
+
throwOke("NO_EFFECTS_DECLARED", { flow: f.name });
|
|
591
|
+
}
|
|
592
|
+
if (!noEffectsWarned) {
|
|
593
|
+
noEffectsWarned = true;
|
|
594
|
+
emitBootWarn(
|
|
595
|
+
`oke boot: flow "${f.name}" (and possibly others) has no declared effects and no ` +
|
|
596
|
+
"Manifest-derived effects — running with an OPEN capability token (every access " +
|
|
597
|
+
"allowed, ledgered but not gated). Run `oke build`, or boot with `manifest` / " +
|
|
598
|
+
"`rootDir`, before deploying — docker/prod refuse to boot this way.",
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
map.set(f.name, createCapabilityToken(f.name, undefined));
|
|
504
602
|
}
|
|
505
603
|
return map;
|
|
506
604
|
}
|
|
@@ -0,0 +1,207 @@
|
|
|
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({...})` 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({
|
|
52
|
+
name: "notes.create",
|
|
53
|
+
in: CreateIn,
|
|
54
|
+
out: CreateOut,
|
|
55
|
+
// Deliberately no `effects` — the "let the compiler infer it" case.
|
|
56
|
+
do: async (input, fx) => {
|
|
57
|
+
await fx.store(db).insert(notes).values({ id: input.id, title: input.title, createdAt: 1 });
|
|
58
|
+
return { ok: true };
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("boot-level: undeclared-effects flow, no manifest / rootDir", () => {
|
|
65
|
+
test("local / test: open token — real insert succeeds (documented dev-loop fallback)", async () => {
|
|
66
|
+
resetBindings();
|
|
67
|
+
resetFlowSeq();
|
|
68
|
+
const db = store.sql("app", { schema: { notes } });
|
|
69
|
+
// `on(...)` (inside buildUnannotatedCreateFlow) must run BEFORE `oke(...)`
|
|
70
|
+
// is constructed — oke() synchronously drains the global on() registry
|
|
71
|
+
// at construction time; a binding created only as .adopt()'s argument
|
|
72
|
+
// (after oke() already ran) never gets wired to a route.
|
|
73
|
+
const create = buildUnannotatedCreateFlow(db);
|
|
74
|
+
const app = oke({ name: "stamp-open", gate: { policies: [gate.public] } }).adopt({ create });
|
|
75
|
+
Object.assign(app.$options, { stores: [db] });
|
|
76
|
+
await createTestApp(app); // createTestApp always boots env: "test"
|
|
77
|
+
|
|
78
|
+
const res = await app.fetch(
|
|
79
|
+
new Request("http://localhost/notes", {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "content-type": "application/json" },
|
|
82
|
+
body: JSON.stringify({ id: "x1", title: "hi" }),
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
expect(res.status).toBe(200);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("docker: hard fail at boot — OKE1008, never an open token in a deploy-shaped env", async () => {
|
|
89
|
+
resetBindings();
|
|
90
|
+
resetFlowSeq();
|
|
91
|
+
const db = store.sql("app", { schema: { notes } });
|
|
92
|
+
const create = buildUnannotatedCreateFlow(db);
|
|
93
|
+
const app = oke({ name: "stamp-docker", gate: { policies: [gate.public] } }).adopt({ create });
|
|
94
|
+
|
|
95
|
+
await expect(
|
|
96
|
+
app.boot({ env: "docker", stores: [db], unguardedHttp: "allow", startScheduler: false }),
|
|
97
|
+
).rejects.toThrow(/OKE1008/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("prod: hard fail at boot — same posture as docker", async () => {
|
|
101
|
+
resetBindings();
|
|
102
|
+
resetFlowSeq();
|
|
103
|
+
const db = store.sql("app", { schema: { notes } });
|
|
104
|
+
const create = buildUnannotatedCreateFlow(db);
|
|
105
|
+
const app = oke({ name: "stamp-prod", gate: { policies: [gate.public] } }).adopt({ create });
|
|
106
|
+
|
|
107
|
+
await expect(
|
|
108
|
+
app.boot({ env: "prod", stores: [db], unguardedHttp: "allow", startScheduler: false }),
|
|
109
|
+
).rejects.toThrow(/OKE1008/);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("boot-level: table-ref resolution stays backward compatible", () => {
|
|
114
|
+
test("older store-level effects (sql:<store>) still cover a schema-table op — every existing template's convention", async () => {
|
|
115
|
+
resetBindings();
|
|
116
|
+
resetFlowSeq();
|
|
117
|
+
const db = store.sql("app", { schema: { notes } });
|
|
118
|
+
const create = on(
|
|
119
|
+
http.post("/notes").gate(gate.public),
|
|
120
|
+
flow({
|
|
121
|
+
name: "notes.create",
|
|
122
|
+
in: CreateIn,
|
|
123
|
+
out: CreateOut,
|
|
124
|
+
// The convention every template/test predates Direction B with —
|
|
125
|
+
// store-level, not table-level. Must keep working unchanged.
|
|
126
|
+
effects: { writes: ["sql:app"] },
|
|
127
|
+
do: async (input, fx) => {
|
|
128
|
+
await fx
|
|
129
|
+
.store(db)
|
|
130
|
+
.insert(notes)
|
|
131
|
+
.values({ id: input.id, title: input.title, createdAt: 1 });
|
|
132
|
+
return { ok: true };
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
const app = oke({ name: "stamp-back-compat", gate: { policies: [gate.public] } }).adopt({
|
|
137
|
+
create,
|
|
138
|
+
});
|
|
139
|
+
Object.assign(app.$options, { stores: [db] });
|
|
140
|
+
await createTestApp(app);
|
|
141
|
+
|
|
142
|
+
const res = await app.fetch(
|
|
143
|
+
new Request("http://localhost/notes", {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { "content-type": "application/json" },
|
|
146
|
+
body: JSON.stringify({ id: "x1", title: "hi" }),
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
expect(res.status).toBe(200);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("boot-level: undeclared-effects flow, rootDir stamping from a real source tree", () => {
|
|
154
|
+
test("real fx.store(db).insert(table) write succeeds once inference is stamped onto the capability token", async () => {
|
|
155
|
+
resetBindings();
|
|
156
|
+
resetFlowSeq();
|
|
157
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-stamp-"));
|
|
158
|
+
try {
|
|
159
|
+
await mkdir(join(dir, "flows", "notes"), { recursive: true });
|
|
160
|
+
// A real source file with NO manual `effects:` — extractManifest infers
|
|
161
|
+
// `writes: ["sql:notes"]` for this from the real `fx.store(db).insert`
|
|
162
|
+
// call, exactly like the kernel test flow above.
|
|
163
|
+
await writeFile(
|
|
164
|
+
join(dir, "flows", "notes", "index.ts"),
|
|
165
|
+
`
|
|
166
|
+
import { on, flow, http, gate } from "okengine";
|
|
167
|
+
// Unresolved bindings are fine — extraction is AST-only, never executed.
|
|
168
|
+
// What matters is the literal shape: fx.store(db).insert(notes) so the
|
|
169
|
+
// same table-name inference that produced "sql:notes" earlier fires here.
|
|
170
|
+
const db = {} as any;
|
|
171
|
+
const notes = {} as any;
|
|
172
|
+
export const create = on(
|
|
173
|
+
http.post("/notes").gate(gate.public),
|
|
174
|
+
flow({
|
|
175
|
+
name: "notes.create",
|
|
176
|
+
do: async (input, fx) => {
|
|
177
|
+
await fx.store(db).insert(notes).values({ id: input.id, title: input.title, createdAt: 1 });
|
|
178
|
+
return { ok: true };
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
`,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const db = store.sql("app", { schema: { notes } });
|
|
186
|
+
const create = buildUnannotatedCreateFlow(db);
|
|
187
|
+
const app = oke({ name: "stamp-rootdir", gate: { policies: [gate.public] } }).adopt({
|
|
188
|
+
create,
|
|
189
|
+
});
|
|
190
|
+
Object.assign(app.$options, { stores: [db] });
|
|
191
|
+
await createTestApp(app, { boot: { rootDir: dir } });
|
|
192
|
+
|
|
193
|
+
const res = await app.fetch(
|
|
194
|
+
new Request("http://localhost/notes", {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: { "content-type": "application/json" },
|
|
197
|
+
body: JSON.stringify({ id: "x1", title: "hi" }),
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
const body = (await res.json()) as { data: unknown; error: { message?: string } | null };
|
|
201
|
+
expect(res.status, body.error?.message ?? "").toBe(200);
|
|
202
|
+
expect(body.data).toEqual({ ok: true });
|
|
203
|
+
} finally {
|
|
204
|
+
await rm(dir, { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
});
|
package/src/kernel/errors.ts
CHANGED
|
@@ -142,6 +142,17 @@ 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
|
+
},
|
|
145
156
|
/**
|
|
146
157
|
* Emit target has no subscriber (unified-theory §21).
|
|
147
158
|
* Thrown at emit when `optional` is false and nobody is subscribed.
|
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);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL resource-ref naming — the one place both the compiler's AST-based
|
|
3
|
+
* effect inference ({@link "../compiler/effects-infer.ts"}) and the
|
|
4
|
+
* kernel's capability gate ({@link "../kernel/fx.ts"} `gatedSqlHandle`)
|
|
5
|
+
* compute a per-table `sql:<table>` ref, so the two cannot silently
|
|
6
|
+
* drift apart again.
|
|
7
|
+
*
|
|
8
|
+
* Table-name *resolution* differs by nature — the compiler reads a static
|
|
9
|
+
* AST identifier (or, better, the declared name behind a
|
|
10
|
+
* `store.schema.table(name, …)` binding); the kernel reads the declared
|
|
11
|
+
* name straight off the live table object at call time. Only the
|
|
12
|
+
* naming/formatting step is shared here; that is the part a second,
|
|
13
|
+
* independently-written implementation could get wrong.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ResourceRef } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The declared name a real `store.schema.table(name, columns)` object
|
|
20
|
+
* carries at runtime — the same string that was the first argument to
|
|
21
|
+
* `store.schema.table(...)` in source, regardless of what the JS binding
|
|
22
|
+
* is called. `undefined` for anything else (raw ORM tables, plain
|
|
23
|
+
* objects, missing/optional table arguments).
|
|
24
|
+
*
|
|
25
|
+
* @param value - Value passed where a table argument is expected
|
|
26
|
+
*/
|
|
27
|
+
export function schemaTableName(value: unknown): string | undefined {
|
|
28
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
29
|
+
const v = value as { kind?: unknown; name?: unknown };
|
|
30
|
+
if (v.kind !== "schema-table") return undefined;
|
|
31
|
+
return typeof v.name === "string" ? v.name : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `sql:<table>` resource ref for a declared table name.
|
|
36
|
+
*
|
|
37
|
+
* @param table - Declared table name
|
|
38
|
+
*/
|
|
39
|
+
export function sqlTableRef(table: string): ResourceRef {
|
|
40
|
+
return `sql:${table}` as ResourceRef;
|
|
41
|
+
}
|