okengine 0.6.1 → 0.8.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 (167) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +73 -181
  3. package/package.json +3 -3
  4. package/site/content/docs/console/clock.mdx +25 -5
  5. package/site/content/docs/console/signals.mdx +13 -4
  6. package/site/content/docs/elements/ai.mdx +29 -2
  7. package/site/content/docs/elements/channel.mdx +42 -25
  8. package/site/content/docs/elements/clock.mdx +59 -25
  9. package/site/content/docs/elements/flow.mdx +8 -2
  10. package/site/content/docs/elements/gate.mdx +8 -1
  11. package/site/content/docs/elements/signal.mdx +173 -29
  12. package/site/content/docs/elements/store.mdx +595 -73
  13. package/site/content/docs/elements/vault.mdx +15 -5
  14. package/site/content/docs/get-started/basic-usage.mdx +13 -6
  15. package/site/content/docs/get-started/installation.mdx +52 -14
  16. package/site/content/docs/get-started/introduction.mdx +1 -1
  17. package/site/content/docs/plugins/magic-link.mdx +42 -0
  18. package/site/content/docs/plugins/phone-number.mdx +78 -17
  19. package/site/content/docs/plugins/two-factor.mdx +1 -0
  20. package/site/content/docs/reference/cli.md +32 -2
  21. package/site/content/docs/reference/configuration.mdx +15 -13
  22. package/site/content/docs/reference/environment-variables.mdx +32 -18
  23. package/site/content/docs/reference/errors.mdx +12 -11
  24. package/site/content/docs/reference/fx.mdx +15 -13
  25. package/site/content/docs/reference/security.md +3 -1
  26. package/src/cli/ai-setup/ai-setup.test.ts +144 -0
  27. package/src/cli/ai-setup/apply.ts +214 -0
  28. package/src/cli/ai-setup/catalog.ts +263 -0
  29. package/src/cli/ai-setup/detect-ollama.ts +166 -0
  30. package/src/cli/ai-setup/index.ts +228 -0
  31. package/src/cli/ai-setup/prompts.ts +649 -0
  32. package/src/cli/ai-setup/recommend.test.ts +100 -0
  33. package/src/cli/ai-setup/recommend.ts +203 -0
  34. package/src/cli/ai.ts +38 -0
  35. package/src/cli/db-auto-push.test.ts +6 -1
  36. package/src/cli/db-auto-push.ts +8 -3
  37. package/src/cli/db-seed.ts +359 -0
  38. package/src/cli/db.test.ts +341 -3
  39. package/src/cli/db.ts +75 -8
  40. package/src/cli/dev-db-push.test.ts +59 -0
  41. package/src/cli/dev.test.ts +40 -0
  42. package/src/cli/dev.ts +55 -1
  43. package/src/cli/docker-clean.test.ts +179 -0
  44. package/src/cli/docker-clean.ts +406 -0
  45. package/src/cli/docker.ts +16 -4
  46. package/src/cli/ensure-drizzle-config.ts +1 -1
  47. package/src/cli/hero-meta.test.ts +6 -5
  48. package/src/cli/hero-meta.ts +7 -1
  49. package/src/cli/index.ts +5 -0
  50. package/src/cli/load-config.images.test.ts +22 -0
  51. package/src/cli/load-config.ts +7 -2
  52. package/src/cli/openbao-bootstrap.test.ts +1 -0
  53. package/src/cli/openbao-bootstrap.ts +9 -2
  54. package/src/cli/registry.ts +116 -2
  55. package/src/client/live-gap.test.ts +35 -0
  56. package/src/client/transport.test.ts +21 -0
  57. package/src/client/transport.ts +27 -5
  58. package/src/compiler/effects-infer.ts +1 -0
  59. package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
  60. package/src/config/define-config.test.ts +6 -6
  61. package/src/config/index.ts +5 -1
  62. package/src/console/server/console.test.ts +45 -0
  63. package/src/console/server/flows.ts +39 -9
  64. package/src/console/server/i18n.ts +41 -0
  65. package/src/console/server/vault.ts +12 -38
  66. package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
  67. package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
  68. package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
  69. package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
  70. package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
  71. package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
  72. package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
  73. package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
  74. package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
  75. package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
  76. package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
  77. package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
  78. package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
  79. package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
  80. package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
  81. package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
  82. package/src/console/ui/dist/index.html +1 -1
  83. package/src/console/ui/shell/components/ui.tsx +5 -1
  84. package/src/console/ui/shell/setup/Wizard.tsx +26 -5
  85. package/src/docker/cleanup.test.ts +193 -0
  86. package/src/docker/cleanup.ts +355 -0
  87. package/src/docker/docker.test.ts +4 -0
  88. package/src/docker/index.ts +19 -0
  89. package/src/docker/recipes/ollama.ts +10 -5
  90. package/src/drivers/ai-ollama.ts +45 -2
  91. package/src/drivers/ai-providers.test.ts +31 -0
  92. package/src/drivers/channel-sently.test.ts +8 -0
  93. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  94. package/src/drivers/channel-types.ts +71 -0
  95. package/src/drivers/clock-postgres.test.ts +258 -0
  96. package/src/drivers/clock-postgres.ts +410 -0
  97. package/src/drivers/index.ts +21 -0
  98. package/src/drivers/journal-postgres.test.ts +175 -0
  99. package/src/drivers/journal-postgres.ts +492 -0
  100. package/src/drivers/signal-engine.ts +98 -25
  101. package/src/drivers/signal-nats.ts +4 -4
  102. package/src/drivers/signal-postgres.ts +158 -64
  103. package/src/drivers/signal-redis.ts +4 -4
  104. package/src/drivers/signal-types.ts +61 -2
  105. package/src/drivers/vault-driver-removal.test.ts +5 -1
  106. package/src/elements/channel/runtime.ts +51 -0
  107. package/src/elements/channel.test.ts +71 -0
  108. package/src/elements/clock/chaos-child.ts +389 -0
  109. package/src/elements/clock/chaos.test.ts +204 -0
  110. package/src/elements/clock/durable.ts +7 -0
  111. package/src/elements/clock/reconcile.ts +110 -0
  112. package/src/elements/clock/runtime.ts +13 -0
  113. package/src/elements/clock.test.ts +164 -2
  114. package/src/elements/clock.ts +2 -1
  115. package/src/elements/gate/runtime.ts +6 -0
  116. package/src/elements/signal/chaos-child.ts +41 -4
  117. package/src/elements/signal/declare.ts +4 -1
  118. package/src/elements/signal/delivery-modes.test.ts +179 -0
  119. package/src/elements/signal/key-ordering.test.ts +306 -0
  120. package/src/elements/signal/lease-reclaim.test.ts +123 -0
  121. package/src/elements/signal/optional-emit.test.ts +76 -0
  122. package/src/elements/signal/order-lifecycle.test.ts +130 -0
  123. package/src/elements/signal/orphan-messages.test.ts +152 -0
  124. package/src/elements/signal/runtime.ts +11 -4
  125. package/src/elements/signal/schema-emit.test.ts +107 -0
  126. package/src/elements/signal.test.ts +89 -5
  127. package/src/elements/store/files-fx.ts +104 -0
  128. package/src/elements/store/files-image.test.ts +251 -0
  129. package/src/elements/store/files-image.ts +494 -0
  130. package/src/elements/store/runtime.ts +60 -0
  131. package/src/elements/store/seed.test.ts +27 -0
  132. package/src/elements/store/seed.ts +68 -0
  133. package/src/elements/store/sql-session.test.ts +39 -0
  134. package/src/elements/store/sql-session.ts +55 -0
  135. package/src/elements/store/upsert-app.test.ts +103 -0
  136. package/src/elements/store.ts +29 -0
  137. package/src/elements/vault/boot-chain.ts +150 -0
  138. package/src/elements/vault/runtime.ts +8 -0
  139. package/src/index.ts +15 -0
  140. package/src/kernel/app.ts +168 -15
  141. package/src/kernel/boot-bind/channel.test.ts +16 -0
  142. package/src/kernel/boot-bind/channel.ts +13 -0
  143. package/src/kernel/boot-bind/clock.ts +63 -7
  144. package/src/kernel/boot-bind/gate.ts +71 -3
  145. package/src/kernel/boot-bind/honor-config.test.ts +348 -0
  146. package/src/kernel/boot-bind/journal.ts +89 -0
  147. package/src/kernel/boot-bind/signal.ts +64 -7
  148. package/src/kernel/boot-bind/vault.ts +27 -8
  149. package/src/kernel/boot.test.ts +6 -4
  150. package/src/kernel/boot.ts +63 -15
  151. package/src/kernel/concurrency.ts +1 -1
  152. package/src/kernel/errors.ts +8 -2
  153. package/src/kernel/fx.test.ts +19 -0
  154. package/src/kernel/fx.ts +153 -9
  155. package/src/kernel/index.ts +6 -0
  156. package/src/kernel/journal-boot.test.ts +397 -0
  157. package/src/kernel/journal-suspend.ts +35 -0
  158. package/src/kernel/journal.test.ts +142 -0
  159. package/src/kernel/journal.ts +202 -27
  160. package/src/plugins/auth-methods.security.test.ts +10 -7
  161. package/src/plugins/phone-number.ts +67 -10
  162. package/src/plugins/taqnyat.live.test.ts +174 -0
  163. package/src/runtime/dev-request-log.test.ts +20 -1
  164. package/src/runtime/dev-request-log.ts +38 -0
  165. package/src/term.test.ts +15 -0
  166. package/src/term.ts +8 -3
  167. package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
