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
|
@@ -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") {
|
|
@@ -123,8 +123,7 @@ describe("extractManifest — performance", () => {
|
|
|
123
123
|
parts.push(`
|
|
124
124
|
export const flow_${i} = on(
|
|
125
125
|
http.get("/f/${i}"),
|
|
126
|
-
flow({
|
|
127
|
-
name: "synth.flow_${i}",
|
|
126
|
+
flow("synth.flow_${i}", {
|
|
128
127
|
do: async (input, fx) => {
|
|
129
128
|
await fx.store(db).insert(items).values(input);
|
|
130
129
|
const rows = await fx.store(db).select().from(items);
|
|
@@ -166,8 +165,7 @@ export const daily = { name: "daily" };
|
|
|
166
165
|
|
|
167
166
|
export const listLinks = on(
|
|
168
167
|
http.get("/links"),
|
|
169
|
-
flow({
|
|
170
|
-
name: "rel.root",
|
|
168
|
+
flow("rel.root", {
|
|
171
169
|
do: async (_input, fx) => {
|
|
172
170
|
return fx.store(db).select().from(links);
|
|
173
171
|
},
|
|
@@ -178,8 +176,7 @@ export const listLinks = on(
|
|
|
178
176
|
// relations, so the related table must NOT appear in effects.
|
|
179
177
|
export const withDaily = on(
|
|
180
178
|
http.get("/links/with-daily"),
|
|
181
|
-
flow({
|
|
182
|
-
name: "rel.with",
|
|
179
|
+
flow("rel.with", {
|
|
183
180
|
do: async (_input, fx) => {
|
|
184
181
|
return fx.store(db).findMany({ with: { daily: true } });
|
|
185
182
|
},
|
|
@@ -344,3 +341,186 @@ export const remove = mounted.remove;
|
|
|
344
341
|
expect(manifest.flows?.list?.breaking).toBe(true);
|
|
345
342
|
});
|
|
346
343
|
});
|
|
344
|
+
|
|
345
|
+
describe("extractManifest — files-store write methods", () => {
|
|
346
|
+
test("fx.store(files).put(...) infers a write, unannotated", async () => {
|
|
347
|
+
const source = `
|
|
348
|
+
import { on, flow, http, gate, store } from "okengine";
|
|
349
|
+
|
|
350
|
+
export const files = store.files("uploads");
|
|
351
|
+
|
|
352
|
+
export const attach = on(
|
|
353
|
+
http.post("/attach").gate(gate.public),
|
|
354
|
+
flow("notes.attach", {
|
|
355
|
+
do: async (input, fx) => {
|
|
356
|
+
await fx.store(files).put("key", input.text);
|
|
357
|
+
return { ok: true };
|
|
358
|
+
},
|
|
359
|
+
}),
|
|
360
|
+
);
|
|
361
|
+
`;
|
|
362
|
+
const manifest = await extractFromSources({ "src/flows/attach.ts": source });
|
|
363
|
+
|
|
364
|
+
expect(manifest.flows?.["notes.attach"]?.effects?.writes).toEqual(["files:uploads"]);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("fx.store(files).putImage(...) infers a write, unannotated", async () => {
|
|
368
|
+
const source = `
|
|
369
|
+
import { on, flow, http, gate, store } from "okengine";
|
|
370
|
+
|
|
371
|
+
export const files = store.files("uploads");
|
|
372
|
+
|
|
373
|
+
export const attach = on(
|
|
374
|
+
http.post("/attach").gate(gate.public),
|
|
375
|
+
flow("notes.attachImage", {
|
|
376
|
+
do: async (input, fx) => {
|
|
377
|
+
await fx.store(files).putImage("key", input.bytes);
|
|
378
|
+
return { ok: true };
|
|
379
|
+
},
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
`;
|
|
383
|
+
const manifest = await extractFromSources({ "src/flows/attach.ts": source });
|
|
384
|
+
|
|
385
|
+
expect(manifest.flows?.["notes.attachImage"]?.effects?.writes).toEqual(["files:uploads"]);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("fx.store(files).list(...) infers a read, unannotated", async () => {
|
|
389
|
+
const source = `
|
|
390
|
+
import { on, flow, every, store } from "okengine";
|
|
391
|
+
|
|
392
|
+
export const files = store.files("uploads");
|
|
393
|
+
|
|
394
|
+
export const sweep = on(
|
|
395
|
+
every("1d"),
|
|
396
|
+
flow("notes.sweep", {
|
|
397
|
+
do: async (_input, fx) => {
|
|
398
|
+
const keys = await fx.store(files).list();
|
|
399
|
+
return { count: keys.length };
|
|
400
|
+
},
|
|
401
|
+
}),
|
|
402
|
+
);
|
|
403
|
+
`;
|
|
404
|
+
const manifest = await extractFromSources({ "src/flows/sweep.ts": source });
|
|
405
|
+
|
|
406
|
+
expect(manifest.flows?.["notes.sweep"]?.effects?.reads).toEqual(["files:uploads"]);
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
describe("extractManifest — channel medium binder aliasing", () => {
|
|
411
|
+
test("mail.template(...) resolves through `const mail = channel.email(...)`", async () => {
|
|
412
|
+
const source = `
|
|
413
|
+
import { on, flow, http, gate, channel } from "okengine";
|
|
414
|
+
|
|
415
|
+
const mail = channel.email({ from: "Notes <notes@localhost>" });
|
|
416
|
+
|
|
417
|
+
export const noteCreatedMail = mail.template("note-created", {
|
|
418
|
+
locales: ["en", "ar"],
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
export const onCreated = on(
|
|
422
|
+
http.post("/hook").gate(gate.public),
|
|
423
|
+
flow("notes.onCreated", {
|
|
424
|
+
do: async (payload, fx) => {
|
|
425
|
+
await fx.send(noteCreatedMail, { to: "you@localhost", data: payload });
|
|
426
|
+
},
|
|
427
|
+
}),
|
|
428
|
+
);
|
|
429
|
+
`;
|
|
430
|
+
const manifest = await extractFromSources({ "src/flows/notes.ts": source });
|
|
431
|
+
|
|
432
|
+
expect(manifest.flows?.["notes.onCreated"]?.effects?.sends).toEqual(["note-created"]);
|
|
433
|
+
expect(manifest.channels?.["note-created"]?.medium).toBe("email");
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("sms medium binder alias carries its medium (not defaulted to email)", async () => {
|
|
437
|
+
const source = `
|
|
438
|
+
import { on, flow, http, gate, channel } from "okengine";
|
|
439
|
+
|
|
440
|
+
const otp = channel.sms({});
|
|
441
|
+
|
|
442
|
+
export const otpTemplate = otp.template("otp-code", {});
|
|
443
|
+
|
|
444
|
+
export const send = on(
|
|
445
|
+
http.post("/otp").gate(gate.public),
|
|
446
|
+
flow("notes.sendOtp", {
|
|
447
|
+
do: async (payload, fx) => {
|
|
448
|
+
await fx.send(otpTemplate, { to: "+10000000000", data: payload });
|
|
449
|
+
},
|
|
450
|
+
}),
|
|
451
|
+
);
|
|
452
|
+
`;
|
|
453
|
+
const manifest = await extractFromSources({ "src/flows/otp.ts": source });
|
|
454
|
+
|
|
455
|
+
expect(manifest.flows?.["notes.sendOtp"]?.effects?.sends).toEqual(["otp-code"]);
|
|
456
|
+
expect(manifest.channels?.["otp-code"]?.medium).toBe("sms");
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
describe("extractManifest — flow(name, options) positional signature", () => {
|
|
461
|
+
test("bare flow(name, {...}) extracts name, in, out, effects", async () => {
|
|
462
|
+
const source = `
|
|
463
|
+
import { flow } from "okengine";
|
|
464
|
+
|
|
465
|
+
export const chargeOrder = flow("checkout.chargeOrder", {
|
|
466
|
+
in: ChargeIn,
|
|
467
|
+
out: ChargeOut,
|
|
468
|
+
effects: { reads: ["sql:orders"], writes: ["sql:orders"] },
|
|
469
|
+
do: async (input, fx) => {
|
|
470
|
+
return { ok: true };
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
`;
|
|
474
|
+
const manifest = await extractFromSources({ "src/flows/checkout.ts": source });
|
|
475
|
+
|
|
476
|
+
expect(manifest.flows?.["checkout.chargeOrder"]).toBeDefined();
|
|
477
|
+
expect(manifest.flows?.["checkout.chargeOrder"]?.in).toBeDefined();
|
|
478
|
+
expect(manifest.flows?.["checkout.chargeOrder"]?.out).toBeDefined();
|
|
479
|
+
expect(manifest.flows?.["checkout.chargeOrder"]?.effects?.reads).toEqual(["sql:orders"]);
|
|
480
|
+
expect(manifest.flows?.["checkout.chargeOrder"]?.effects?.writes).toEqual(["sql:orders"]);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("on(trigger, flow(name, {...})) extracts the same shape as bare flow()", async () => {
|
|
484
|
+
const source = `
|
|
485
|
+
import { on, http, flow } from "okengine";
|
|
486
|
+
|
|
487
|
+
export const getOrder = on(
|
|
488
|
+
http.get("/orders/:id"),
|
|
489
|
+
flow("checkout.getOrder", {
|
|
490
|
+
out: OrderOut,
|
|
491
|
+
effects: { reads: ["sql:orders"] },
|
|
492
|
+
do: async (input, fx) => {
|
|
493
|
+
return { id: input.id };
|
|
494
|
+
},
|
|
495
|
+
}),
|
|
496
|
+
);
|
|
497
|
+
`;
|
|
498
|
+
const manifest = await extractFromSources({ "src/flows/checkout.ts": source });
|
|
499
|
+
|
|
500
|
+
expect(manifest.flows?.["checkout.getOrder"]).toBeDefined();
|
|
501
|
+
expect(manifest.flows?.["checkout.getOrder"]?.trigger?.http?.method).toBe("GET");
|
|
502
|
+
expect(manifest.flows?.["checkout.getOrder"]?.out).toBeDefined();
|
|
503
|
+
expect(manifest.flows?.["checkout.getOrder"]?.effects?.reads).toEqual(["sql:orders"]);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("a stray `name:` key inside the options object is ignored — the positional arg wins", async () => {
|
|
507
|
+
// Regression guard for the exact class of AST-shape assumption that broke
|
|
508
|
+
// silently before (channel-alias tracking, `.put()` inference): the
|
|
509
|
+
// compiler must read the flow's identity from the first positional
|
|
510
|
+
// argument, never from a `name` property on the options object, even
|
|
511
|
+
// when one happens to be present.
|
|
512
|
+
const source = `
|
|
513
|
+
import { flow } from "okengine";
|
|
514
|
+
|
|
515
|
+
export const decoy = flow("checkout.realName", {
|
|
516
|
+
name: "checkout.decoyName",
|
|
517
|
+
effects: { reads: ["sql:orders"] },
|
|
518
|
+
do: async () => ({ ok: true }),
|
|
519
|
+
});
|
|
520
|
+
`;
|
|
521
|
+
const manifest = await extractFromSources({ "src/flows/checkout.ts": source });
|
|
522
|
+
|
|
523
|
+
expect(manifest.flows?.["checkout.realName"]).toBeDefined();
|
|
524
|
+
expect(manifest.flows?.["checkout.decoyName"]).toBeUndefined();
|
|
525
|
+
});
|
|
526
|
+
});
|
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
|
};
|
|
@@ -1134,11 +1159,11 @@ function registerFlow(args: {
|
|
|
1134
1159
|
scope: ProjectScope;
|
|
1135
1160
|
exportName: string | undefined;
|
|
1136
1161
|
}): void {
|
|
1137
|
-
const opts = objectArg(args.flowCall.arguments[
|
|
1162
|
+
const opts = objectArg(args.flowCall.arguments[1]);
|
|
1138
1163
|
if (!opts) return;
|
|
1139
1164
|
|
|
1140
1165
|
const name =
|
|
1141
|
-
|
|
1166
|
+
stringArg(args.flowCall.arguments[0]) ??
|
|
1142
1167
|
args.exportName ??
|
|
1143
1168
|
`flow_${Object.keys(args.scope.flows).length + 1}`;
|
|
1144
1169
|
|
|
@@ -1207,7 +1232,7 @@ function registerFlow(args: {
|
|
|
1207
1232
|
const errors = parseErrors(objectProp(opts, "errors"));
|
|
1208
1233
|
if (errors) flow.errors = errors;
|
|
1209
1234
|
|
|
1210
|
-
if (effects
|
|
1235
|
+
if (effects) flow.effects = effects;
|
|
1211
1236
|
|
|
1212
1237
|
const line = lineAt(args.file.source, args.flowCall.start ?? 0);
|
|
1213
1238
|
flow.source = `${args.file.path}:${line}`;
|
|
@@ -5,8 +5,7 @@ import { on, flow, http } from "okengine";
|
|
|
5
5
|
*/
|
|
6
6
|
export const escapeHatch = on(
|
|
7
7
|
http.get("/raw"),
|
|
8
|
-
flow({
|
|
9
|
-
name: "raw.unannotated",
|
|
8
|
+
flow("raw.unannotated", {
|
|
10
9
|
do: async (_, fx) => {
|
|
11
10
|
return fx.raw("select 1");
|
|
12
11
|
},
|
|
@@ -18,8 +17,7 @@ export const escapeHatch = on(
|
|
|
18
17
|
*/
|
|
19
18
|
export const annotated = on(
|
|
20
19
|
http.get("/raw-ok"),
|
|
21
|
-
flow({
|
|
22
|
-
name: "raw.annotated",
|
|
20
|
+
flow("raw.annotated", {
|
|
23
21
|
effects: { reads: ["sql:orders"] },
|
|
24
22
|
do: async (_, fx) => {
|
|
25
23
|
return fx.raw("select * from orders");
|
|
@@ -15,8 +15,7 @@ void 0;
|
|
|
15
15
|
|
|
16
16
|
export const create = on(
|
|
17
17
|
http.post("/bookings").gate(member, canBook, fair),
|
|
18
|
-
flow({
|
|
19
|
-
name: "bookings.create",
|
|
18
|
+
flow("bookings.create", {
|
|
20
19
|
in: BookingIn,
|
|
21
20
|
out: BookingOut,
|
|
22
21
|
errors: { FlightFull },
|
|
@@ -35,8 +34,7 @@ export const create = on(
|
|
|
35
34
|
|
|
36
35
|
export const mine = on(
|
|
37
36
|
http.get("/bookings").gate(member).live(),
|
|
38
|
-
flow({
|
|
39
|
-
name: "bookings.mine",
|
|
37
|
+
flow("bookings.mine", {
|
|
40
38
|
live: true,
|
|
41
39
|
do: (_, fx) =>
|
|
42
40
|
fx
|
|
@@ -47,13 +45,11 @@ export const mine = on(
|
|
|
47
45
|
}),
|
|
48
46
|
);
|
|
49
47
|
|
|
50
|
-
export const getBooking = flow({
|
|
51
|
-
name: "bookings.getBooking",
|
|
48
|
+
export const getBooking = flow("bookings.getBooking", {
|
|
52
49
|
do: ({ id }, fx) => fx.store(db).findById(bookings, id),
|
|
53
50
|
});
|
|
54
51
|
|
|
55
|
-
export const refundBooking = flow({
|
|
56
|
-
name: "bookings.refundBooking",
|
|
52
|
+
export const refundBooking = flow("bookings.refundBooking", {
|
|
57
53
|
do: async ({ id }, fx) => {
|
|
58
54
|
await fx.store(db).update(bookings).set({ status: "refunded" }).where({ id });
|
|
59
55
|
return fx.store(db).findById(bookings, id);
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { flow } from "okengine";
|
|
2
2
|
import { stripeKey } from "../../vault.ts";
|
|
3
3
|
|
|
4
|
-
export const chargeBooking = flow({
|
|
5
|
-
name: "payments.chargeBooking",
|
|
4
|
+
export const chargeBooking = flow("payments.chargeBooking", {
|
|
6
5
|
durable: true,
|
|
7
6
|
do: async ({ orderId }, fx) => {
|
|
8
7
|
// Journal only serializable step values — a Redacted is not journaled.
|
|
@@ -6,8 +6,7 @@ import { tickets } from "../../schema.ts";
|
|
|
6
6
|
|
|
7
7
|
export const createTicket = on(
|
|
8
8
|
http.post("/tickets").gate(member),
|
|
9
|
-
flow({
|
|
10
|
-
name: "support.createTicket",
|
|
9
|
+
flow("support.createTicket", {
|
|
11
10
|
pii: "masked",
|
|
12
11
|
cost: { estimatePerCall: 0.011, budget: 0.02 },
|
|
13
12
|
do: async (input, fx) => {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"effects": {
|
|
33
33
|
"reads": ["sql:bookings"]
|
|
34
34
|
},
|
|
35
|
-
"source": "src/flows/bookings/index.ts:
|
|
35
|
+
"source": "src/flows/bookings/index.ts:48"
|
|
36
36
|
},
|
|
37
37
|
"bookings.mine": {
|
|
38
38
|
"trigger": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"effects": {
|
|
46
46
|
"reads": ["sql:bookings"]
|
|
47
47
|
},
|
|
48
|
-
"source": "src/flows/bookings/index.ts:
|
|
48
|
+
"source": "src/flows/bookings/index.ts:37",
|
|
49
49
|
"live": true,
|
|
50
50
|
"cacheKeys": "computed:sql:bookings/userId"
|
|
51
51
|
},
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"reads": ["sql:bookings"],
|
|
55
55
|
"writes": ["sql:bookings"]
|
|
56
56
|
},
|
|
57
|
-
"source": "src/flows/bookings/index.ts:
|
|
57
|
+
"source": "src/flows/bookings/index.ts:52"
|
|
58
58
|
},
|
|
59
59
|
"payments.chargeBooking": {
|
|
60
60
|
"effects": {
|
|
@@ -16,8 +16,7 @@ export const linkClicked = signal("link-clicked", {
|
|
|
16
16
|
// ① HTTP
|
|
17
17
|
export const create = on(
|
|
18
18
|
http.post("/orders"),
|
|
19
|
-
flow({
|
|
20
|
-
name: "triggers.http",
|
|
19
|
+
flow("triggers.http", {
|
|
21
20
|
do: async (input, fx) => {
|
|
22
21
|
await fx.store(db).insert(orders).values(input);
|
|
23
22
|
return { ok: true };
|
|
@@ -28,8 +27,7 @@ export const create = on(
|
|
|
28
27
|
// ② CLOCK / every
|
|
29
28
|
on(
|
|
30
29
|
every("10m"),
|
|
31
|
-
flow({
|
|
32
|
-
name: "triggers.every",
|
|
30
|
+
flow("triggers.every", {
|
|
33
31
|
do: (_, fx) => fx.store(db).delete(links).where({ createdAt: 0 }),
|
|
34
32
|
}),
|
|
35
33
|
);
|
|
@@ -37,8 +35,7 @@ on(
|
|
|
37
35
|
// ③ SIGNAL
|
|
38
36
|
on(
|
|
39
37
|
linkClicked,
|
|
40
|
-
flow({
|
|
41
|
-
name: "triggers.signal",
|
|
38
|
+
flow("triggers.signal", {
|
|
42
39
|
do: async ({ code }, fx) => {
|
|
43
40
|
const clicks = await fx.store(db).increment(links, code, "clicks");
|
|
44
41
|
await fx.emit(linkClicked, { code, clicks });
|
|
@@ -49,8 +46,7 @@ on(
|
|
|
49
46
|
// ④ CDC
|
|
50
47
|
on(
|
|
51
48
|
table("orders").changed("status"),
|
|
52
|
-
flow({
|
|
53
|
-
name: "triggers.cdc",
|
|
49
|
+
flow("triggers.cdc", {
|
|
54
50
|
do: ({ before, after }, fx) => fx.store(db).insert(orders).values({ before, after }),
|
|
55
51
|
}),
|
|
56
52
|
);
|
|
@@ -58,8 +54,7 @@ on(
|
|
|
58
54
|
// ⑤ INTERNAL / call-only
|
|
59
55
|
export const stats = on(
|
|
60
56
|
internal,
|
|
61
|
-
flow({
|
|
62
|
-
name: "triggers.internal",
|
|
57
|
+
flow("triggers.internal", {
|
|
63
58
|
do: async ({ code }, fx) => {
|
|
64
59
|
const [row] = await fx.store(db).select().from(links).where({ code });
|
|
65
60
|
return row?.clicks ?? 0;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.adopt()` barrel generator — real filesystem before/after proofs.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { generateAdoptBarrel } from "./generate-adopt.ts";
|
|
10
|
+
|
|
11
|
+
async function makeUnit(root: string, name: string, withIndex = true): Promise<void> {
|
|
12
|
+
const dir = join(root, "src/flows", name);
|
|
13
|
+
await mkdir(dir, { recursive: true });
|
|
14
|
+
if (withIndex) await writeFile(join(dir, "index.ts"), "export const nothing = 1;\n");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("generateAdoptBarrel", () => {
|
|
18
|
+
test("emits one `export * as <unit>` per flows folder with an index.ts barrel", async () => {
|
|
19
|
+
const root = await mkdtemp(join(tmpdir(), "oke-adopt-gen-"));
|
|
20
|
+
try {
|
|
21
|
+
await makeUnit(root, "notes");
|
|
22
|
+
await makeUnit(root, "main");
|
|
23
|
+
const result = await generateAdoptBarrel({ rootDir: root });
|
|
24
|
+
expect(result.units).toEqual(["main", "notes"]);
|
|
25
|
+
expect(result.source).toContain('export * as main from "./main/index.ts";');
|
|
26
|
+
expect(result.source).toContain('export * as notes from "./notes/index.ts";');
|
|
27
|
+
expect(result.skipped).toEqual([]);
|
|
28
|
+
} finally {
|
|
29
|
+
await rm(root, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("skips a unit folder with no index.ts barrel (never crashes)", async () => {
|
|
34
|
+
const root = await mkdtemp(join(tmpdir(), "oke-adopt-gen-"));
|
|
35
|
+
try {
|
|
36
|
+
await makeUnit(root, "notes");
|
|
37
|
+
await makeUnit(root, "scratch", false);
|
|
38
|
+
const result = await generateAdoptBarrel({ rootDir: root });
|
|
39
|
+
expect(result.units).toEqual(["notes"]);
|
|
40
|
+
expect(result.skipped).toEqual(["scratch"]);
|
|
41
|
+
} finally {
|
|
42
|
+
await rm(root, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("missing flows directory yields an empty barrel, never throws", async () => {
|
|
47
|
+
const root = await mkdtemp(join(tmpdir(), "oke-adopt-gen-"));
|
|
48
|
+
try {
|
|
49
|
+
const result = await generateAdoptBarrel({ rootDir: root });
|
|
50
|
+
expect(result.units).toEqual([]);
|
|
51
|
+
expect(result.source).toContain("AUTO-GENERATED");
|
|
52
|
+
} finally {
|
|
53
|
+
await rm(root, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("real before/after: generated barrel matches the hand-written form it replaces", async () => {
|
|
58
|
+
const root = await mkdtemp(join(tmpdir(), "oke-adopt-gen-"));
|
|
59
|
+
try {
|
|
60
|
+
await mkdir(join(root, "src/flows/main"), { recursive: true });
|
|
61
|
+
await mkdir(join(root, "src/flows/notes"), { recursive: true });
|
|
62
|
+
await writeFile(join(root, "src/flows/main/index.ts"), 'export const root = "main-flow";\n');
|
|
63
|
+
await writeFile(
|
|
64
|
+
join(root, "src/flows/notes/index.ts"),
|
|
65
|
+
'export const create = "notes-flow";\n',
|
|
66
|
+
);
|
|
67
|
+
const result = await generateAdoptBarrel({ rootDir: root });
|
|
68
|
+
const generatedPath = join(root, "src/flows/generated.ts");
|
|
69
|
+
await writeFile(generatedPath, result.source);
|
|
70
|
+
|
|
71
|
+
// Same shape as `import * as main from "./flows/main"; import * as
|
|
72
|
+
// notes from "./flows/notes"; .adopt({ main, notes })` — a namespace
|
|
73
|
+
// object keyed by unit, one key per flows folder, real modules loaded.
|
|
74
|
+
const generatedModule = (await import(generatedPath)) as {
|
|
75
|
+
main: { root: string };
|
|
76
|
+
notes: { create: string };
|
|
77
|
+
};
|
|
78
|
+
expect(Object.keys(generatedModule).sort()).toEqual(["main", "notes"]);
|
|
79
|
+
expect(generatedModule.main.root).toBe("main-flow");
|
|
80
|
+
expect(generatedModule.notes.create).toBe("notes-flow");
|
|
81
|
+
} finally {
|
|
82
|
+
await rm(root, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|