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.
Files changed (108) hide show
  1. package/AGENTS.md +1 -1
  2. package/package.json +1 -1
  3. package/site/content/docs/client/auth.mdx +1 -2
  4. package/site/content/docs/client/calling.mdx +8 -8
  5. package/site/content/docs/client/index.mdx +4 -4
  6. package/site/content/docs/client/react.mdx +5 -0
  7. package/site/content/docs/elements/channel/email.mdx +25 -36
  8. package/site/content/docs/elements/channel/index.mdx +88 -46
  9. package/site/content/docs/elements/channel/push.mdx +7 -9
  10. package/site/content/docs/elements/channel/sms.mdx +6 -4
  11. package/site/content/docs/elements/channel/whatsapp.mdx +11 -9
  12. package/site/content/docs/elements/clock/index.mdx +14 -25
  13. package/site/content/docs/elements/flow/http.mdx +24 -8
  14. package/site/content/docs/elements/flow/index.mdx +15 -12
  15. package/site/content/docs/elements/flow/routing.mdx +166 -122
  16. package/site/content/docs/elements/gate/rls.mdx +2 -2
  17. package/site/content/docs/elements/store/index.mdx +11 -3
  18. package/site/content/docs/elements/store/search.mdx +5 -5
  19. package/site/content/docs/elements/store/sql.mdx +91 -33
  20. package/site/content/docs/elements/vault/index.mdx +3 -5
  21. package/site/content/docs/plugins/magic-link.mdx +4 -3
  22. package/site/content/docs/plugins/otp.mdx +4 -3
  23. package/site/content/docs/plugins/two-factor.mdx +4 -0
  24. package/site/content/docs/providers/index.mdx +1 -1
  25. package/site/content/docs/recipes/index.mdx +1 -1
  26. package/site/content/docs/reference/cli.mdx +13 -4
  27. package/site/content/docs/reference/configuration.mdx +7 -7
  28. package/site/content/docs/reference/errors.mdx +30 -30
  29. package/site/content/docs/reference/fx.mdx +16 -8
  30. package/site/content/docs/reference/i18n.mdx +4 -4
  31. package/site/content/docs/reference/plugins.mdx +4 -4
  32. package/site/content/docs/understand/try-it.mdx +2 -0
  33. package/src/cli/ai-setup/ai-setup.test.ts +40 -0
  34. package/src/cli/ai-setup/apply.ts +28 -46
  35. package/src/cli/ask-vault-gaps.test.ts +60 -4
  36. package/src/cli/ask-vault-gaps.ts +59 -3
  37. package/src/cli/build.test.ts +3 -3
  38. package/src/cli/build.ts +5 -5
  39. package/src/cli/db-auto-push.test.ts +11 -0
  40. package/src/cli/db-auto-push.ts +6 -2
  41. package/src/cli/db.test.ts +1 -1
  42. package/src/cli/db.ts +6 -6
  43. package/src/cli/dev-app-runner.ts +12 -4
  44. package/src/cli/dev-db-push.test.ts +6 -2
  45. package/src/cli/dev-schema-sync.ts +1 -1
  46. package/src/cli/dev.test.ts +10 -7
  47. package/src/cli/dev.ts +18 -12
  48. package/src/cli/ensure-drizzle-config.ts +4 -3
  49. package/src/client-react/browser.test.ts +23 -0
  50. package/src/client-react/use-live-query.ts +1 -1
  51. package/src/compiler/flow-path.test.ts +1 -0
  52. package/src/compiler/flow-path.ts +1 -1
  53. package/src/compiler/generate-adopt.test.ts +55 -1
  54. package/src/compiler/generate-adopt.ts +111 -21
  55. package/src/config/index.ts +6 -4
  56. package/src/console/ui-next/dist/assets/{access-page-C5yG4aS2.js → access-page-DFLu0wTA.js} +1 -1
  57. package/src/console/ui-next/dist/assets/{agent-disclosure-D2ToVK86.js → agent-disclosure-DGscxaF5.js} +1 -1
  58. package/src/console/ui-next/dist/assets/{cache-glyph-7HUR0kCz.js → cache-glyph-BGmRZk7d.js} +1 -1
  59. package/src/console/ui-next/dist/assets/{call-pii-button-D0ky3aXt.js → call-pii-button--feUYxvG.js} +1 -1
  60. package/src/console/ui-next/dist/assets/{collapsible-BLUHH2dB.js → collapsible-JWvpaiGY.js} +1 -1
  61. package/src/console/ui-next/dist/assets/{duration-tone-BgHEFMtm.js → duration-tone-D9yCJG4n.js} +1 -1
  62. package/src/console/ui-next/dist/assets/{flows-page-DDybqshQ.js → flows-page-Bs6MD9GB.js} +1 -1
  63. package/src/console/ui-next/dist/assets/{highlighted-json-BFiJKYV4.js → highlighted-json-xH8MrEnv.js} +1 -1
  64. package/src/console/ui-next/dist/assets/{http-method-BVAcB0bI.js → http-method-C4vB6ZIw.js} +1 -1
  65. package/src/console/ui-next/dist/assets/{index-BID6LSYI.js → index-yTCY4AcS.js} +3 -3
  66. package/src/console/ui-next/dist/assets/{observability-page-CANo_mMK.js → observability-page-BxJ3R6dU.js} +1 -1
  67. package/src/console/ui-next/dist/assets/{replica-lag-CqQGCshp.js → replica-lag-QRKB_IE8.js} +1 -1
  68. package/src/console/ui-next/dist/assets/{request-meta-DyUDkEt4.js → request-meta-DqZ-fMu5.js} +1 -1
  69. package/src/console/ui-next/dist/assets/{store-page-CA0rJ_Ow.js → store-page-Dixb6L7a.js} +1 -1
  70. package/src/console/ui-next/dist/assets/{trace-detail-sheet-DjH_fMzI.js → trace-detail-sheet-CazhjtiU.js} +1 -1
  71. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWSomIeQ.js → tree-expand-toggle-DlnqYKfr.js} +1 -1
  72. package/src/console/ui-next/dist/assets/{units-page-Cz9TIViw.js → units-page-BXTLjU2-.js} +1 -1
  73. package/src/console/ui-next/dist/assets/{vault-page-Bb1o1O9p.js → vault-page-39KR__bc.js} +1 -1
  74. package/src/console/ui-next/dist/index.html +1 -1
  75. package/src/drivers/clock-postgres.test.ts +10 -2
  76. package/src/drivers/clock-postgres.ts +18 -2
  77. package/src/drivers/vault-driver-removal.test.ts +2 -2
  78. package/src/elements/channel/declare.ts +66 -3
  79. package/src/elements/channel/runtime.ts +9 -11
  80. package/src/elements/channel.test.ts +42 -0
  81. package/src/elements/channel.ts +4 -2
  82. package/src/elements/clock/reconcile.ts +45 -24
  83. package/src/elements/clock.test.ts +33 -0
  84. package/src/elements/store/emit-drizzle.ts +285 -65
  85. package/src/elements/store/load-plugin-tables.ts +1 -1
  86. package/src/elements/store/prepare-row.test.ts +57 -4
  87. package/src/elements/store/schema-decl.test.ts +178 -0
  88. package/src/elements/store/sql-session.ts +44 -2
  89. package/src/elements/store/table.ts +8 -6
  90. package/src/kernel/adopt-barrel-fresh.test.ts +1 -1
  91. package/src/kernel/app.ts +26 -32
  92. package/src/kernel/auto-registry.test.ts +26 -1
  93. package/src/kernel/boot.ts +2 -2
  94. package/src/kernel/boundary-contract.ts +6 -1
  95. package/src/kernel/errors.ts +3 -3
  96. package/src/kernel/flow-units.ts +3 -3
  97. package/src/kernel/fx.ts +12 -2
  98. package/src/kernel/mutation-id.ts +8 -0
  99. package/src/kernel/plugin.ts +4 -3
  100. package/src/kernel/project-out.test.ts +176 -0
  101. package/src/kernel/project-out.ts +91 -0
  102. package/src/kernel/realtime-bind.ts +2 -3
  103. package/src/kernel/router/linear.ts +12 -6
  104. package/src/kernel/router.test.ts +13 -0
  105. package/src/plugins/magic-link.ts +25 -24
  106. package/src/plugins/otp.ts +35 -24
  107. package/src/plugins/two-factor.ts +15 -0
  108. package/src/runs/duckdb.test.ts +2 -2
