okengine 0.7.0 → 0.9.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 (125) hide show
  1. package/package.json +3 -2
  2. package/site/content/docs/deployment/docker-swarm.mdx +228 -0
  3. package/site/content/docs/deployment/docker.mdx +212 -0
  4. package/site/content/docs/deployment/index.mdx +83 -0
  5. package/site/content/docs/deployment/kubernetes.mdx +176 -0
  6. package/site/content/docs/deployment/meta.json +5 -0
  7. package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
  8. package/site/content/docs/elements/channel.mdx +25 -12
  9. package/site/content/docs/elements/clock.mdx +17 -15
  10. package/site/content/docs/elements/flow.mdx +6 -2
  11. package/site/content/docs/elements/signal.mdx +10 -8
  12. package/site/content/docs/elements/store.mdx +165 -0
  13. package/site/content/docs/get-started/index.mdx +5 -0
  14. package/site/content/docs/get-started/installation.mdx +18 -16
  15. package/site/content/docs/index.mdx +5 -0
  16. package/site/content/docs/meta.json +10 -1
  17. package/site/content/docs/plugins/index.mdx +1 -2
  18. package/site/content/docs/plugins/magic-link.mdx +44 -2
  19. package/site/content/docs/plugins/meta.json +1 -2
  20. package/site/content/docs/plugins/otp.mdx +202 -0
  21. package/site/content/docs/plugins/two-factor.mdx +2 -1
  22. package/site/content/docs/reference/cli.md +5 -2
  23. package/site/content/docs/reference/configuration.mdx +21 -3
  24. package/site/content/docs/reference/environment-variables.mdx +21 -8
  25. package/site/content/docs/reference/plugins.mdx +1 -1
  26. package/src/auth/auth.test.ts +36 -0
  27. package/src/auth/bindings.ts +3 -12
  28. package/src/auth/identity.ts +33 -0
  29. package/src/auth/index.ts +5 -0
  30. package/src/auth/otp-capability.ts +119 -0
  31. package/src/auth/otp-seal.test.ts +61 -0
  32. package/src/auth/otp-seal.ts +84 -0
  33. package/src/auth/schema.ts +3 -0
  34. package/src/auth/sessions.ts +26 -27
  35. package/src/auth/tables.ts +4 -0
  36. package/src/auth/verification.ts +61 -1
  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-app-runner.ts +4 -0
  41. package/src/cli/docker.ts +4 -1
  42. package/src/cli/load-config.images.test.ts +26 -0
  43. package/src/cli/load-config.ts +10 -2
  44. package/src/cli/registry.ts +38 -2
  45. package/src/compiler/effects-infer.ts +1 -0
  46. package/src/config/index.ts +4 -0
  47. package/src/console/server/operator-db.ts +34 -9
  48. package/src/docker/compose.ts +162 -6
  49. package/src/docker/derive.ts +60 -3
  50. package/src/docker/docker.test.ts +374 -1
  51. package/src/docker/helpers.ts +2 -0
  52. package/src/docker/index.ts +11 -0
  53. package/src/docker/recipes/caddy.ts +51 -0
  54. package/src/docker/recipes/dragonfly.ts +31 -0
  55. package/src/docker/recipes/index.ts +25 -2
  56. package/src/docker/recipes/pgdog.ts +84 -0
  57. package/src/docker/recipes/redis.ts +6 -3
  58. package/src/docker/recipes/traefik.ts +83 -0
  59. package/src/docker/recipes/valkey.ts +30 -0
  60. package/src/docker/stack-id.ts +5 -0
  61. package/src/docker/types.ts +18 -0
  62. package/src/drivers/channel-sently.test.ts +8 -0
  63. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  64. package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
  65. package/src/drivers/channel-types.ts +72 -0
  66. package/src/drivers/clock-postgres.test.ts +258 -0
  67. package/src/drivers/clock-postgres.ts +410 -0
  68. package/src/drivers/index.ts +18 -0
  69. package/src/drivers/journal-postgres.test.ts +175 -0
  70. package/src/drivers/journal-postgres.ts +492 -0
  71. package/src/elements/channel/otp-delivery.test.ts +76 -0
  72. package/src/elements/channel/otp-delivery.ts +291 -0
  73. package/src/elements/channel/runtime.ts +203 -114
  74. package/src/elements/channel.test.ts +71 -0
  75. package/src/elements/channel.ts +12 -2
  76. package/src/elements/clock/chaos-child.ts +280 -41
  77. package/src/elements/clock/durable.ts +7 -0
  78. package/src/elements/clock/reconcile.ts +2 -2
  79. package/src/elements/clock/runtime.ts +5 -3
  80. package/src/elements/clock.ts +1 -1
  81. package/src/elements/store/seed.test.ts +27 -0
  82. package/src/elements/store/seed.ts +68 -0
  83. package/src/elements/store/sql-session.test.ts +39 -0
  84. package/src/elements/store/sql-session.ts +55 -0
  85. package/src/elements/store/upsert-app.test.ts +103 -0
  86. package/src/elements/store.ts +5 -0
  87. package/src/index.ts +18 -0
  88. package/src/kernel/app.ts +221 -14
  89. package/src/kernel/boot-bind/channel.test.ts +16 -0
  90. package/src/kernel/boot-bind/channel.ts +64 -0
  91. package/src/kernel/boot-bind/clock.ts +17 -6
  92. package/src/kernel/boot-bind/gate.ts +14 -19
  93. package/src/kernel/boot-bind/honor-config.test.ts +123 -4
  94. package/src/kernel/boot-bind/journal.ts +89 -0
  95. package/src/kernel/boot-bind/signal.ts +20 -0
  96. package/src/kernel/boot-bind/store.test.ts +82 -0
  97. package/src/kernel/boot-bind/store.ts +22 -0
  98. package/src/kernel/boot.test.ts +6 -4
  99. package/src/kernel/boot.ts +53 -13
  100. package/src/kernel/concurrency.ts +1 -1
  101. package/src/kernel/fx.test.ts +9 -0
  102. package/src/kernel/fx.ts +175 -5
  103. package/src/kernel/graceful-shutdown.test.ts +76 -0
  104. package/src/kernel/graceful-shutdown.ts +106 -0
  105. package/src/kernel/horizontal-child.ts +257 -0
  106. package/src/kernel/horizontal.integration.test.ts +229 -0
  107. package/src/kernel/index.ts +14 -0
  108. package/src/kernel/journal-boot.test.ts +397 -0
  109. package/src/kernel/journal-suspend.ts +35 -0
  110. package/src/kernel/journal.test.ts +142 -0
  111. package/src/kernel/journal.ts +202 -27
  112. package/src/kernel/ready.test.ts +76 -0
  113. package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
  114. package/src/plugins/auth-methods.security.test.ts +20 -27
  115. package/src/plugins/auth-methods.test.ts +7 -6
  116. package/src/plugins/index.ts +12 -8
  117. package/src/plugins/magic-link.ts +1 -23
  118. package/src/plugins/otp.test.ts +236 -0
  119. package/src/plugins/otp.ts +570 -0
  120. package/src/plugins/taqnyat.live.test.ts +172 -0
  121. package/src/release/official-plugins.ts +1 -2
  122. package/site/content/docs/plugins/email-otp.mdx +0 -117
  123. package/site/content/docs/plugins/phone-number.mdx +0 -111
  124. package/src/plugins/email-otp.ts +0 -214
  125. package/src/plugins/phone-number.ts +0 -149