@@ -3,15 +3,74 @@
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
- import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
6
+ import { Database } from "bun:sqlite";
7
+ import { mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
7
8
  import { tmpdir } from "node:os";
8
9
  import { join, resolve } from "node:path";
9
- import { EXIT_OK, EXIT_RUNTIME } from "./exit.ts";
10
- import { dbCli, emitAbstractSchemaPrestep, runDb, runGenerate, runPush } from "./db.ts";
10
+ import { createFx } from "../kernel/fx.ts";
11
+ import { defineSeed, type SeedFn } from "../elements/store/seed.ts";
12
+ import { EXIT_OK, EXIT_RUNTIME, EXIT_USAGE } from "./exit.ts";
13
+ import {
14
+ dbCli,
15
+ emitAbstractSchemaPrestep,
16
+ executeSeedDef,
17
+ runDb,
18
+ runGenerate,
19
+ runPush,
20
+ runSeed,
21
+ } from "./db.ts";
11
22
 
12
23
  /** Repo public entry — absolute import so temp apps need no install. */
13
24
  const OKE_INDEX = resolve(import.meta.dir, "../index.ts");
14
25
  const CONFIG_MOD = resolve(import.meta.dir, "../config/index.ts");
26
+ const DB_MOD = resolve(import.meta.dir, "./db.ts");
27
+
28
+ /**
29
+ * Run `oke db generate|migrate` in a fresh Bun process.
30
+ *
31
+ * drizzle-kit's in-process `generate` caches the schema module — a second
32
+ * generate in the same process after rewriting the schema file returns
33
+ * `no_changes`. Real CLI invocations are one process each; the adversarial
34
+ * multi-migration test must match that.
35
+ *
36
+ * Temp projects live under {@link import.meta.dir} so `bunx drizzle-kit`
37
+ * (migrate's default) walks up to the repo `node_modules`.
38
+ */
39
+ async function runDbFresh(
40
+ sub: "generate" | "migrate",
41
+ cwd: string,
42
+ ): Promise<{ readonly code: number; readonly out: string }> {
43
+ const proc = Bun.spawn(
44
+ [
45
+ "bun",
46
+ "-e",
47
+ `import { runDb } from ${JSON.stringify(DB_MOD)};
48
+ const cwd = ${JSON.stringify(cwd)};
49
+ const prev = process.cwd();
50
+ process.chdir(cwd);
51
+ try {
52
+ process.exit(await runDb(${JSON.stringify(sub)}, { cwd, skipEmit: true }));
53
+ } finally {
54
+ process.chdir(prev);
55
+ }
56
+ `,
57
+ ],
58
+ { cwd, stdout: "pipe", stderr: "pipe", env: process.env as Record<string, string> },
59
+ );
60
+ const [stdout, stderr, code] = await Promise.all([
61
+ new Response(proc.stdout).text(),
62
+ new Response(proc.stderr).text(),
63
+ proc.exited,
64
+ ]);
65
+ return { code, out: `${stdout}${stderr}` };
66
+ }
67
+
68
+ async function migrationFolders(drizzleDir: string): Promise<string[]> {
69
+ return (await readdir(drizzleDir, { withFileTypes: true }))
70
+ .filter((d) => d.isDirectory())
71
+ .map((d) => d.name)
72
+ .sort();
73
+ }
15
74
 
16
75
  describe("oke db", () => {
17
76
  test("help lists subcommands", async () => {
@@ -26,6 +85,7 @@ describe("oke db", () => {
26
85
  expect(lines.join("\n")).toContain("push");
27
86
  expect(lines.join("\n")).toContain("generate");
28
87
  expect(lines.join("\n")).toContain("migrate");
88
+ expect(lines.join("\n")).toContain("seed");
29
89
  expect(lines.join("\n")).toContain("oke schema generate");
30
90
  } finally {
31
91
  console.log = orig;
@@ -187,3 +247,281 @@ export const app = oke({ name: "plugin-only" }).plug(
187
247
  expect(generated).toContain('integer("hits")');
188
248
  });
189
249
  });
250
+
251
+ describe("oke db multi-migration catch-up", () => {
252
+ /**
253
+ * Real drizzle-kit generate/migrate through `oke db` — proves multi-file
254
+ * versioned migrations and that re-migrate / environment lag only apply
255
+ * previously-unapplied files (drizzle-kit `__drizzle_migrations` history).
256
+ * The CLI wrapper must not interfere with that bookkeeping.
257
+ */
258
+ test("second migrate skips applied files; lag DB catches up with only the new one", async () => {
259
+ const dir = await mkdtemp(join(import.meta.dir, ".tmp-db-mig-"));
260
+ try {
261
+ await mkdir(join(dir, "src"), { recursive: true });
262
+ await writeFile(
263
+ join(dir, "src", "schema.ts"),
264
+ `import { sqliteTable, text } from "drizzle-orm/sqlite-core";
265
+ export const notes = sqliteTable("notes", {
266
+ id: text("id").primaryKey(),
267
+ title: text("title").notNull(),
268
+ });
269
+ `,
270
+ );
271
+ await writeFile(
272
+ join(dir, "drizzle.config.ts"),
273
+ `import { defineConfig } from "drizzle-kit";
274
+ export default defineConfig({
275
+ dialect: "sqlite",
276
+ schema: "./src/schema.ts",
277
+ out: "./drizzle",
278
+ dbCredentials: { url: "file:./app.db" },
279
+ });
280
+ `,
281
+ );
282
+
283
+ const gen1 = await runDbFresh("generate", dir);
284
+ expect(gen1.code).toBe(EXIT_OK);
285
+ expect(gen1.out).toMatch(/oke db generate: wrote /);
286
+ const migsAfterFirst = await migrationFolders(join(dir, "drizzle"));
287
+ expect(migsAfterFirst.length).toBe(1);
288
+ const initialMig = migsAfterFirst[0];
289
+ expect(initialMig).toBeDefined();
290
+ if (initialMig === undefined) throw new Error("expected initial migration folder");
291
+
292
+ const mig1 = await runDbFresh("migrate", dir);
293
+ expect(mig1.code).toBe(EXIT_OK);
294
+ expect(mig1.out).toContain("oke db migrate: applied");
295
+
296
+ let db = new Database(join(dir, "app.db"));
297
+ expect(
298
+ db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='notes'").get(),
299
+ ).toBeTruthy();
300
+ let history = db.query("SELECT name FROM __drizzle_migrations ORDER BY id").all() as Array<{
301
+ name: string;
302
+ }>;
303
+ expect(history.map((r) => r.name)).toEqual([initialMig]);
304
+ db.close();
305
+
306
+ await writeFile(
307
+ join(dir, "src", "schema.ts"),
308
+ `import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
309
+ export const notes = sqliteTable("notes", {
310
+ id: text("id").primaryKey(),
311
+ title: text("title").notNull(),
312
+ pinned: integer("pinned").notNull().default(0),
313
+ });
314
+ `,
315
+ );
316
+ // Distinct timestamp folder names (drizzle-kit uses YYYYMMDDHHmmss).
317
+ await Bun.sleep(1100);
318
+
319
+ const gen2 = await runDbFresh("generate", dir);
320
+ expect(gen2.code).toBe(EXIT_OK);
321
+ expect(gen2.out).toMatch(/oke db generate: wrote /);
322
+ const migs = await migrationFolders(join(dir, "drizzle"));
323
+ expect(migs.length).toBe(2);
324
+ const firstMig = migs[0];
325
+ const secondMig = migs[1];
326
+ expect(firstMig).toBeDefined();
327
+ expect(secondMig).toBeDefined();
328
+ if (firstMig === undefined || secondMig === undefined) {
329
+ throw new Error("expected two migration folders");
330
+ }
331
+ expect(firstMig).toBe(initialMig);
332
+
333
+ const mig2 = await runDbFresh("migrate", dir);
334
+ expect(mig2.code).toBe(EXIT_OK);
335
+
336
+ db = new Database(join(dir, "app.db"));
337
+ history = db.query("SELECT name FROM __drizzle_migrations ORDER BY id").all() as Array<{
338
+ name: string;
339
+ }>;
340
+ expect(history.map((r) => r.name)).toEqual([firstMig, secondMig]);
341
+ const cols = db.query("PRAGMA table_info(notes)").all() as Array<{ name: string }>;
342
+ expect(cols.map((c) => c.name)).toContain("pinned");
343
+ db.close();
344
+
345
+ // Idempotent re-migrate: history unchanged, no error.
346
+ const migNoop = await runDbFresh("migrate", dir);
347
+ expect(migNoop.code).toBe(EXIT_OK);
348
+ db = new Database(join(dir, "app.db"));
349
+ const historyNoop = db
350
+ .query("SELECT name FROM __drizzle_migrations ORDER BY id")
351
+ .all() as Array<{ name: string }>;
352
+ expect(historyNoop.map((r) => r.name)).toEqual([firstMig, secondMig]);
353
+ db.close();
354
+
355
+ // Staging/prod lag: fresh DB with only the first migration applied,
356
+ // then both files present — catch up applies only the second.
357
+ await rm(join(dir, "app.db"), { force: true });
358
+ const stash = join(dir, "_stash_second");
359
+ await rm(stash, { recursive: true, force: true });
360
+ await rename(join(dir, "drizzle", secondMig), stash);
361
+
362
+ const lagFirst = await runDbFresh("migrate", dir);
363
+ expect(lagFirst.code).toBe(EXIT_OK);
364
+ db = new Database(join(dir, "app.db"));
365
+ const lagHist1 = db
366
+ .query("SELECT name FROM __drizzle_migrations ORDER BY id")
367
+ .all() as Array<{ name: string }>;
368
+ expect(lagHist1.map((r) => r.name)).toEqual([firstMig]);
369
+ const lagCols1 = db.query("PRAGMA table_info(notes)").all() as Array<{ name: string }>;
370
+ expect(lagCols1.map((c) => c.name)).not.toContain("pinned");
371
+ db.close();
372
+
373
+ await rename(stash, join(dir, "drizzle", secondMig));
374
+ const lagCatchUp = await runDbFresh("migrate", dir);
375
+ expect(lagCatchUp.code).toBe(EXIT_OK);
376
+ db = new Database(join(dir, "app.db"));
377
+ const lagHist2 = db
378
+ .query("SELECT name FROM __drizzle_migrations ORDER BY id")
379
+ .all() as Array<{ name: string }>;
380
+ expect(lagHist2.map((r) => r.name)).toEqual([firstMig, secondMig]);
381
+ const lagCols2 = db.query("PRAGMA table_info(notes)").all() as Array<{ name: string }>;
382
+ expect(lagCols2.map((c) => c.name)).toContain("pinned");
383
+ db.close();
384
+ } finally {
385
+ await rm(dir, { recursive: true, force: true });
386
+ }
387
+ }, 120_000);
388
+ });
389
+
390
+ describe("oke db seed", () => {
391
+ function trackingDef(order: string[]) {
392
+ const a: SeedFn = async function a() {
393
+ order.push("a");
394
+ };
395
+ const b: SeedFn = async function b() {
396
+ order.push("b");
397
+ };
398
+ const dev: SeedFn = async function seedDev() {
399
+ order.push("dev");
400
+ };
401
+ const prod: SeedFn = async function seedProd() {
402
+ order.push("prod");
403
+ };
404
+ return defineSeed({
405
+ essential: [a, b],
406
+ dev,
407
+ prod,
408
+ });
409
+ }
410
+
411
+ async function seedWith(
412
+ env: "local" | "docker" | "test" | "prod",
413
+ extras: Parameters<typeof runSeed>[0] = {},
414
+ ) {
415
+ const order: string[] = [];
416
+ const out: string[] = [];
417
+ const code = await runSeed({
418
+ env,
419
+ seedDef: trackingDef(order),
420
+ createFx: async () => ({
421
+ fx: createFx({ flow: "oke.db.seed.test" }),
422
+ stop: async () => {},
423
+ }),
424
+ write: (t) => out.push(t),
425
+ force: true,
426
+ ...extras,
427
+ });
428
+ return { code, order, out: out.join("") };
429
+ }
430
+
431
+ test("essential array order is execution order", async () => {
432
+ const { code, order, out } = await seedWith("local");
433
+ expect(code).toBe(EXIT_OK);
434
+ expect(order.slice(0, 2)).toEqual(["a", "b"]);
435
+ expect(out).toContain("oke db seed: essential a");
436
+ expect(out).toContain("oke db seed: ok");
437
+ });
438
+
439
+ test("local runs essential + dev, never prod", async () => {
440
+ const { order } = await seedWith("local");
441
+ expect(order).toEqual(["a", "b", "dev"]);
442
+ });
443
+
444
+ test("docker runs essential + dev, never prod", async () => {
445
+ const { order } = await seedWith("docker");
446
+ expect(order).toEqual(["a", "b", "dev"]);
447
+ });
448
+
449
+ test("prod runs essential + prod, never dev", async () => {
450
+ const { order } = await seedWith("prod");
451
+ expect(order).toEqual(["a", "b", "prod"]);
452
+ });
453
+
454
+ test("test runs essential only", async () => {
455
+ const { order, out } = await seedWith("test");
456
+ expect(order).toEqual(["a", "b"]);
457
+ expect(out).toContain("essential only");
458
+ });
459
+
460
+ test("prod without --force requires confirmEnv", async () => {
461
+ const out: string[] = [];
462
+ let asked = false;
463
+ const code = await runSeed({
464
+ env: "prod",
465
+ force: false,
466
+ stdinIsTTY: true,
467
+ seedDef: defineSeed({ essential: async () => {} }),
468
+ createFx: async () => ({
469
+ fx: createFx({ flow: "oke.db.seed.test" }),
470
+ stop: async () => {},
471
+ }),
472
+ confirmEnv: async () => {
473
+ asked = true;
474
+ return false;
475
+ },
476
+ write: (t) => out.push(t),
477
+ });
478
+ expect(asked).toBe(true);
479
+ expect(code).toBe(EXIT_USAGE);
480
+ expect(out.join("")).toContain("cancelled");
481
+ });
482
+
483
+ test("prod with --force skips confirm", async () => {
484
+ let asked = false;
485
+ const { code } = await seedWith("prod", {
486
+ force: true,
487
+ confirmEnv: async () => {
488
+ asked = true;
489
+ return false;
490
+ },
491
+ });
492
+ expect(asked).toBe(false);
493
+ expect(code).toBe(EXIT_OK);
494
+ });
495
+
496
+ test("docker confirm proceeds when confirmEnv returns true", async () => {
497
+ const out: string[] = [];
498
+ const order: string[] = [];
499
+ const code = await runSeed({
500
+ env: "docker",
501
+ force: false,
502
+ stdinIsTTY: true,
503
+ seedDef: trackingDef(order),
504
+ createFx: async () => ({
505
+ fx: createFx({ flow: "oke.db.seed.test" }),
506
+ stop: async () => {},
507
+ }),
508
+ confirmEnv: async (env, target) => {
509
+ expect(env).toBe("docker");
510
+ expect(target.length).toBeGreaterThan(0);
511
+ return true;
512
+ },
513
+ write: (t) => out.push(t),
514
+ });
515
+ expect(code).toBe(EXIT_OK);
516
+ expect(order).toEqual(["a", "b", "dev"]);
517
+ expect(out.join("")).toContain("oke db seed:");
518
+ });
519
+
520
+ test("executeSeedDef is the category source of truth", async () => {
521
+ const order: string[] = [];
522
+ const out: string[] = [];
523
+ const fx = createFx({ flow: "x" });
524
+ await executeSeedDef(trackingDef(order), "prod", fx, (t) => out.push(t));
525
+ expect(order).toEqual(["a", "b", "prod"]);
526
+ });
527
+ });
package/src/cli/db.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * `oke db push|generate|migrate` — domain schema sync via drizzle-kit.
2
+ * `oke db push|generate|migrate|seed` — domain schema sync via drizzle-kit,
3
+ * plus explicit seed runs.
3
4
  *
4
5
  * Distinct from `oke schema generate` (core/plugin stub tables).
5
6
  * When abstract decls exist (`src/schema.decl.ts`), emits Drizzle first —
@@ -15,11 +16,24 @@ import {
15
16
  import { loadPluginTablesFromAppEntry } from "../elements/store/load-plugin-tables.ts";
16
17
  import { resolveDriverId, type ConfigEnv, type OkeConfig } from "../config/index.ts";
17
18
  import type { TableContribution } from "../kernel/plugin.ts";
19
+ import { runSeed, type SeedOptions } from "./db-seed.ts";
18
20
  import { resolveDrizzleKitEnv } from "./drizzle-env.ts";
19
21
  import { resolveDevSqlEnv } from "./resolve-dev-sql-env.ts";
20
22
  import { EXIT_OK, EXIT_RUNTIME, EXIT_USAGE } from "./exit.ts";
21
23
  import { loadOkeConfig } from "./load-config.ts";
22
24
 
25
+ export {
26
+ executeSeedDef,
27
+ formatSeedTarget,
28
+ isSeedDef,
29
+ loadSeedDef,
30
+ redactConnectionTarget,
31
+ runSeed,
32
+ runSeedFns,
33
+ type SeedOptions,
34
+ type SeedTally,
35
+ } from "./db-seed.ts";
36
+
23
37
  /**
24
38
  * Apply a drizzle-kit env overlay to `process.env` for the in-process SDK,
25
39
  * returning a restore function. drizzle-kit's `push`/`generate` read
@@ -67,7 +81,7 @@ export async function withDrizzleKitEnv<T>(
67
81
  }
68
82
 
69
83
  /** Subcommands under `oke db`. */
70
- export type DbSubcommand = "push" | "generate" | "migrate";
84
+ export type DbSubcommand = "push" | "generate" | "migrate" | "seed";
71
85
 
72
86
  /** Options for {@link runDb}. */
73
87
  export interface DbOptions {
@@ -104,6 +118,15 @@ export interface DbOptions {
104
118
  readonly pluginTables?: readonly TableContribution[];
105
119
  /** Override app entry path for plugin-table discovery. */
106
120
  readonly entry?: string;
121
+ /** Skip docker/prod confirm for seed (`--force`). */
122
+ readonly force?: boolean;
123
+ /** Seed module path override. */
124
+ readonly seedPath?: string;
125
+ /** Seed-only injectables forwarded to {@link runSeed}. */
126
+ readonly seedDef?: SeedOptions["seedDef"];
127
+ readonly createFx?: SeedOptions["createFx"];
128
+ readonly confirmEnv?: SeedOptions["confirmEnv"];
129
+ readonly stdinIsTTY?: boolean;
107
130
  }
108
131
 
109
132
  /** Minimal drizzle-kit envelope we handle. */
@@ -135,7 +158,7 @@ export async function resolveDrizzleConfigPath(
135
158
  /**
136
159
  * Run `oke db <subcommand>`.
137
160
  *
138
- * @param sub - push | generate | migrate
161
+ * @param sub - push | generate | migrate | seed
139
162
  * @param options - Paths / injectables
140
163
  */
141
164
  export async function runDb(sub: DbSubcommand, options: DbOptions = {}): Promise<number> {
@@ -143,6 +166,22 @@ export async function runDb(sub: DbSubcommand, options: DbOptions = {}): Promise
143
166
  const cwd = options.cwd ?? process.cwd();
144
167
  const loaded = await loadOkeConfig(cwd).catch(() => null);
145
168
  const env = options.env ?? (await resolveDevSqlEnv(cwd));
169
+
170
+ if (sub === "seed") {
171
+ return runSeed({
172
+ cwd,
173
+ write,
174
+ env,
175
+ force: options.force,
176
+ seedPath: options.seedPath,
177
+ entry: options.entry,
178
+ seedDef: options.seedDef,
179
+ createFx: options.createFx,
180
+ confirmEnv: options.confirmEnv,
181
+ stdinIsTTY: options.stdinIsTTY,
182
+ });
183
+ }
184
+
146
185
  if (!options.skipEmit) {
147
186
  await emitAbstractSchemaPrestep(cwd, loaded?.config, write, env, {
148
187
  pluginTables: options.pluginTables,
@@ -399,16 +438,16 @@ export async function detectDbDrift(options: DbOptions = {}): Promise<{
399
438
  }
400
439
 
401
440
  /**
402
- * CLI entry: parse `oke db <push|generate|migrate> [--config path]`.
441
+ * CLI entry: parse `oke db <push|generate|migrate|seed> []`.
403
442
  *
404
443
  * @param args - Args after `db`
405
444
  */
406
445
  export async function dbCli(args: readonly string[]): Promise<number> {
407
446
  const sub = args[0];
408
447
  if (!sub || sub === "--help" || sub === "-h") {
409
- console.log(`oke db push|generate|migrate [--config|-c drizzle.config.ts]
448
+ console.log(`oke db push|generate|migrate|seed [--config|-c] [--env name] [--force]
410
449
 
411
- Domain schema sync via drizzle-kit.
450
+ Domain schema sync via drizzle-kit, plus explicit seed.
412
451
  When src/schema.decl.ts and/or a plugged app entry exists, emits
413
452
  schema.generated.ts from store.schema.table + live plugin .table()
414
453
  contributions — then runs drizzle-kit.
@@ -418,24 +457,52 @@ Not the same as \`oke schema generate\` (core/plugin stub tables).
418
457
  push Apply schema to the live local DB (dev; no migration files)
419
458
  generate Write versioned SQL under drizzle/ for review
420
459
  migrate Apply generated migrations (explicit; never automatic in prod)
460
+ seed Run defineSeed (essential + env category); never at boot
461
+
462
+ --env Override config env (local|docker|test|prod)
463
+ --force Skip docker/prod confirmation prompt (CI)
464
+ --entry App entry for seed boot / plugin table discovery
421
465
  `);
422
466
  return sub ? EXIT_OK : EXIT_USAGE;
423
467
  }
424
- if (sub !== "push" && sub !== "generate" && sub !== "migrate") {
468
+ if (sub !== "push" && sub !== "generate" && sub !== "migrate" && sub !== "seed") {
425
469
  console.error(`oke db: unknown subcommand "${sub}"`);
426
470
  return EXIT_USAGE;
427
471
  }
428
472
 
429
473
  let config: string | undefined;
474
+ let env: ConfigEnv | undefined;
475
+ let force = false;
476
+ let entry: string | undefined;
430
477
  for (let i = 1; i < args.length; i++) {
431
478
  const a = args[i]!;
432
479
  if (a === "--config" || a === "-c") config = args[++i];
480
+ else if (a === "--env") {
481
+ const value = args[++i];
482
+ const parsed = parseConfigEnv(value);
483
+ if (!parsed) {
484
+ console.error(`oke db: invalid --env ${JSON.stringify(value)} (local|docker|test|prod)`);
485
+ return EXIT_USAGE;
486
+ }
487
+ env = parsed;
488
+ } else if (a === "--force") force = true;
489
+ else if (a === "--entry" || a === "-e") entry = args[++i];
433
490
  else if (a === "--help" || a === "-h") {
434
491
  return dbCli(["--help"]);
492
+ } else if (a.startsWith("-")) {
493
+ console.error(`oke db ${sub}: unknown flag ${a}`);
494
+ return EXIT_USAGE;
435
495
  }
436
496
  }
437
497
 
438
- return runDb(sub, { config });
498
+ return runDb(sub, { config, env, force, entry });
499
+ }
500
+
501
+ function parseConfigEnv(value: string | undefined): ConfigEnv | undefined {
502
+ if (value === "local" || value === "docker" || value === "test" || value === "prod") {
503
+ return value;
504
+ }
505
+ return undefined;
439
506
  }
440
507
 
441
508
  function reportKitResult(verb: string, result: DbKitResult, write: (text: string) => void): number {
@@ -139,6 +139,65 @@ export const entries = sqliteTable("entries", {
139
139
  expect(pushes.length).toBeGreaterThan(before);
140
140
  });
141
141
 
142
+ test("schema.generated.ts change does not re-trigger dbPush", async () => {
143
+ const cwd = await scaffoldProject();
144
+ const pushes: string[] = [];
145
+ const watcher = controlledWatcher();
146
+ let resolveReady!: () => void;
147
+ const ready = new Promise<void>((r) => {
148
+ resolveReady = r;
149
+ });
150
+
151
+ const result = await runDev({
152
+ cwd,
153
+ local: true,
154
+ keepAlive: false,
155
+ dryRun: false,
156
+ appPort: 0,
157
+ consolePort: 0,
158
+ mcpPort: 0,
159
+ silentClaim: true,
160
+ stdinIsTTY: false,
161
+ startApp: async () => ({
162
+ port: 0,
163
+ url: new URL("http://127.0.0.1:9"),
164
+ stop() {},
165
+ }),
166
+ serveConsole: async () => ({
167
+ port: 0,
168
+ stop() {},
169
+ }),
170
+ serveMcp: async () => ({
171
+ port: 0,
172
+ url: new URL("http://127.0.0.1:9"),
173
+ stop() {},
174
+ }),
175
+ regenClient: async () => {},
176
+ dbPush: async () => {
177
+ pushes.push("push");
178
+ return 0;
179
+ },
180
+ onDbAutoPush: () => {
181
+ resolveReady();
182
+ },
183
+ watchFs: watcher.watchFs,
184
+ onReady: async (session) => {
185
+ sessions.push(session);
186
+ },
187
+ });
188
+
189
+ expect(result.code).toBe(0);
190
+ await Promise.race([ready, Bun.sleep(800)]);
191
+ const before = pushes.length;
192
+ expect(before).toBeGreaterThanOrEqual(1);
193
+
194
+ // Simulate emit writing schema.generated.ts after push — must not loop.
195
+ watcher.change("schema.generated.ts");
196
+ watcher.change("src/schema.generated.ts");
197
+ await Bun.sleep(500);
198
+ expect(pushes.length).toBe(before);
199
+ });
200
+
142
201
  test("--no-db-push / noDbPush never calls dbPush", async () => {
143
202
  const cwd = await scaffoldProject();
144
203
  const pushes: string[] = [];
@@ -645,6 +645,46 @@ describe("oke dev mode resolution", () => {
645
645
  expect(await readDevMode(dir)).toBeNull();
646
646
  });
647
647
 
648
+ test("session stop calls composeStop after a successful docker up", async () => {
649
+ const dir = await mkdtemp(join(tmpdir(), "oke-dev-compose-stop-"));
650
+ await Bun.write(join(dir, "src/app.ts"), "export {}\n");
651
+ await writeDevMode(dir, "docker");
652
+ let upCalls = 0;
653
+ let stopCalls = 0;
654
+ let stoppedFiles: readonly string[] | undefined;
655
+ const result = await runDev({
656
+ cwd: dir,
657
+ stdinIsTTY: false,
658
+ images: { "store.sql": "postgres:16-alpine" },
659
+ credentials: {
660
+ "store.sql": {
661
+ user: "oke",
662
+ password: "test-password-not-in-yaml",
663
+ database: "oke",
664
+ },
665
+ },
666
+ composeUp: async (files) => {
667
+ upCalls++;
668
+ expect(files.length).toBeGreaterThan(0);
669
+ },
670
+ composeStop: (files) => {
671
+ stopCalls++;
672
+ stoppedFiles = files;
673
+ },
674
+ startApp: async () => ({ stop() {} }),
675
+ serveConsole: async () => ({ stop() {} }),
676
+ regenClient: async () => {},
677
+ write: () => {},
678
+ keepAlive: false,
679
+ });
680
+ expect(result.code).toBe(0);
681
+ expect(upCalls).toBe(1);
682
+ expect(result.session).toBeDefined();
683
+ result.session!.stop();
684
+ expect(stopCalls).toBe(1);
685
+ expect(stoppedFiles?.length).toBeGreaterThan(0);
686
+ });
687
+
648
688
  test("saved docker + compose failure exits loudly (no silent downgrade)", async () => {
649
689
  const dir = await mkdtemp(join(tmpdir(), "oke-dev-mode-fail-"));
650
690
  await Bun.write(join(dir, "src/app.ts"), "export {}\n");