@@ -54,7 +54,7 @@ export const app = oke({ name: "shop", env: "dev" }).plug(audit);
54
54
  <Step>
55
55
  ### Everything derives as usual
56
56
 
57
- Plugin flows appear in the Manifest, plugin tables land in `schema.drizzle.ts` on the next `oke db push`, plugin panels show up in the Console. No extra wiring — a contribution is ordinary OKE, just authored elsewhere.
57
+ Plugin flows appear in the Manifest, plugin tables land in `src/db/drizzle/` on the next `oke db push`, plugin panels show up in the Console. No extra wiring — a contribution is ordinary OKE, just authored elsewhere.
58
58
 
59
59
  </Step>
60
60
 
@@ -76,8 +76,8 @@ Every method below exists on both the fluent definition and the boot-time builde
76
76
  | `.clock(decl)` | A named clock schedule — merged into boot clocks |
77
77
  | `.signal(decl)` | A signal declaration — merged into boot signals |
78
78
  | `.gate(decl)` | A gate declaration — merged into boot gates |
79
- | `.channelTemplate(decl)` | A channel template — merged into boot channel templates |
80
- | `.channelCatalog(catalog)` | Template body catalog entries — merged into boot channel catalog (`{{field}}` interpolation) |
79
+ | `.channelTemplate(decl)` | `channel.<medium>().template(…)` — `catalog` on the decl drains into boot |
80
+ | `.channelCatalog(catalog)` | Overlay bodies after `.template({ catalog })` (`{{field}}`); later locale wins |
81
81
  | `.driver(id, impl)` | A protocol-named driver for an existing element |
82
82
  | `.image(role, recipe)` | An image recipe for a docker role |
83
83
  | `.table(name, columns, options)` | A whole DB table, merged into the generated schema (`options.description` / `plane` optional) |
@@ -226,7 +226,7 @@ Extending an existing **app-owned** table with plugin columns is not supported i
226
226
  two configurations of one plugin would silently diverge. Pass identical config, or rename one
227
227
  instance.
228
228
  </Accordion>
229
- <Accordion title="My plugin table is missing from schema.drizzle.ts">
229
+ <Accordion title="My plugin table is missing from drizzle/">
230
230
  The CLI reads table contributions from the live app entry. Make sure the plugin is actually
231
231
  `.plug()`ed in `src/app.ts` (or `db.entry` if overridden), then re-run `oke db push`.
232
232
  </Accordion>