@@ -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 {
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { resolve } from "node:path";
19
19
  import { pathToFileURL } from "node:url";
20
+ import { installGracefulShutdown, type GracefulShutdownApp } from "../kernel/graceful-shutdown.ts";
20
21
  import { createBunRuntime } from "../runtime/bun.ts";
21
22
  import { APP_PORT, type FetchApp } from "../runtime/types.ts";
22
23
  import { clearTerminalScreen, formatAppReadyLine, formatDevHero } from "../term.ts";
@@ -33,6 +34,8 @@ type DevRunnerGlobals = typeof globalThis & {
33
34
  /** App entry shape — FetchApp plus boot before serve. */
34
35
  type BootableApp = FetchApp & {
35
36
  boot(): Promise<unknown>;
37
+ stop(): Promise<void>;
38
+ readonly bootResult?: GracefulShutdownApp["bootResult"];
36
39
  };
37
40
 
38
41
  const entry = Bun.env["OKE_ENTRY"];
@@ -68,6 +71,7 @@ const handle = createBunRuntime().serve(mod.app, {
68
71
  hostname,
69
72
  id: DEV_APP_SERVE_ID,
70
73
  });
74
+ installGracefulShutdown({ app: mod.app, handle });
71
75
 
72
76
  const appUrl = `http://${formatHostForUrl(hostname)}:${handle.port}`;
73
77
  const g = globalThis as DevRunnerGlobals;
package/src/cli/docker.ts CHANGED
@@ -105,7 +105,10 @@ export async function runDockerDerive(
105
105
  write(
106
106
  `compose merge order (cwd ${composeDir}/):\n${result.composeFiles.map((f) => ` -f ${f}`).join("\n")}\n`,
107
107
  );
108
- write(`(${composeDir}/compose.override.yml is layer 4 — user-owned, never written by oke)\n`);
108
+ write(
109
+ `(${composeDir}/compose.override.yml is layer 4 — user-owned, never written by oke)\n` +
110
+ `(${composeDir}/compose.all.yml is layers 1–3 merged — single-file alternative for Swarm / one \`-c\`)\n`,
111
+ );
109
112
  return { code: 0, result };
110
113
  } catch (err) {
111
114
  console.error(err instanceof Error ? err.message : String(err));
@@ -21,6 +21,7 @@ describe("defaultImagesFromConfig", () => {
21
21
  });
22
22
  expect(images).toEqual({
23
23
  "store.sql": "postgres:18-alpine",
24
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
24
25
  "store.kv": "redis:8-alpine",
25
26
  });
26
27
  });
@@ -35,6 +36,31 @@ describe("defaultImagesFromConfig", () => {
35
36
  },
36
37
  });
