okengine 0.19.8 → 0.20.0
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/AGENTS.md +1 -1
- package/package.json +1 -1
- package/site/content/docs/client/auth.mdx +1 -2
- package/site/content/docs/client/calling.mdx +8 -8
- package/site/content/docs/client/index.mdx +4 -4
- package/site/content/docs/client/react.mdx +5 -0
- package/site/content/docs/elements/channel/email.mdx +25 -36
- package/site/content/docs/elements/channel/index.mdx +88 -46
- package/site/content/docs/elements/channel/push.mdx +7 -9
- package/site/content/docs/elements/channel/sms.mdx +6 -4
- package/site/content/docs/elements/channel/whatsapp.mdx +11 -9
- package/site/content/docs/elements/clock/index.mdx +14 -25
- package/site/content/docs/elements/flow/http.mdx +24 -8
- package/site/content/docs/elements/flow/index.mdx +15 -12
- package/site/content/docs/elements/flow/routing.mdx +166 -122
- package/site/content/docs/elements/gate/rls.mdx +2 -2
- package/site/content/docs/elements/store/index.mdx +11 -3
- package/site/content/docs/elements/store/search.mdx +5 -5
- package/site/content/docs/elements/store/sql.mdx +91 -33
- package/site/content/docs/elements/vault/index.mdx +3 -5
- package/site/content/docs/plugins/magic-link.mdx +4 -3
- package/site/content/docs/plugins/otp.mdx +4 -3
- package/site/content/docs/plugins/two-factor.mdx +4 -0
- package/site/content/docs/providers/index.mdx +1 -1
- package/site/content/docs/recipes/index.mdx +1 -1
- package/site/content/docs/reference/cli.mdx +13 -4
- package/site/content/docs/reference/configuration.mdx +7 -7
- package/site/content/docs/reference/errors.mdx +30 -30
- package/site/content/docs/reference/fx.mdx +16 -8
- package/site/content/docs/reference/i18n.mdx +4 -4
- package/site/content/docs/reference/plugins.mdx +4 -4
- package/site/content/docs/understand/try-it.mdx +2 -0
- package/src/cli/ai-setup/ai-setup.test.ts +40 -0
- package/src/cli/ai-setup/apply.ts +28 -46
- package/src/cli/ask-vault-gaps.test.ts +60 -4
- package/src/cli/ask-vault-gaps.ts +59 -3
- package/src/cli/build.test.ts +3 -3
- package/src/cli/build.ts +5 -5
- package/src/cli/db-auto-push.test.ts +11 -0
- package/src/cli/db-auto-push.ts +6 -2
- package/src/cli/db.test.ts +1 -1
- package/src/cli/db.ts +6 -6
- package/src/cli/dev-app-runner.ts +12 -4
- package/src/cli/dev-db-push.test.ts +6 -2
- package/src/cli/dev-schema-sync.ts +1 -1
- package/src/cli/dev.test.ts +10 -7
- package/src/cli/dev.ts +18 -12
- package/src/cli/ensure-drizzle-config.ts +4 -3
- package/src/client-react/browser.test.ts +23 -0
- package/src/client-react/use-live-query.ts +1 -1
- package/src/compiler/flow-path.test.ts +1 -0
- package/src/compiler/flow-path.ts +1 -1
- package/src/compiler/generate-adopt.test.ts +55 -1
- package/src/compiler/generate-adopt.ts +111 -21
- package/src/config/index.ts +6 -4
- package/src/console/ui-next/dist/assets/{access-page-C5yG4aS2.js → access-page-DFLu0wTA.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-D2ToVK86.js → agent-disclosure-DGscxaF5.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-7HUR0kCz.js → cache-glyph-BGmRZk7d.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-D0ky3aXt.js → call-pii-button--feUYxvG.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BLUHH2dB.js → collapsible-JWvpaiGY.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-BgHEFMtm.js → duration-tone-D9yCJG4n.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DDybqshQ.js → flows-page-Bs6MD9GB.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-BFiJKYV4.js → highlighted-json-xH8MrEnv.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-BVAcB0bI.js → http-method-C4vB6ZIw.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-BID6LSYI.js → index-yTCY4AcS.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CANo_mMK.js → observability-page-BxJ3R6dU.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-CqQGCshp.js → replica-lag-QRKB_IE8.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DyUDkEt4.js → request-meta-DqZ-fMu5.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CA0rJ_Ow.js → store-page-Dixb6L7a.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-DjH_fMzI.js → trace-detail-sheet-CazhjtiU.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWSomIeQ.js → tree-expand-toggle-DlnqYKfr.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-Cz9TIViw.js → units-page-BXTLjU2-.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-Bb1o1O9p.js → vault-page-39KR__bc.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/drivers/clock-postgres.test.ts +10 -2
- package/src/drivers/clock-postgres.ts +18 -2
- package/src/drivers/vault-driver-removal.test.ts +2 -2
- package/src/elements/channel/declare.ts +66 -3
- package/src/elements/channel/runtime.ts +9 -11
- package/src/elements/channel.test.ts +42 -0
- package/src/elements/channel.ts +4 -2
- package/src/elements/clock/reconcile.ts +45 -24
- package/src/elements/clock.test.ts +33 -0
- package/src/elements/store/emit-drizzle.ts +285 -65
- package/src/elements/store/load-plugin-tables.ts +1 -1
- package/src/elements/store/prepare-row.test.ts +57 -4
- package/src/elements/store/schema-decl.test.ts +178 -0
- package/src/elements/store/sql-session.ts +44 -2
- package/src/elements/store/table.ts +8 -6
- package/src/kernel/adopt-barrel-fresh.test.ts +1 -1
- package/src/kernel/app.ts +26 -32
- package/src/kernel/auto-registry.test.ts +26 -1
- package/src/kernel/boot.ts +2 -2
- package/src/kernel/boundary-contract.ts +6 -1
- package/src/kernel/errors.ts +3 -3
- package/src/kernel/flow-units.ts +3 -3
- package/src/kernel/fx.ts +12 -2
- package/src/kernel/mutation-id.ts +8 -0
- package/src/kernel/plugin.ts +4 -3
- package/src/kernel/project-out.test.ts +176 -0
- package/src/kernel/project-out.ts +91 -0
- package/src/kernel/realtime-bind.ts +2 -3
- package/src/kernel/router/linear.ts +12 -6
- package/src/kernel/router.test.ts +13 -0
- package/src/plugins/magic-link.ts +25 -24
- package/src/plugins/otp.ts +35 -24
- package/src/plugins/two-factor.ts +15 -0
- package/src/runs/duckdb.test.ts +2 -2
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
6
9
|
import { plugin } from "../../kernel/plugin.ts";
|
|
7
10
|
import { createRecordingApi } from "../../kernel/registry.ts";
|
|
8
11
|
import {
|
|
@@ -18,9 +21,13 @@ import {
|
|
|
18
21
|
type SqlStoreHandle,
|
|
19
22
|
} from "../store.ts";
|
|
20
23
|
import {
|
|
24
|
+
discoverDeclareRel,
|
|
25
|
+
discoverGeneratedRel,
|
|
21
26
|
emitDrizzleSource,
|
|
22
27
|
GENERATED_SCHEMA_HEADER,
|
|
28
|
+
maybeEmitDomainSchema,
|
|
23
29
|
mergeSchemaTables,
|
|
30
|
+
resolveEmitPaths,
|
|
24
31
|
tablesFromPluginContributions,
|
|
25
32
|
} from "./emit-drizzle.ts";
|
|
26
33
|
|
|
@@ -297,3 +304,174 @@ describe("store.schema RLS extras", () => {
|
|
|
297
304
|
expect(emitDrizzleSource([notes], "postgres")).toContain("pgTable.withRLS");
|
|
298
305
|
});
|
|
299
306
|
});
|
|
307
|
+
|
|
308
|
+
describe("declare path discovery", () => {
|
|
309
|
+
test("schema.ts wins over schema/index.ts", async () => {
|
|
310
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-decl-priority-"));
|
|
311
|
+
await mkdir(join(dir, "src", "db", "schema"), { recursive: true });
|
|
312
|
+
await writeFile(join(dir, "src", "db", "schema.ts"), "export {}\n");
|
|
313
|
+
await writeFile(join(dir, "src", "db", "schema", "index.ts"), "export {}\n");
|
|
314
|
+
expect(discoverDeclareRel(dir)).toBe("src/db/schema.ts");
|
|
315
|
+
expect(resolveEmitPaths(dir).declarePath).toBe(join(dir, "src/db/schema.ts"));
|
|
316
|
+
await rm(dir, { recursive: true, force: true });
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("schema.decl.ts when schema.ts is absent", async () => {
|
|
320
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-decl-prev-"));
|
|
321
|
+
await mkdir(join(dir, "src", "db", "schema"), { recursive: true });
|
|
322
|
+
await writeFile(join(dir, "src", "db", "schema.decl.ts"), "export {}\n");
|
|
323
|
+
await writeFile(join(dir, "src", "db", "schema", "index.ts"), "export {}\n");
|
|
324
|
+
expect(discoverDeclareRel(dir)).toBe("src/db/schema.decl.ts");
|
|
325
|
+
await rm(dir, { recursive: true, force: true });
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("schema/index.ts when single-file declare is absent", async () => {
|
|
329
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-decl-folder-"));
|
|
330
|
+
await mkdir(join(dir, "src", "db", "schema"), { recursive: true });
|
|
331
|
+
await writeFile(join(dir, "src", "db", "schema", "index.ts"), "export {}\n");
|
|
332
|
+
expect(discoverDeclareRel(dir)).toBe("src/db/schema/index.ts");
|
|
333
|
+
await rm(dir, { recursive: true, force: true });
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("db.declare overrides discovery", async () => {
|
|
337
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-decl-override-"));
|
|
338
|
+
await mkdir(join(dir, "src", "db", "schema"), { recursive: true });
|
|
339
|
+
await writeFile(join(dir, "src", "db", "schema.ts"), "export {}\n");
|
|
340
|
+
const resolved = resolveEmitPaths(dir, { declare: "src/db/schema/index.ts" });
|
|
341
|
+
expect(resolved.declarePath).toBe(join(dir, "src/db/schema/index.ts"));
|
|
342
|
+
await rm(dir, { recursive: true, force: true });
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("drizzle/index.ts wins over drizzle.ts", async () => {
|
|
346
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-gen-folder-"));
|
|
347
|
+
await mkdir(join(dir, "src", "db", "drizzle"), { recursive: true });
|
|
348
|
+
await writeFile(join(dir, "src", "db", "drizzle", "index.ts"), "export {}\n");
|
|
349
|
+
await writeFile(join(dir, "src", "db", "drizzle.ts"), "export {}\n");
|
|
350
|
+
expect(discoverGeneratedRel(dir, "src/db/schema.ts")).toBe("src/db/drizzle/index.ts");
|
|
351
|
+
await rm(dir, { recursive: true, force: true });
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("omitted generated is drizzle/index.ts even if older files exist", async () => {
|
|
355
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-gen-default-"));
|
|
356
|
+
await mkdir(join(dir, "src", "db"), { recursive: true });
|
|
357
|
+
await writeFile(join(dir, "src", "db", "drizzle.ts"), "export {}\n");
|
|
358
|
+
await writeFile(join(dir, "src", "db", "schema.drizzle.ts"), "export {}\n");
|
|
359
|
+
expect(discoverGeneratedRel(dir, "src/db/schema.ts")).toBe("src/db/drizzle/index.ts");
|
|
360
|
+
await rm(dir, { recursive: true, force: true });
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe("maybeEmitDomainSchema — barrel declare", () => {
|
|
365
|
+
test("emits from schema/index.ts without schema.ts", async () => {
|
|
366
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-schema-folder-"));
|
|
367
|
+
const schemaDir = join(dir, "src", "db", "schema");
|
|
368
|
+
await mkdir(schemaDir, { recursive: true });
|
|
369
|
+
const oke = resolve(import.meta.dir, "../../index.ts");
|
|
370
|
+
await writeFile(
|
|
371
|
+
join(schemaDir, "posts.ts"),
|
|
372
|
+
`import { store, field } from ${JSON.stringify(oke)};
|
|
373
|
+
export const posts = store.schema.table("posts", {
|
|
374
|
+
id: field.id().primaryKey(),
|
|
375
|
+
title: field.text().notNull(),
|
|
376
|
+
});
|
|
377
|
+
`,
|
|
378
|
+
);
|
|
379
|
+
await writeFile(join(schemaDir, "index.ts"), `export * from "./posts.ts";\n`);
|
|
380
|
+
const result = await maybeEmitDomainSchema({
|
|
381
|
+
cwd: dir,
|
|
382
|
+
dialect: "postgres",
|
|
383
|
+
});
|
|
384
|
+
expect(result.emitted).toBe(true);
|
|
385
|
+
expect(result.tableCount).toBe(1);
|
|
386
|
+
const generated = await readFile(join(dir, "src", "db", "drizzle", "posts.ts"), "utf8");
|
|
387
|
+
expect(generated).toContain('pgTable("posts"');
|
|
388
|
+
const barrel = await readFile(join(dir, "src", "db", "drizzle", "index.ts"), "utf8");
|
|
389
|
+
expect(barrel).toContain('export * from "./posts.ts"');
|
|
390
|
+
await rm(dir, { recursive: true, force: true });
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("writes one file per table with FK import + relations", async () => {
|
|
394
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-drizzle-split-"));
|
|
395
|
+
const schemaDir = join(dir, "src", "db", "schema");
|
|
396
|
+
await mkdir(schemaDir, { recursive: true });
|
|
397
|
+
const oke = resolve(import.meta.dir, "../../index.ts");
|
|
398
|
+
await writeFile(
|
|
399
|
+
join(schemaDir, "links.ts"),
|
|
400
|
+
`import { store, field } from ${JSON.stringify(oke)};
|
|
401
|
+
export const links = store.schema.table("links", {
|
|
402
|
+
id: field.id().primaryKey(),
|
|
403
|
+
code: field.text().notNull().unique(),
|
|
404
|
+
});
|
|
405
|
+
`,
|
|
406
|
+
);
|
|
407
|
+
await writeFile(
|
|
408
|
+
join(schemaDir, "daily.ts"),
|
|
409
|
+
`import { store, field } from ${JSON.stringify(oke)};
|
|
410
|
+
import { links } from "./links.ts";
|
|
411
|
+
export const daily = store.schema.table("daily", {
|
|
412
|
+
id: field.id().primaryKey(),
|
|
413
|
+
code: field.text().notNull().references(() => links.code, { onDelete: "cascade" }),
|
|
414
|
+
});
|
|
415
|
+
`,
|
|
416
|
+
);
|
|
417
|
+
await writeFile(
|
|
418
|
+
join(schemaDir, "relations.ts"),
|
|
419
|
+
`import { store } from ${JSON.stringify(oke)};
|
|
420
|
+
import { daily } from "./daily.ts";
|
|
421
|
+
import { links } from "./links.ts";
|
|
422
|
+
export const relations = store.schema.relations({ links, daily }, (r) => ({
|
|
423
|
+
links: { daily: r.many.daily({ from: r.links.code, to: r.daily.code }) },
|
|
424
|
+
daily: { link: r.one.links({ from: r.daily.code, to: r.links.code, optional: false }) },
|
|
425
|
+
}));
|
|
426
|
+
`,
|
|
427
|
+
);
|
|
428
|
+
await writeFile(
|
|
429
|
+
join(schemaDir, "index.ts"),
|
|
430
|
+
`export * from "./links.ts";
|
|
431
|
+
export * from "./daily.ts";
|
|
432
|
+
export * from "./relations.ts";
|
|
433
|
+
`,
|
|
434
|
+
);
|
|
435
|
+
const result = await maybeEmitDomainSchema({ cwd: dir, dialect: "postgres" });
|
|
436
|
+
expect(result.emitted).toBe(true);
|
|
437
|
+
expect(result.tableCount).toBe(2);
|
|
438
|
+
const daily = await readFile(join(dir, "src", "db", "drizzle", "daily.ts"), "utf8");
|
|
439
|
+
expect(daily).toContain('import { links } from "./links.ts"');
|
|
440
|
+
expect(daily).toContain(".references(() => links.code");
|
|
441
|
+
const rel = await readFile(join(dir, "src", "db", "drizzle", "relations.ts"), "utf8");
|
|
442
|
+
expect(rel).toContain("defineRelations({ links, daily }");
|
|
443
|
+
const barrel = await readFile(join(dir, "src", "db", "drizzle", "index.ts"), "utf8");
|
|
444
|
+
expect(barrel).toContain('export * from "./relations.ts"');
|
|
445
|
+
await rm(dir, { recursive: true, force: true });
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("prunes stale generated files and drops sibling drizzle.ts", async () => {
|
|
449
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-drizzle-prune-"));
|
|
450
|
+
const schemaDir = join(dir, "src", "db", "schema");
|
|
451
|
+
await mkdir(schemaDir, { recursive: true });
|
|
452
|
+
await mkdir(join(dir, "src", "db", "drizzle"), { recursive: true });
|
|
453
|
+
const oke = resolve(import.meta.dir, "../../index.ts");
|
|
454
|
+
await writeFile(
|
|
455
|
+
join(schemaDir, "posts.ts"),
|
|
456
|
+
`import { store, field } from ${JSON.stringify(oke)};
|
|
457
|
+
export const posts = store.schema.table("posts", {
|
|
458
|
+
id: field.id().primaryKey(),
|
|
459
|
+
});
|
|
460
|
+
`,
|
|
461
|
+
);
|
|
462
|
+
await writeFile(join(schemaDir, "index.ts"), `export * from "./posts.ts";\n`);
|
|
463
|
+
await writeFile(
|
|
464
|
+
join(dir, "src", "db", "drizzle.ts"),
|
|
465
|
+
`// generated by oke — do not edit\nexport const stale = true;\n`,
|
|
466
|
+
);
|
|
467
|
+
await writeFile(
|
|
468
|
+
join(dir, "src", "db", "drizzle", "gone.ts"),
|
|
469
|
+
`// generated by oke — do not edit\nexport const gone = true;\n`,
|
|
470
|
+
);
|
|
471
|
+
await maybeEmitDomainSchema({ cwd: dir, dialect: "postgres" });
|
|
472
|
+
expect(await Bun.file(join(dir, "src", "db", "drizzle.ts")).exists()).toBe(false);
|
|
473
|
+
expect(await Bun.file(join(dir, "src", "db", "drizzle", "gone.ts")).exists()).toBe(false);
|
|
474
|
+
expect(await Bun.file(join(dir, "src", "db", "drizzle", "posts.ts")).exists()).toBe(true);
|
|
475
|
+
await rm(dir, { recursive: true, force: true });
|
|
476
|
+
});
|
|
477
|
+
});
|
|
@@ -22,9 +22,11 @@ import {
|
|
|
22
22
|
compileWhere,
|
|
23
23
|
compileOrderBy,
|
|
24
24
|
resolveSelectColumns,
|
|
25
|
+
type CompiledWhere,
|
|
25
26
|
type WhereMap,
|
|
26
27
|
} from "./sql-condition.ts";
|
|
27
28
|
import {
|
|
29
|
+
coerceTemporalBindValue,
|
|
28
30
|
mapRowToJs,
|
|
29
31
|
prepareInsertRow,
|
|
30
32
|
prepareUpdateRow,
|
|
@@ -622,6 +624,46 @@ export function createSqlStoreHandle(
|
|
|
622
624
|
return out;
|
|
623
625
|
}
|
|
624
626
|
|
|
627
|
+
/**
|
|
628
|
+
* Coerce epoch-ms / ISO strings on temporal WHERE binds the same way
|
|
629
|
+
* insert/update SET does, so `lte(col, fx.clock.now())` is a timestamp.
|
|
630
|
+
*
|
|
631
|
+
* @param table - Table whose columns supply SQL types
|
|
632
|
+
* @param compiled - Predicate list from {@link compileWhere}
|
|
633
|
+
*/
|
|
634
|
+
function coerceCompiledWhere(
|
|
635
|
+
table: TableHandle | unknown,
|
|
636
|
+
compiled: CompiledWhere,
|
|
637
|
+
): CompiledWhere {
|
|
638
|
+
const cols = resolveColumns(table);
|
|
639
|
+
if (cols.length === 0 || compiled.predicates.length === 0) return compiled;
|
|
640
|
+
const bySql = new Map(cols.map((c) => [c.sqlName, c]));
|
|
641
|
+
|
|
642
|
+
let changed = false;
|
|
643
|
+
const predicates = compiled.predicates.map((p) => {
|
|
644
|
+
const col = bySql.get(p.column);
|
|
645
|
+
if (!col || p.op === "is null" || p.op === "is not null") return p;
|
|
646
|
+
if (p.op === "in" && Array.isArray(p.value)) {
|
|
647
|
+
const incoming = p.value;
|
|
648
|
+
const value = incoming.map((v) => coerceTemporalBindValue(col.sqlType, v));
|
|
649
|
+
if (value.some((v, i) => v !== incoming[i])) changed = true;
|
|
650
|
+
return { ...p, value };
|
|
651
|
+
}
|
|
652
|
+
const value = coerceTemporalBindValue(col.sqlType, p.value);
|
|
653
|
+
if (value !== p.value) changed = true;
|
|
654
|
+
return { ...p, value };
|
|
655
|
+
});
|
|
656
|
+
if (!changed) return compiled;
|
|
657
|
+
|
|
658
|
+
const params: unknown[] = [];
|
|
659
|
+
for (const p of predicates) {
|
|
660
|
+
if (p.op === "is null" || p.op === "is not null") continue;
|
|
661
|
+
if (p.op === "in" && Array.isArray(p.value)) params.push(...p.value);
|
|
662
|
+
else params.push(p.value);
|
|
663
|
+
}
|
|
664
|
+
return { clause: compiled.clause, params, predicates };
|
|
665
|
+
}
|
|
666
|
+
|
|
625
667
|
function compileTableWhere(
|
|
626
668
|
table: TableHandle | unknown,
|
|
627
669
|
where: unknown,
|
|
@@ -632,9 +674,9 @@ export function createSqlStoreHandle(
|
|
|
632
674
|
!Array.isArray(where) &&
|
|
633
675
|
!("queryChunks" in (where as object))
|
|
634
676
|
) {
|
|
635
|
-
return compileWhere(normalizeWhereMap(table, where as WhereMap));
|
|
677
|
+
return coerceCompiledWhere(table, compileWhere(normalizeWhereMap(table, where as WhereMap)));
|
|
636
678
|
}
|
|
637
|
-
return compileWhere(where);
|
|
679
|
+
return coerceCompiledWhere(table, compileWhere(where));
|
|
638
680
|
}
|
|
639
681
|
|
|
640
682
|
/**
|
|
@@ -295,7 +295,8 @@ function drizzleSqlType(
|
|
|
295
295
|
|
|
296
296
|
/**
|
|
297
297
|
* Bind value for a temporal SQL column. Epoch-ms numbers (e.g. `fx.clock.now()`)
|
|
298
|
-
*
|
|
298
|
+
* and parseable ISO datetime strings (HTTP `z.iso.datetime()`) become `Date`
|
|
299
|
+
* so Postgres `timestamp` / `date` accept them; other values pass through.
|
|
299
300
|
*
|
|
300
301
|
* @param sqlType - Resolved DDL type
|
|
301
302
|
* @param value - Incoming JS value
|
|
@@ -304,13 +305,14 @@ export function coerceTemporalBindValue(
|
|
|
304
305
|
sqlType: ResolvedColumn["sqlType"],
|
|
305
306
|
value: unknown,
|
|
306
307
|
): unknown {
|
|
307
|
-
if (
|
|
308
|
-
|
|
309
|
-
typeof value === "number" &&
|
|
310
|
-
Number.isFinite(value)
|
|
311
|
-
) {
|
|
308
|
+
if (sqlType !== "TIMESTAMP" && sqlType !== "DATE") return value;
|
|
309
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
312
310
|
return new Date(value);
|
|
313
311
|
}
|
|
312
|
+
if (typeof value === "string") {
|
|
313
|
+
const parsed = Date.parse(value);
|
|
314
|
+
if (Number.isFinite(parsed)) return new Date(parsed);
|
|
315
|
+
}
|
|
314
316
|
return value;
|
|
315
317
|
}
|
|
316
318
|
|
|
@@ -82,7 +82,7 @@ describe("boot-level: stale barrel through a real oke() boot", () => {
|
|
|
82
82
|
const root = await mkdtemp(join(tmpdir(), "oke-adopt-fresh-"));
|
|
83
83
|
try {
|
|
84
84
|
// "notes" exists on disk but this app only ever adopted "main" —
|
|
85
|
-
// exactly what a stale `src/flows/
|
|
85
|
+
// exactly what a stale `src/flows/index.ts` after adding a unit
|
|
86
86
|
// (and forgetting to rerun `oke dev` / `oke build`) looks like.
|
|
87
87
|
await makeUnitDir(root, "notes");
|
|
88
88
|
const mainFlow = flow("main.health", {
|
package/src/kernel/app.ts
CHANGED
|
@@ -39,15 +39,16 @@ import { requirePackageModule } from "../shared/lazy-src.ts";
|
|
|
39
39
|
import { lazyRequire } from "./lazy-require.ts";
|
|
40
40
|
import type { DurableResult, RunDurableOptions } from "../elements/clock/durable.ts";
|
|
41
41
|
import { applyClockTimezoneDefaults } from "../elements/clock/declare.ts";
|
|
42
|
-
import
|
|
42
|
+
import { catalogFromTemplates, mergeTemplateCatalogs } from "../elements/channel/declare.ts";
|
|
43
43
|
import { parseAcceptLanguage } from "../elements/channel/locale.ts";
|
|
44
44
|
import { runWithLocale } from "../i18n/locale-context.ts";
|
|
45
45
|
import { isFlow, type AnyFlowDef } from "./flow.ts";
|
|
46
46
|
import { failureFromUnknown, runCompensationPhase } from "./compensate.ts";
|
|
47
47
|
import { withAbortSignal } from "./abort-scope.ts";
|
|
48
48
|
import { withCdcMutationId } from "../elements/store/sql-session.ts";
|
|
49
|
-
import { MUTATION_ID_HEADER } from "./
|
|
49
|
+
import { MUTATION_ID_HEADER } from "./mutation-id.ts";
|
|
50
50
|
import { fxRetry } from "./concurrency.ts";
|
|
51
|
+
import { projectFlowOut } from "./project-out.ts";
|
|
51
52
|
import type {
|
|
52
53
|
CreateFxOptions,
|
|
53
54
|
Fx,
|
|
@@ -381,13 +382,13 @@ export interface UnitHooks<D extends Record<string, unknown> = {}> {
|
|
|
381
382
|
}
|
|
382
383
|
|
|
383
384
|
/**
|
|
384
|
-
* Module-augmentation slot filled by `src/flows/
|
|
385
|
+
* Module-augmentation slot filled by `src/flows/index.ts`.
|
|
385
386
|
* Kernel tests do not import an app generated file, so this stays `{}`.
|
|
386
387
|
*/
|
|
387
388
|
export interface RegisteredFlowUnits {}
|
|
388
389
|
|
|
389
390
|
/**
|
|
390
|
-
* `$routes` derived from {@link RegisteredFlowUnits} after `import
|
|
391
|
+
* `$routes` derived from {@link RegisteredFlowUnits} after `import "@/flows"`.
|
|
391
392
|
*
|
|
392
393
|
* @typeParam U - Augmented unit map
|
|
393
394
|
*/
|
|
@@ -853,7 +854,7 @@ function registerOnceSignalBinding(
|
|
|
853
854
|
}
|
|
854
855
|
|
|
855
856
|
/**
|
|
856
|
-
* Fold `
|
|
857
|
+
* Fold `src/flows/index.ts` units into `$routes` and `flowsByName`.
|
|
857
858
|
*
|
|
858
859
|
* @param units - Drained {@link registerFlowUnits} bag
|
|
859
860
|
* @param routes - Runtime `$routes`
|
|
@@ -967,12 +968,21 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
967
968
|
// explicit `options.channel` nor a registered template exists — a defined
|
|
968
969
|
// object here would flip `resolveElementNeeds`'s `channel` need to `true`
|
|
969
970
|
// for every app, whether or not it uses channel at all.
|
|
971
|
+
const channelTemplates = mergeUnique(
|
|
972
|
+
options.channel?.templates,
|
|
973
|
+
registrySnapshot.channelTemplates,
|
|
974
|
+
);
|
|
975
|
+
const channelCatalog = mergeTemplateCatalogs(
|
|
976
|
+
catalogFromTemplates(channelTemplates),
|
|
977
|
+
options.channel?.catalog,
|
|
978
|
+
);
|
|
970
979
|
const effectiveChannel: BootOptions["channel"] =
|
|
971
980
|
options.channel === undefined && registrySnapshot.channelTemplates.length === 0
|
|
972
981
|
? undefined
|
|
973
982
|
: {
|
|
974
983
|
...(options.channel ?? {}),
|
|
975
|
-
templates:
|
|
984
|
+
templates: channelTemplates,
|
|
985
|
+
...(channelCatalog ? { catalog: channelCatalog } : {}),
|
|
976
986
|
};
|
|
977
987
|
// Same undefined-when-empty rule for AI — a bare `{}` would force the AI
|
|
978
988
|
// runtime open even when the app never declared models / prompts.
|
|
@@ -1444,7 +1454,13 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1444
1454
|
const baseSignals = overrides?.signals ?? effectiveSignals;
|
|
1445
1455
|
const baseClocks = overrides?.clocks ?? effectiveClocks;
|
|
1446
1456
|
const baseChannel = overrides?.channel ?? effectiveChannel;
|
|
1447
|
-
const
|
|
1457
|
+
const mergedTemplates = [...(baseChannel?.templates ?? []), ...pluginChannelTemplates];
|
|
1458
|
+
const mergedCatalog = mergeTemplateCatalogs(
|
|
1459
|
+
catalogFromTemplates(baseChannel?.templates),
|
|
1460
|
+
baseChannel?.catalog,
|
|
1461
|
+
catalogFromTemplates(pluginChannelTemplates),
|
|
1462
|
+
...pluginChannelCatalogs,
|
|
1463
|
+
);
|
|
1448
1464
|
|
|
1449
1465
|
cdcPkByTable = pkColumnByTableFromManifest(overrides?.manifest ?? options.manifest);
|
|
1450
1466
|
|
|
@@ -1465,7 +1481,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1465
1481
|
stores: overrides?.stores ?? effectiveStores,
|
|
1466
1482
|
channel: {
|
|
1467
1483
|
...(baseChannel ?? {}),
|
|
1468
|
-
templates:
|
|
1484
|
+
templates: mergedTemplates,
|
|
1469
1485
|
...(mergedCatalog ? { catalog: mergedCatalog } : {}),
|
|
1470
1486
|
defaultLocale:
|
|
1471
1487
|
baseChannel?.defaultLocale ??
|
|
@@ -1965,7 +1981,8 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1965
1981
|
journalSession?.rewind();
|
|
1966
1982
|
return flowDef.do(input as never, fx);
|
|
1967
1983
|
};
|
|
1968
|
-
const
|
|
1984
|
+
const raw = await (flowDef.retry ? fxRetry(run, flowDef.retry) : run());
|
|
1985
|
+
const output = isFlowFailure(raw) ? raw : await projectFlowOut(flowDef.out, raw);
|
|
1969
1986
|
if (!isFlowFailure(output) && cache && storeRt && cacheEffects) {
|
|
1970
1987
|
const ledgerFx = cache.effectsFromLedger(ledger.entries);
|
|
1971
1988
|
const writeEffects: Effects = {
|
|
@@ -2616,28 +2633,5 @@ function redactArchivedFields(input: unknown, fields: readonly string[] | undefi
|
|
|
2616
2633
|
return obj;
|
|
2617
2634
|
}
|
|
2618
2635
|
|
|
2619
|
-
/**
|
|
2620
|
-
* Deep-merge channel template catalogs (later parts win per locale).
|
|
2621
|
-
*
|
|
2622
|
-
* @param parts - Catalog fragments (undefined skipped)
|
|
2623
|
-
*/
|
|
2624
|
-
function mergeTemplateCatalogs(
|
|
2625
|
-
...parts: readonly (TemplateCatalog | undefined)[]
|
|
2626
|
-
): TemplateCatalog | undefined {
|
|
2627
|
-
const out: Record<
|
|
2628
|
-
string,
|
|
2629
|
-
Record<string, { readonly subject?: string; readonly text?: string; readonly html?: string }>
|
|
2630
|
-
> = {};
|
|
2631
|
-
let any = false;
|
|
2632
|
-
for (const part of parts) {
|
|
2633
|
-
if (!part) continue;
|
|
2634
|
-
any = true;
|
|
2635
|
-
for (const [template, locales] of Object.entries(part)) {
|
|
2636
|
-
out[template] = { ...(out[template] ?? {}), ...locales };
|
|
2637
|
-
}
|
|
2638
|
-
}
|
|
2639
|
-
return any ? out : undefined;
|
|
2640
|
-
}
|
|
2641
|
-
|
|
2642
2636
|
/** @internal expose smart router type for tests */
|
|
2643
2637
|
export type { HttpTrigger, SmartRouter };
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { describe, expect, test } from "bun:test";
|
|
16
|
-
import { channel } from "../elements/channel/declare.ts";
|
|
16
|
+
import { channel, resetChannelTemplates } from "../elements/channel/declare.ts";
|
|
17
17
|
import { clock } from "../elements/clock/declare.ts";
|
|
18
18
|
import { gate } from "../elements/gate/declare.ts";
|
|
19
19
|
import { signal } from "../elements/signal/declare.ts";
|
|
@@ -291,4 +291,29 @@ describe("oke() auto-registry — stores/secrets/signals/clocks/gates/channel.te
|
|
|
291
291
|
const json = (await res.json()) as { data: { summary: string } };
|
|
292
292
|
expect(json.data.summary).toBe("one-line summary");
|
|
293
293
|
});
|
|
294
|
+
|
|
295
|
+
test("channel.template({ catalog }) drains into oke() without channel.catalog bag", () => {
|
|
296
|
+
resetBindings();
|
|
297
|
+
resetChannelTemplates();
|
|
298
|
+
const mail = channel.email({ from: "Drain <drain@localhost>" });
|
|
299
|
+
mail.template("welcome-drain", {
|
|
300
|
+
locales: ["en"],
|
|
301
|
+
catalog: {
|
|
302
|
+
en: { subject: "Hi {{name}}", text: "Hello {{name}}" },
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
const sms = channel.sms();
|
|
306
|
+
sms.template("ops.drain", {
|
|
307
|
+
catalog: { en: { text: "Disk {{pct}}%" } },
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const app = oke({
|
|
311
|
+
name: "catalog-drain",
|
|
312
|
+
autoBoot: false,
|
|
313
|
+
startScheduler: false,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
expect(app.$options.channel?.catalog?.["welcome-drain"]?.en?.subject).toBe("Hi {{name}}");
|
|
317
|
+
expect(app.$options.channel?.catalog?.["ops.drain"]?.en?.text).toBe("Disk {{pct}}%");
|
|
318
|
+
});
|
|
294
319
|
});
|
package/src/kernel/boot.ts
CHANGED
|
@@ -804,7 +804,7 @@ async function tryListFlowsUnits(rootDir: string): Promise<readonly string[] | u
|
|
|
804
804
|
/**
|
|
805
805
|
* Confirm every `src/flows/<unit>` folder on disk actually reached this
|
|
806
806
|
* boot's adopted flows — the disk-file counterpart of a stale/missing
|
|
807
|
-
* generated `.adopt()` barrel (`src/flows/
|
|
807
|
+
* generated `.adopt()` barrel (`src/flows/index.ts`). A folder present
|
|
808
808
|
* on disk with zero adopted flows under that unit means the barrel wasn't
|
|
809
809
|
* regenerated (or was hand-edited) after the folder was added.
|
|
810
810
|
*
|
|
@@ -846,7 +846,7 @@ export async function assertAdoptBarrelFresh(
|
|
|
846
846
|
staleAdoptBarrelWarned = true;
|
|
847
847
|
emitBootWarn(
|
|
848
848
|
`oke boot: src/flows/${missing[0]} exists on disk but adopted no flows — the ` +
|
|
849
|
-
'.adopt() barrel ("src/flows/
|
|
849
|
+
'.adopt() barrel ("src/flows/index.ts") is stale. Run `oke dev` or `oke build` ' +
|
|
850
850
|
"to regenerate it — dev+compose/prod refuse to boot this way.",
|
|
851
851
|
);
|
|
852
852
|
}
|
|
@@ -16,7 +16,12 @@ export interface BoundaryContract<
|
|
|
16
16
|
> {
|
|
17
17
|
/** Request / args schema (Standard Schema). */
|
|
18
18
|
readonly in?: ISchema;
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Success reply schema (Manifest + typed client). Success values are
|
|
21
|
+
* projected onto it (`fx.json.create(row)` / `return row`) — Date timestamps
|
|
22
|
+
* become ISO-8601; extra keys strip when parse succeeds. Miss is not a client
|
|
23
|
+
* error.
|
|
24
|
+
*/
|
|
20
25
|
readonly out?: OSchema;
|
|
21
26
|
/** Typed domain errors (`fx.fail`). */
|
|
22
27
|
readonly errors?: E;
|
package/src/kernel/errors.ts
CHANGED
|
@@ -239,7 +239,7 @@ export const OKE_ERRORS = {
|
|
|
239
239
|
},
|
|
240
240
|
/**
|
|
241
241
|
* A `src/flows/<unit>` folder exists on disk but no adopted flow carries
|
|
242
|
-
* that unit — the generated `.adopt()` barrel (`src/flows/
|
|
242
|
+
* that unit — the generated `.adopt()` barrel (`src/flows/index.ts`)
|
|
243
243
|
* is stale or was hand-edited. dev+compose / prod — never a silently-incomplete
|
|
244
244
|
* route table in a deploy-shaped environment.
|
|
245
245
|
*/
|
|
@@ -247,7 +247,7 @@ export const OKE_ERRORS = {
|
|
|
247
247
|
code: 1030,
|
|
248
248
|
domain: "kernel",
|
|
249
249
|
cause: "src/flows/{unit} exists on disk but adopted no flows — the .adopt() barrel is stale.",
|
|
250
|
-
fix: "Run `oke dev` or `oke build` to regenerate `src/flows/
|
|
250
|
+
fix: "Run `oke dev` or `oke build` to regenerate `src/flows/index.ts`.",
|
|
251
251
|
},
|
|
252
252
|
/**
|
|
253
253
|
* `http.get()` was never stamped from the file tree — refuse a silent `/`.
|
|
@@ -256,7 +256,7 @@ export const OKE_ERRORS = {
|
|
|
256
256
|
code: 1040,
|
|
257
257
|
domain: "kernel",
|
|
258
258
|
cause: 'Flow "{flow}" bound {method} with no path — the file-tree stamp never ran.',
|
|
259
|
-
fix: 'Put the file under `src/flows/<unit>/` and import `@/flows
|
|
259
|
+
fix: 'Put the file under `src/flows/<unit>/` and import `@/flows`, or pass an explicit path to `http.{method}("/…")`.',
|
|
260
260
|
},
|
|
261
261
|
/**
|
|
262
262
|
* Two HTTP bindings share method + path — last-add-wins is the opposite of this DX.
|
package/src/kernel/flow-units.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Module-evaluation registry for `src/flows/
|
|
2
|
+
* Module-evaluation registry for `src/flows/index.ts`.
|
|
3
3
|
*
|
|
4
|
-
* Same consume/ignore posture as `on()` / `listBindings()`:
|
|
4
|
+
* Same consume/ignore posture as `on()` / `listBindings()`: the adopt barrel
|
|
5
5
|
* calls {@link registerFlowUnits}; {@link oke} drains the bag into `$routes`
|
|
6
6
|
* unless `registry: "ignore"`.
|
|
7
7
|
*/
|
|
@@ -19,7 +19,7 @@ const pending: Record<string, FlowUnitBag> = {};
|
|
|
19
19
|
/**
|
|
20
20
|
* Record generated flow units for the next {@link oke} construction.
|
|
21
21
|
*
|
|
22
|
-
* @param units - `{ notes, main, … }` from `
|
|
22
|
+
* @param units - `{ notes, main, … }` from `src/flows/index.ts`
|
|
23
23
|
*/
|
|
24
24
|
export function registerFlowUnits(units: Record<string, FlowUnitBag>): void {
|
|
25
25
|
Object.assign(pending, units);
|
package/src/kernel/fx.ts
CHANGED
|
@@ -182,7 +182,11 @@ export interface FxTenant {
|
|
|
182
182
|
|
|
183
183
|
/** Clock surface on `fx`. */
|
|
184
184
|
export interface FxClock {
|
|
185
|
-
/**
|
|
185
|
+
/**
|
|
186
|
+
* Current epoch-ms (injectable via {@link CreateFxOptions.now}).
|
|
187
|
+
* Pass into SQL `timestamp` / `date` columns as-is — store insert/update
|
|
188
|
+
* and WHERE binds coerce finite numbers and parseable ISO strings to `Date`.
|
|
189
|
+
*/
|
|
186
190
|
now(): number;
|
|
187
191
|
/**
|
|
188
192
|
* Instant `duration` before {@link FxClock.now} (`"30d"` → now − 30 days).
|
|
@@ -465,11 +469,17 @@ export type JsonPage<T> = {
|
|
|
465
469
|
* JSON response helpers. `fx.json.create` answers 201; `fx.json.ok` can carry
|
|
466
470
|
* a top-level `meta` (Stripe-style `{ data, meta?, error }`);
|
|
467
471
|
* `fx.json.empty` answers 204.
|
|
472
|
+
* When the exposure declares `out`, the kernel projects the success value onto
|
|
473
|
+
* that schema — `fx.json.create(row)` is enough; no Date→ISO mapper.
|
|
468
474
|
*/
|
|
469
475
|
export interface FxJson {
|
|
470
476
|
/** 200 — body `{ data: value, meta?, error: null }`. */
|
|
471
477
|
ok<T>(value: T, opts?: { readonly meta?: Record<string, unknown> }): JsonResult<T>;
|
|
472
|
-
/**
|
|
478
|
+
/**
|
|
479
|
+
* 201 — body `{ data: value, error: null }`.
|
|
480
|
+
* When the exposure declares `out`, the kernel projects `value` onto that
|
|
481
|
+
* schema (Date → ISO-8601, extra keys stripped) so a store `row` is enough.
|
|
482
|
+
*/
|
|
473
483
|
create<T>(value: T): JsonResult<T>;
|
|
474
484
|
/** 204 — no body. */
|
|
475
485
|
empty(): JsonResult<never>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-path mutation id header — shared by the kernel HTTP glue and
|
|
3
|
+
* `okengine/client-react` optimistic mutate. Keep this file free of Node
|
|
4
|
+
* APIs so Vite SPAs can import `Can` / `useLiveQuery`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Header echoed into `mutationId` on write-path live-query events. */
|
|
8
|
+
export const MUTATION_ID_HEADER = "x-oke-mutation-id";
|
package/src/kernel/plugin.ts
CHANGED
|
@@ -290,9 +290,10 @@ export interface PluginApi {
|
|
|
290
290
|
*/
|
|
291
291
|
channelTemplate(decl: ChannelTemplateDecl): PluginApi;
|
|
292
292
|
/**
|
|
293
|
-
* Contribute template body catalog entries
|
|
293
|
+
* Contribute template body catalog entries — overlay merged into boot
|
|
294
|
+
* channel catalog after `.template({ catalog })` bodies (`{{field}}`).
|
|
294
295
|
*
|
|
295
|
-
* @param catalog - Locale bodies keyed by template name
|
|
296
|
+
* @param catalog - Locale bodies keyed by template name
|
|
296
297
|
*/
|
|
297
298
|
channelCatalog(catalog: TemplateCatalog): PluginApi;
|
|
298
299
|
}
|
|
@@ -475,7 +476,7 @@ export interface PluginDef<D extends Record<string, unknown> = {}> {
|
|
|
475
476
|
gate(decl: GateDecl | GateAllDecl): PluginDef<D>;
|
|
476
477
|
/** Queue a channel template. */
|
|
477
478
|
channelTemplate(decl: ChannelTemplateDecl): PluginDef<D>;
|
|
478
|
-
/** Queue channel template body catalog entries. */
|
|
479
|
+
/** Queue channel template body catalog entries (overlay after `.template({ catalog })`). */
|
|
479
480
|
channelCatalog(catalog: TemplateCatalog): PluginDef<D>;
|
|
480
481
|
}
|
|
481
482
|
|