@@ -21,6 +21,8 @@ cd my-app
21
21
  bun run dev
22
22
  ```
23
23
 
24
+ Default is `-t blank` (health + Store/Vault). `-t shorter` is a URL shortener (`POST /links`, `GET /:code`).
25
+
24
26
  Open the Console at the address printed in the terminal and claim it with the code shown there. The Flows listed weren't configured anywhere — they were derived from the code you just scaffolded.
25
27
 
26
28
  ## Call it
@@ -464,6 +464,46 @@ export default defineConfig({
464
464
  }
465
465
  });
466
466
 
467
+ test("applyAiSetup writes src/core/ai.ts when the barrel is src/core/index.ts", () => {
468
+ const dir = mkdtempSync(join(tmpdir(), "oke-ai-setup-index-"));
469
+ try {
470
+ writeFileSync(
471
+ join(dir, "oke.config.ts"),
472
+ `import { defineConfig } from "okengine/config";
473
+ export default defineConfig({
474
+ drivers: {
475
+ channel: { email: { dev: "console", test: "console", prod: "console" } },
476
+ },
477
+ });
478
+ `,
479
+ "utf8",
480
+ );
481
+ mkdirSync(join(dir, "src", "core"), { recursive: true });
482
+ writeFileSync(join(dir, "src", "core", "index.ts"), `export * from "./store.ts";\n`, "utf8");
483
+ writeFileSync(
484
+ join(dir, "src", "app.ts"),
485
+ `import "@/core";\nexport const app = {};\n`,
486
+ "utf8",
487
+ );
488
+
489
+ applyAiSetup(dir, {
490
+ driver: "openai-compatible",
491
+ provider: "openrouter",
492
+ chatModel: "openrouter/free",
493
+ });
494
+
495
+ const index = readFileSync(join(dir, "src", "core", "index.ts"), "utf8");
496
+ expect(index).not.toContain("ai.model");
497
+ expect(index).toContain('export * from "./ai.ts"');
498
+ const aiTs = readFileSync(join(dir, "src", "core", "ai.ts"), "utf8");
499
+ expect(aiTs).toContain("smart");
500
+ const app = readFileSync(join(dir, "src", "app.ts"), "utf8");
501
+ expect(app).not.toContain("./core/ai");
502
+ } finally {
503
+ rmSync(dir, { recursive: true, force: true });
504
+ }
505
+ });
506
+
467
507
  test("applyAiSetup writes API token to .env.local and vault.secret into core", () => {
468
508
  const dir = mkdtempSync(join(tmpdir(), "oke-ai-setup-key-"));
469
509
  try {
@@ -434,8 +434,8 @@ export function ensureAiApiKeyVaultSecret(cwd: string, apiKeyEnv: string): void
434
434
 
435
435
  /**
436
436
  * Write AI model declarations into `src/core/ai.ts` when that split exists
437
- * (so a thin `src/core.ts` barrel stays a re-export), else `src/core.ts`,
438
- * else legacy `src/core/index.ts` + sidecar.
437
+ * (so a thin `src/core.ts` / `src/core/index.ts` barrel stays a re-export),
438
+ * else `src/core.ts`.
439
439
  *
440
440
  * @param cwd - Project root
441
441
  * @param input - Setup choices
@@ -445,25 +445,23 @@ function writeAiModels(cwd: string, input: AiSetupApplyInput): string {
445
445
  const rendered = renderAiTs(input);
446
446
  const coreAiPath = join(cwd, "src", "core", "ai.ts");
447
447
  const coreTsPath = join(cwd, "src", "core.ts");
448
- const legacyIndex = join(cwd, "src", "core", "index.ts");
449
-
450
- if (existsSync(coreAiPath)) {
451
- const existing = readFileSync(coreAiPath, "utf8");
452
- writeFileSync(coreAiPath, resolveAiCoreSource(existing, rendered), "utf8");
448
+ const coreIndexPath = join(cwd, "src", "core", "index.ts");
449
+ const splitLayout =
450
+ existsSync(coreAiPath) || (!existsSync(coreTsPath) && existsSync(coreIndexPath));
451
+
452
+ if (splitLayout) {
453
+ mkdirSync(dirname(coreAiPath), { recursive: true });
454
+ const existing = existsSync(coreAiPath) ? readFileSync(coreAiPath, "utf8") : "";
455
+ writeFileSync(
456
+ coreAiPath,
457
+ existing ? resolveAiCoreSource(existing, rendered) : rendered,
458
+ "utf8",
459
+ );
453
460
  ensureCoreBarrelExportsAi(cwd);
454
461
  ensureCoreImported(cwd);
455
462
  return coreAiPath;
456
463
  }
457
464
 
458
- // Folder layout still in the wild — keep writing a sidecar.
459
- if (!existsSync(coreTsPath) && existsSync(legacyIndex)) {
460
- const aiTsPath = join(cwd, "src", "core", "ai.ts");
461
- mkdirSync(dirname(aiTsPath), { recursive: true });
462
- writeFileSync(aiTsPath, rendered, "utf8");
463
- ensureLegacyAiImported(cwd);
464
- return aiTsPath;
465
- }
466
-
467
465
  mkdirSync(dirname(coreTsPath), { recursive: true });
468
466
  if (existsSync(coreTsPath)) {
469
467
  const existing = readFileSync(coreTsPath, "utf8");
@@ -625,16 +623,25 @@ export function ensureNamedOkengineImport(source: string, name: string): string
625
623
  }
626
624
 
627
625
  /**
628
- * Keep a `src/core.ts` barrel re-exporting `./core/ai.ts` after a split write.
626
+ * Keep a core barrel re-exporting the AI sidecar after a split write.
627
+ *
628
+ * `src/core.ts` → `./core/ai.ts`; `src/core/index.ts` → `./ai.ts`.
629
629
  *
630
630
  * @param cwd - Project root
631
631
  */
632
632
  function ensureCoreBarrelExportsAi(cwd: string): void {
633
633
  const coreTsPath = join(cwd, "src", "core.ts");
634
- if (!existsSync(coreTsPath)) return;
635
- const src = readFileSync(coreTsPath, "utf8");
636
- if (/from\s+["']\.\/core\/ai/.test(src)) return;
637
- writeFileSync(coreTsPath, `${src.trimEnd()}\nexport * from "./core/ai.ts";\n`, "utf8");
634
+ if (existsSync(coreTsPath)) {
635
+ const src = readFileSync(coreTsPath, "utf8");
636
+ if (/from\s+["']\.\/core\/ai(?:\.ts)?["']/.test(src)) return;
637
+ writeFileSync(coreTsPath, `${src.trimEnd()}\nexport * from "./core/ai.ts";\n`, "utf8");
638
+ return;
639
+ }
640
+ const coreIndexPath = join(cwd, "src", "core", "index.ts");
641
+ if (!existsSync(coreIndexPath)) return;
642
+ const src = readFileSync(coreIndexPath, "utf8");
643
+ if (/from\s+["']\.\/ai(?:\.ts)?["']/.test(src)) return;
644
+ writeFileSync(coreIndexPath, `${src.trimEnd()}\nexport * from "./ai.ts";\n`, "utf8");
638
645
  }
639
646
 
640
647
  /**
@@ -656,28 +663,3 @@ function ensureCoreImported(cwd: string): void {
656
663
  }
657
664
  writeFileSync(appPath, `import "@/core";\n${src}`, "utf8");
658
665
  }
659
-
660
- /**
661
- * Legacy `src/core/*` layout — side-effect import the AI sidecar.
662
- *
663
- * @param cwd - Project root
664
- */
665
- function ensureLegacyAiImported(cwd: string): void {
666
- const candidates: ReadonlyArray<{ readonly rel: string; readonly importLine: string }> = [
667
- { rel: "src/app.ts", importLine: `import "./core/ai";\n` },
668
- { rel: "src/core/index.ts", importLine: `import "./ai";\n` },
669
- ];
670
- for (const { rel, importLine } of candidates) {
671
- const path = join(cwd, rel);
672
- if (!existsSync(path)) continue;
673
- const src = readFileSync(path, "utf8");
674
- if (
675
- /from\s+["']\.\/(?:core\/)?ai["']/.test(src) ||
676
- /import\s+["']\.\/(?:core\/)?ai["']/.test(src)
677
- ) {
678
- return;
679
- }
680
- writeFileSync(path, `${importLine}${src}`, "utf8");
681
- return;
682
- }
683
- }
@@ -2,11 +2,21 @@
2
2
  * Tests for interactive Vault gap fill during `oke dev`.
3
3
  */
4
4
 
5
- import { describe, expect, test } from "bun:test";
6
- import { mkdtempSync, readFileSync, rmSync } from "node:fs";
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { tmpdir } from "node:os";
8
- import { join } from "node:path";
9
- import { maybeAskVaultGaps } from "./ask-vault-gaps.ts";
8
+ import { join, resolve } from "node:path";
9
+ import { resetRequiredEnvNames, resetSecrets } from "../elements/vault/declare.ts";
10
+ import { resetBindings } from "../kernel/on.ts";
11
+ import { maybeAskVaultGaps, probeVaultGaps } from "./ask-vault-gaps.ts";
12
+
13
+ const OKE = resolve(import.meta.dir, "../index.ts");
14
+
15
+ afterEach(() => {
16
+ resetBindings();
17
+ resetSecrets();
18
+ resetRequiredEnvNames();
19
+ });
10
20
 
11
21
  describe("maybeAskVaultGaps", () => {
12
22
  test("skips when non-TTY", async () => {
@@ -82,3 +92,49 @@ describe("maybeAskVaultGaps", () => {
82
92
  }
83
93
  });
84
94
  });
95
+
96
+ describe("probeVaultGaps", () => {
97
+ test("sees secrets on oke({ secrets }) after consume drains the registry", async () => {
98
+ const dir = mkdtempSync(join(tmpdir(), "probe-vault-"));
99
+ const name = `PROBE_GAP_${crypto.randomUUID().replaceAll("-", "_")}`;
100
+ try {
101
+ writeFileSync(
102
+ join(dir, "app.ts"),
103
+ `
104
+ import { oke, vault } from ${JSON.stringify(OKE)};
105
+ export const key = vault.secret(${JSON.stringify(name)}, {
106
+ description: "probe gap",
107
+ });
108
+ export const app = oke({
109
+ name: "probe-vault",
110
+ autoBoot: false,
111
+ secrets: [key],
112
+ });
113
+ `,
114
+ );
115
+ const gaps = await probeVaultGaps(dir, "app.ts");
116
+ expect(gaps.map((g) => g.name)).toContain(name);
117
+ } finally {
118
+ rmSync(dir, { recursive: true, force: true });
119
+ }
120
+ });
121
+
122
+ test("sees auto-registered vault.secret after oke() consume", async () => {
123
+ const dir = mkdtempSync(join(tmpdir(), "probe-vault-auto-"));
124
+ const name = `PROBE_AUTO_${crypto.randomUUID().replaceAll("-", "_")}`;
125
+ try {
126
+ writeFileSync(
127
+ join(dir, "app.ts"),
128
+ `
129
+ import { oke, vault } from ${JSON.stringify(OKE)};
130
+ vault.secret(${JSON.stringify(name)}, { description: "auto registry gap" });
131
+ export const app = oke({ name: "probe-vault-auto", autoBoot: false });
132
+ `,
133
+ );
134
+ const gaps = await probeVaultGaps(dir, "app.ts");
135
+ expect(gaps.map((g) => g.name)).toContain(name);
136
+ } finally {
137
+ rmSync(dir, { recursive: true, force: true });
138
+ }
139
+ });
140
+ });
@@ -9,8 +9,10 @@ import { resolve } from "node:path";
9
9
  import { resolveDriverId } from "../config/index.ts";
10
10
  import { VAULT_DEFAULTS } from "../config/driver-defaults.ts";
11
11
  import { resolveAppEntryForPluginTables } from "../elements/store/load-plugin-tables.ts";
12
+ import type { VaultSecretDecl } from "../elements/vault/declare.ts";
12
13
  import { buildVaultBootChain, normalizeVaultDriverId } from "../elements/vault/boot-chain.ts";
13
14
  import { createVaultRuntime, VaultBootError, type VaultGap } from "../elements/vault/runtime.ts";
15
+ import type { OkeOptions } from "../kernel/app.ts";
14
16
  import { requiredEnvRegistry, secretRegistry } from "../kernel/element-registries.ts";
15
17
  import { formatCliChrome } from "../term.ts";
16
18
  import { loadOkeConfig } from "./load-config.ts";
@@ -43,6 +45,10 @@ export interface AskVaultGapsOptions {
43
45
  /**
44
46
  * Probe Vault contracts declared by the app entry and return boot gaps.
45
47
  *
48
+ * `oke()` drains `secretRegistry` at construction (`registry: "consume"`),
49
+ * so the probe reads `app.$options.secrets` — the same list boot uses —
50
+ * and unions any leftovers still on the module-evaluation registries.
51
+ *
46
52
  * @param cwd - Project root
47
53
  * @param entry - Optional entry override
48
54
  */
@@ -54,9 +60,12 @@ export async function probeVaultGaps(cwd: string, entry?: string): Promise<reado
54
60
  try {
55
61
  const entryAbs = await resolveAppEntryForPluginTables(cwd, entry);
56
62
  if (!entryAbs) return [];
57
- await import(entryAbs);
58
- const secrets = secretRegistry.slice();
59
- const requiredEnv = requiredEnvRegistry.slice();
63
+ const mod = (await import(entryAbs)) as Record<string, unknown>;
64
+ const fromApp = vaultProbeContracts(mod);
65
+ const secrets = unionSecrets(fromApp?.secrets ?? [], secretRegistry.slice());
66
+ const requiredEnv = [
67
+ ...new Set([...(fromApp?.requiredEnv ?? []), ...requiredEnvRegistry.slice()]),
68
+ ];
60
69
  if (secrets.length === 0 && requiredEnv.length === 0) return [];
61
70
 
62
71
  const loaded = await loadOkeConfig(cwd).catch(() => null);
@@ -91,6 +100,53 @@ export async function probeVaultGaps(cwd: string, entry?: string): Promise<reado
91
100
  }
92
101
  }
93
102
 
103
+ /**
104
+ * Contracts the imported `oke()` app will boot with (`$options.secrets` /
105
+ * `vault.requiredEnv`). Undefined when the module has no bootable app.
106
+ *
107
+ * @param mod - Imported entry namespace
108
+ */
109
+ function vaultProbeContracts(mod: Record<string, unknown>):
110
+ | {
111
+ readonly secrets: readonly VaultSecretDecl[];
112
+ readonly requiredEnv: readonly string[];
113
+ }
114
+ | undefined {
115
+ for (const key of ["app", "default", ...Object.keys(mod)]) {
116
+ const value = mod[key];
117
+ if (!value || typeof value !== "object") continue;
118
+ const boot = (value as { boot?: unknown }).boot;
119
+ const options = (value as { $options?: OkeOptions }).$options;
120
+ if (typeof boot !== "function" || options === undefined) continue;
121
+ return {
122
+ secrets: options.secrets ?? [],
123
+ requiredEnv: options.vault?.requiredEnv ?? [],
124
+ };
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ /**
130
+ * Concat secret lists, skipping later entries that share a contract name.
131
+ *
132
+ * @param primary - App `$options.secrets` (boot source of truth)
133
+ * @param extra - Registry leftovers (`registry: "ignore"` / no `oke()` export)
134
+ */
135
+ function unionSecrets(
136
+ primary: readonly VaultSecretDecl[],
137
+ extra: readonly VaultSecretDecl[],
138
+ ): VaultSecretDecl[] {
139
+ const out: VaultSecretDecl[] = [...primary];
140
+ const names = new Set(primary.map((secret) => secret.name));
141
+ for (const secret of extra) {
142
+ if (!names.has(secret.name)) {
143
+ names.add(secret.name);
144
+ out.push(secret);
145
+ }
146
+ }
147
+ return out;
148
+ }
149
+
94
150
  /**
95
151
  * After compose hydrate (and usually schema push), fill Vault boot gaps
96
152
  * one-by-one into `.env.local`. Skips when non-TTY or no gaps.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `oke build` regenerates `src/flows/generated.ts` before bundling — real
2
+ * `oke build` regenerates `src/flows/index.ts` before bundling — real
3
3
  * before/after proof, not an assumption about the wiring.
4
4
  */
5
5
 
@@ -16,13 +16,13 @@ async function makeUnit(root: string, name: string): Promise<void> {
16
16
  }
17
17
 
18
18
  describe("oke build — .adopt() barrel regeneration", () => {
19
- test("real before/after: no generated.ts before build, real barrel content after", async () => {
19
+ test("real before/after: no index.ts before build, real barrel content after", async () => {
20
20
  const root = await mkdtemp(join(tmpdir(), "oke-build-adopt-"));
21
21
  try {
22
22
  await makeUnit(root, "main");
23
23
  await makeUnit(root, "notes");
24
24
 
25
- const generatedPath = join(root, "src/flows/generated.ts");
25
+ const generatedPath = join(root, "src/flows/index.ts");
26
26
  await expect(readFile(generatedPath, "utf8")).rejects.toThrow();
27
27
 
28
28
  const code = await runBuild({
package/src/cli/build.ts CHANGED
@@ -18,16 +18,16 @@ export interface BuildOptions {
18
18
  }) => Promise<{ success: boolean; logs: string }>;
19
19
  /**
20
20
  * Inject `.adopt()` barrel regeneration (tests). Default: real
21
- * `generateAdoptBarrel` + atomic write to `<rootDir>/src/flows/generated.ts`
22
- * (`generated.ts.tmp` → rename). Returns the unit names written, for the
21
+ * `generateAdoptBarrel` + atomic write to `<rootDir>/src/flows/index.ts`
22
+ * (`index.ts.tmp` → rename). Returns the unit names written, for the
23
23
  * status line.
24
24
  */
25
25
  readonly syncAdoptBarrel?: (rootDir: string) => Promise<readonly string[]>;
26
26
  }
27
27
 
28
28
  /**
29
- * Regenerate `src/flows/generated.ts` from every `src/flows/<unit>/index.ts`
30
- * unit folder — pre-step for `oke build` (mirrors `oke db`'s `schema.drizzle.ts`
29
+ * Regenerate `src/flows/index.ts` from every `src/flows/<unit>/` folder —
30
+ * pre-step for `oke build` (mirrors `oke db`'s `drizzle/`
31
31
  * pre-step). A real file on disk, not a virtual module: identical resolution
32
32
  * under `oke dev`'s runtime `import()` and `oke build`'s `Bun.build()`
33
33
  * (investigated — a virtual-module Bun plugin does not resolve consistently
@@ -76,7 +76,7 @@ export async function runBuild(options: BuildOptions = {}): Promise<number> {
76
76
  try {
77
77
  const units = await syncAdoptBarrel(rootDir);
78
78
  write(
79
- `oke build: .adopt() barrel → src/flows/generated.ts (${units.length} unit${units.length === 1 ? "" : "s"})\n`,
79
+ `oke build: .adopt() barrel → src/flows/index.ts (${units.length} unit${units.length === 1 ? "" : "s"})\n`,
80
80
  );
81
81
  const result = await bundle({ entry, outdir, target: bunTarget });
82
82
  if (!result.success) {
@@ -17,7 +17,18 @@ describe("isDomainSchemaWatchPath", () => {
17
17
  expect(isDomainSchemaWatchPath("src/schema.decl.ts")).toBe(true);
18
18
  });
19
19
 
20
+ test("matches split declare modules under schema/", () => {
21
+ expect(isDomainSchemaWatchPath("db/schema/links.ts")).toBe(true);
22
+ expect(isDomainSchemaWatchPath("src/db/schema/daily.ts")).toBe(true);
23
+ expect(isDomainSchemaWatchPath("src/db/schema/index.ts")).toBe(true);
24
+ expect(isDomainSchemaWatchPath("src/db/schema/relations.ts")).toBe(true);
25
+ });
26
+
20
27
  test("ignores emit output (avoids push ↔ generated feedback loop)", () => {
28
+ expect(isDomainSchemaWatchPath("src/db/drizzle.ts")).toBe(false);
29
+ expect(isDomainSchemaWatchPath("drizzle.ts")).toBe(false);
30
+ expect(isDomainSchemaWatchPath("src/db/drizzle/index.ts")).toBe(false);
31
+ expect(isDomainSchemaWatchPath("src/db/drizzle/links.ts")).toBe(false);
21
32
  expect(isDomainSchemaWatchPath("src/db/schema.drizzle.ts")).toBe(false);
22
33
  expect(isDomainSchemaWatchPath("schema.drizzle.ts")).toBe(false);
23
34
  expect(isDomainSchemaWatchPath("flows/notes/schema.drizzle.tsx")).toBe(false);
@@ -11,8 +11,8 @@ export const DB_AUTO_PUSH_DEBOUNCE_MS = 300;
11
11
  * Whether a watched path should trigger `oke db push`.
12
12
  *
13
13
  * Inputs only: declaration / drizzle config / app entry. Never the emit
14
- * output (`schema.drizzle.ts`) — push rewrites that file every run, so
15
- * watching it loops `oke db push` forever under `oke dev`.
14
+ * output (`src/db/drizzle/`) — push rewrites those files every run, so
15
+ * watching them loops `oke db push` forever under `oke dev`.
16
16
  *
17
17
  * @param filename - Relative path from the watcher (may be undefined)
18
18
  */
@@ -21,8 +21,10 @@ export function isDomainSchemaWatchPath(filename: string | null | undefined): bo
21
21
  const normalized = filename.replace(/\\/g, "/");
22
22
  const base = basename(normalized);
23
23
  // Emit output — never a watch trigger (feedback loop with `oke db push`).
24
+ if (base === "drizzle.ts" || base === "drizzle.tsx") return false;
24
25
  if (base === "schema.drizzle.ts" || base === "schema.drizzle.tsx") return false;
25
26
  if (base === "schema.generated.ts" || base === "schema.generated.tsx") return false;
27
+ if (/(^|\/)drizzle\/.+\.tsx?$/.test(normalized)) return false;
26
28
  if (base === "schema.ts" || base === "schema.tsx") return true;
27
29
  if (base === "schema.decl.ts" || base === "schema.decl.tsx") return true;
28
30
  if (base === "drizzle.config.ts" || base === "drizzle.config.js") return true;
@@ -30,6 +32,8 @@ export function isDomainSchemaWatchPath(filename: string | null | undefined): bo
30
32
  if (base === "app.ts" || base === "app.tsx") return true;
31
33
  // Nested domain schemas commonly live under src/**/schema.ts (not emit output).
32
34
  if (/(^|\/)schema(\.decl)?\.tsx?$/.test(normalized)) return true;
35
+ // Split declare modules: `src/db/schema/links.ts` next to `schema/index.ts`.
36
+ if (/(^|\/)schema\/.+\.tsx?$/.test(normalized)) return true;
33
37
  return false;
34
38
  }
35
39
 
@@ -327,7 +327,7 @@ export const app = oke({ name: "plugin-only" }).plug(
327
327
  const emitted = await emitAbstractSchemaPrestep(dir, loaded.config, () => {}, "test");
328
328
  expect(emitted).toBe(true);
329
329
 
330
- const generated = await readFile(join(dir, "src", "db", "schema.drizzle.ts"), "utf8");
330
+ const generated = await readFile(join(dir, "src", "db", "drizzle", "metrics_daily.ts"), "utf8");
331
331
  expect(generated).toContain('pgTable("metrics_daily"');
332
332
  // Postgres dialect maps abstract integer → bigint (32-bit INTEGER is too narrow).
333
333
  expect(generated).toContain('bigint("hits", { mode: "number" })');
package/src/cli/db.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  * plus explicit seed runs.
4
4
  *
5
5
  * Distinct from `oke schema generate` (core/plugin stub tables).
6
- * When abstract decls exist (`src/db/schema.decl.ts`), emits Drizzle first —
7
- * not a third schema CLI.
6
+ * When abstract decls exist (`src/db/schema.ts` or `src/db/schema/index.ts`),
7
+ * emits Drizzle first — not a third schema CLI.
8
8
  */
9
9
 
10
10
  import { resolve } from "node:path";
@@ -279,7 +279,7 @@ export async function runDb(sub: DbSubcommand, options: DbOptions = {}): Promise
279
279
  }
280
280
 
281
281
  /**
282
- * Emit `schema.drizzle.ts` from abstract decls + live plugged plugin tables.
282
+ * Emit `drizzle/` from abstract decls + live plugged plugin tables.
283
283
  *
284
284
  * @param cwd - Project root
285
285
  * @param config - Loaded oke config
@@ -596,9 +596,9 @@ export async function dbCli(args: readonly string[]): Promise<number> {
596
596
  console.log(`oke db push|generate|migrate|seed|studio|search-backfill [--config|-c] [--env name] [--force]
597
597
 
598
598
  Domain schema sync via drizzle-kit, plus explicit seed and hybrid-search backfill.
599
- When src/db/schema.decl.ts and/or a plugged app entry exists, emits
600
- schema.drizzle.ts from store.schema.table + live plugin .table()
601
- contributions — then runs drizzle-kit.
599
+ When src/db/schema.ts or src/db/schema/index.ts and/or a plugged app
600
+ entry exists, emits src/db/drizzle/ from store.schema.table + live plugin
601
+ .table() contributions — then runs drizzle-kit.
602
602
  Hand-written src/schema.ts remains supported (emit skipped if nothing to emit).
603
603
  Not the same as \`oke schema generate\` (core/plugin stub tables).
604
604
 
@@ -81,10 +81,18 @@ if (
81
81
  process.exit(1);
82
82
  }
83
83
 
84
- await mod.app.boot({
85
- rootDir: process.env["OKE_ROOT_DIR"],
86
- ...(manifest !== undefined ? { manifest } : {}),
87
- });
84
+ try {
85
+ await mod.app.boot({
86
+ rootDir: process.env["OKE_ROOT_DIR"],
87
+ ...(manifest !== undefined ? { manifest } : {}),
88
+ });
89
+ } catch (err) {
90
+ // `bun --hot` keeps the watcher alive on an uncaught throw, so `oke dev`
91
+ // would wait the full ready timeout after VaultBootError. Exit so the
92
+ // parent sees a dead child immediately.
93
+ console.error(err);
94
+ process.exit(1);
95
+ }
88
96
  const handle = createBunRuntime().serve(mod.app, {
89
97
  port,
90
98
  hostname,
@@ -169,7 +169,7 @@ export const entries = pgTable("entries", {
169
169
  expect(pushes.length).toBeGreaterThan(before);
170
170
  });
171
171
 
172
- test("schema.drizzle.ts change does not re-trigger dbPush", async () => {
172
+ test("generated drizzle.ts change does not re-trigger dbPush", async () => {
173
173
  const cwd = await scaffoldProject();
174
174
  const pushes: string[] = [];
175
175
  const watcher = controlledWatcher();
@@ -221,7 +221,11 @@ export const entries = pgTable("entries", {
221
221
  const before = pushes.length;
222
222
  expect(before).toBeGreaterThanOrEqual(1);
223
223
 
224
- // Simulate emit writing schema.drizzle.ts after push — must not loop.
224
+ // Simulate emit writing drizzle.ts after push — must not loop.
225
+ watcher.change("drizzle.ts");
226
+ watcher.change("src/db/drizzle.ts");
227
+ watcher.change("src/db/drizzle/index.ts");
228
+ watcher.change("src/db/drizzle/links.ts");
225
229
  watcher.change("schema.drizzle.ts");
226
230
  watcher.change("src/db/schema.drizzle.ts");
227
231
  watcher.change("schema.generated.ts");
@@ -25,7 +25,7 @@ import { loadOkeConfig, resolveImages } from "./load-config.ts";
25
25
  export interface DevSchemaSyncOptions {
26
26
  /** Write output (default: stdout). */
27
27
  readonly write?: (text: string) => void;
28
- /** Skip emitting `schema.drizzle.ts` (tests). */
28
+ /** Skip emitting `drizzle/` (tests). */
29
29
  readonly skipEmit?: boolean;
30
30
  /** Injectable push (tests). Default: `runDb("push", ...)`. */
31
31
  readonly pushFn?: (cwd: string, env: ConfigEnv) => Promise<number>;
@@ -706,7 +706,7 @@ describe("oke dev hot reload", () => {
706
706
  });
707
707
 
708
708
  describe("oke dev syncAdoptBarrel atomic write", () => {
709
- test("default path regenerates generated.ts and leaves no .tmp behind", async () => {
709
+ test("default path regenerates index.ts and leaves no .tmp behind", async () => {
710
710
  const dir = await mkdtemp(join(tmpdir(), "oke-dev-adopt-atomic-"));
711
711
  await mkdir(join(dir, "src/flows/notes"), { recursive: true });
712
712
  await Bun.write(join(dir, "src/flows/notes/index.ts"), "export {};\n");
@@ -791,9 +791,12 @@ describe("oke dev syncAdoptBarrel atomic write", () => {
791
791
  });
792
792
 
793
793
  describe("isFlowsTreeWatchPath", () => {
794
- test("flows tree files trigger regen; generated.ts does not", () => {
794
+ test("flows tree files trigger regen; adopt barrel does not", () => {
795
795
  expect(isFlowsTreeWatchPath("flows/notes/[id]/get.ts")).toBe(true);
796
796
  expect(isFlowsTreeWatchPath("flows\\notes\\list.ts")).toBe(true);
797
+ expect(isFlowsTreeWatchPath("flows/notes/index.ts")).toBe(true);
798
+ expect(isFlowsTreeWatchPath("flows/index.ts")).toBe(false);
799
+ expect(isFlowsTreeWatchPath("flows/index.ts.tmp")).toBe(false);
797
800
  expect(isFlowsTreeWatchPath("flows/generated.ts")).toBe(false);
798
801
  expect(isFlowsTreeWatchPath("flows/generated.ts.tmp")).toBe(false);
799
802
  expect(isFlowsTreeWatchPath("core.ts")).toBe(false);
@@ -953,7 +956,7 @@ describe("oke dev Docker-first", () => {
953
956
  });
954
957
  });
955
958
 
956
- describe("oke dev schema.decl sync framing", () => {
959
+ describe("oke dev schema declare sync framing", () => {
957
960
  let session: DevSession | undefined;
958
961
 
959
962
  afterEach(() => {
@@ -981,7 +984,7 @@ describe("oke dev schema.decl sync framing", () => {
981
984
  await Bun.write(join(dir, "src/app.ts"), "export {}\n");
982
985
  // Genuine definition error: `.references()` target is undefined → emit throws.
983
986
  await Bun.write(
984
- join(dir, "src/db/schema.decl.ts"),
987
+ join(dir, "src/db/schema.ts"),
985
988
  `import { store, field } from ${JSON.stringify(OKE_INDEX)};
986
989
 
987
990
  export const authors = store.schema.table("authors", {
@@ -1024,8 +1027,8 @@ export const posts = store.schema.table("posts", {
1024
1027
  session = result.session;
1025
1028
 
1026
1029
  const plain = writes.join("").replace(/\x1b\[[0-9;]*m/g, "");
1027
- expect(plain).toContain("schema.decl.ts has an error");
1028
- expect(plain).toMatch(/●\s*schema\.decl\.ts has an error/);
1030
+ expect(plain).toContain("schema.ts has an error");
1031
+ expect(plain).toMatch(/●\s*schema\.ts has an error/);
1029
1032
  expect(plain).not.toContain("oke db push (dev) skipped");
1030
1033
  }, 60_000);
1031
1034
 
@@ -1065,7 +1068,7 @@ export const posts = store.schema.table("posts", {
1065
1068
 
1066
1069
  const plain = writes.join("").replace(/\x1b\[[0-9;]*m/g, "");
1067
1070
  expect(plain).toContain("oke db push (dev) skipped — docker mode: oke.config.ts not found");
1068
- expect(plain).not.toContain("schema.decl.ts has an error");
1071
+ expect(plain).not.toContain("schema.ts has an error");
1069
1072
  }, 60_000);
1070
1073
  });
1071
1074