37
38
  expect(images["store.sql"]).toBe("pgvector/pgvector:pg17");
39
+ expect(images.pgdog).toBe("ghcr.io/pgdogdev/pgdog:v0.1.51");
40
+ });
41
+
42
+ test("clock postgres alone pulls store.sql image", () => {
43
+ const images = defaultImagesFromConfig({
44
+ drivers: {
45
+ clock: { prod: "postgres" },
46
+ },
47
+ });
48
+ expect(images).toEqual({
49
+ "store.sql": "postgres:18-alpine",
50
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
51
+ });
52
+ });
53
+
54
+ test("journal postgres alone pulls store.sql image", () => {
55
+ const images = defaultImagesFromConfig({
56
+ drivers: {
57
+ journal: { prod: "postgres" },
58
+ },
59
+ });
60
+ expect(images).toEqual({
61
+ "store.sql": "postgres:18-alpine",
62
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
63
+ });
38
64
  });
39
65
 
40
66
  test("returns empty when no container drivers", () => {
@@ -9,6 +9,7 @@ import type { Manifest } from "../manifest/types.ts";
9
9
  /** Default image pins when `images` is omitted but prod drivers need containers. */
10
10
  const DEFAULT_SQL_IMAGE = "postgres:18-alpine";
11
11
  const DEFAULT_PGVECTOR_IMAGE = "pgvector/pgvector:pg17";
12
+ const DEFAULT_PGDOG_IMAGE = "ghcr.io/pgdogdev/pgdog:v0.1.51";
12
13
  const DEFAULT_KV_IMAGE = "redis:8-alpine";
13
14
  const DEFAULT_VAULT_IMAGE = "openbao/openbao:2.6.1";
14
15
 
@@ -47,18 +48,25 @@ export function defaultImagesFromConfig(config: OkeConfig): Readonly<Record<stri
47
48
  const index = dockerOrProdId(config.drivers?.store?.index);
48
49
  const kv = dockerOrProdId(config.drivers?.store?.kv);
49
50
  const signal = dockerOrProdId(config.drivers?.signal);
51
+ const clock = dockerOrProdId(config.drivers?.clock);
52
+ const journal = dockerOrProdId(config.drivers?.journal);
50
53
 
51
- // Only real SQL-consuming facets pull a SQL image. Clock/signal no longer
52
- // advertise a fictional postgres CronStore / LISTEN bus via compose.
54
+ // SQL-consuming facets pull a SQL image. Signal postgres still needs a
55
+ // LISTEN/NOTIFY client (not Bun.SQL); clock + journal postgres are real
56
+ // SKIP LOCKED stores (CronStore / durable-run journal).
53
57
  const needsSql =
54
58
  sql === "postgres" ||
55
59
  sql === "pgvector" ||
56
60
  index === "pgvector" ||
61
+ clock === "postgres" ||
62
+ journal === "postgres" ||
57
63
  (config.drivers?.prod ?? []).some((p) => p === "postgres" || p === "pgvector");
58
64
 
59
65
  if (needsSql) {
60
66
  out["store.sql"] =
61
67
  sql === "pgvector" || index === "pgvector" ? DEFAULT_PGVECTOR_IMAGE : DEFAULT_SQL_IMAGE;
68
+ // PgDog in front of Postgres — caps real server connections under horizontal scale.
69
+ out.pgdog = DEFAULT_PGDOG_IMAGE;
62
70
  }
63
71
 
64
72
  const needsKv =