okengine 0.7.0 → 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 (61) hide show
  1. package/package.json +2 -2
  2. package/site/content/docs/elements/channel.mdx +23 -12
  3. package/site/content/docs/elements/clock.mdx +17 -15
  4. package/site/content/docs/elements/flow.mdx +6 -2
  5. package/site/content/docs/elements/store.mdx +131 -0
  6. package/site/content/docs/get-started/installation.mdx +18 -16
  7. package/site/content/docs/plugins/magic-link.mdx +42 -0
  8. package/site/content/docs/plugins/phone-number.mdx +78 -17
  9. package/site/content/docs/plugins/two-factor.mdx +1 -0
  10. package/site/content/docs/reference/cli.md +2 -0
  11. package/site/content/docs/reference/configuration.mdx +5 -3
  12. package/site/content/docs/reference/environment-variables.mdx +20 -8
  13. package/src/cli/db-seed.ts +359 -0
  14. package/src/cli/db.test.ts +341 -3
  15. package/src/cli/db.ts +75 -8
  16. package/src/cli/load-config.images.test.ts +22 -0
  17. package/src/cli/load-config.ts +7 -2
  18. package/src/cli/registry.ts +37 -1
  19. package/src/compiler/effects-infer.ts +1 -0
  20. package/src/config/index.ts +4 -0
  21. package/src/drivers/channel-sently.test.ts +8 -0
  22. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  23. package/src/drivers/channel-types.ts +71 -0
  24. package/src/drivers/clock-postgres.test.ts +258 -0
  25. package/src/drivers/clock-postgres.ts +410 -0
  26. package/src/drivers/index.ts +18 -0
  27. package/src/drivers/journal-postgres.test.ts +175 -0
  28. package/src/drivers/journal-postgres.ts +492 -0
  29. package/src/elements/channel/runtime.ts +51 -0
  30. package/src/elements/channel.test.ts +71 -0
  31. package/src/elements/clock/chaos-child.ts +280 -41
  32. package/src/elements/clock/durable.ts +7 -0
  33. package/src/elements/clock/reconcile.ts +2 -2
  34. package/src/elements/clock/runtime.ts +5 -3
  35. package/src/elements/clock.ts +1 -1
  36. package/src/elements/store/seed.test.ts +27 -0
  37. package/src/elements/store/seed.ts +68 -0
  38. package/src/elements/store/sql-session.test.ts +39 -0
  39. package/src/elements/store/sql-session.ts +55 -0
  40. package/src/elements/store/upsert-app.test.ts +103 -0
  41. package/src/elements/store.ts +5 -0
  42. package/src/index.ts +15 -0
  43. package/src/kernel/app.ts +165 -14
  44. package/src/kernel/boot-bind/channel.test.ts +16 -0
  45. package/src/kernel/boot-bind/channel.ts +13 -0
  46. package/src/kernel/boot-bind/clock.ts +17 -6
  47. package/src/kernel/boot-bind/honor-config.test.ts +105 -4
  48. package/src/kernel/boot-bind/journal.ts +89 -0
  49. package/src/kernel/boot.test.ts +6 -4
  50. package/src/kernel/boot.ts +53 -13
  51. package/src/kernel/concurrency.ts +1 -1
  52. package/src/kernel/fx.test.ts +6 -0
  53. package/src/kernel/fx.ts +126 -5
  54. package/src/kernel/index.ts +6 -0
  55. package/src/kernel/journal-boot.test.ts +397 -0
  56. package/src/kernel/journal-suspend.ts +35 -0
  57. package/src/kernel/journal.test.ts +142 -0
  58. package/src/kernel/journal.ts +202 -27
  59. package/src/plugins/auth-methods.security.test.ts +10 -7
  60. package/src/plugins/phone-number.ts +67 -10
  61. package/src/plugins/taqnyat.live.test.ts +174 -0
@@ -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 {
@@ -37,6 +37,28 @@ describe("defaultImagesFromConfig", () => {
37
37
  expect(images["store.sql"]).toBe("pgvector/pgvector:pg17");
38
38
  });
39
39
 
40
+ test("clock postgres alone pulls store.sql image", () => {
41
+ const images = defaultImagesFromConfig({
42
+ drivers: {
43
+ clock: { prod: "postgres" },
44
+ },
45
+ });
46
+ expect(images).toEqual({
47
+ "store.sql": "postgres:18-alpine",
48
+ });
49
+ });
50
+
51
+ test("journal postgres alone pulls store.sql image", () => {
52
+ const images = defaultImagesFromConfig({
53
+ drivers: {
54
+ journal: { prod: "postgres" },
55
+ },
56
+ });
57
+ expect(images).toEqual({
58
+ "store.sql": "postgres:18-alpine",
59
+ });
60
+ });
61
+
40
62
  test("returns empty when no container drivers", () => {
41
63
  expect(defaultImagesFromConfig({})).toEqual({});
42
64
  expect(
@@ -47,13 +47,18 @@ export function defaultImagesFromConfig(config: OkeConfig): Readonly<Record<stri
47
47
  const index = dockerOrProdId(config.drivers?.store?.index);
48
48
  const kv = dockerOrProdId(config.drivers?.store?.kv);
49
49
  const signal = dockerOrProdId(config.drivers?.signal);
50
+ const clock = dockerOrProdId(config.drivers?.clock);
51
+ const journal = dockerOrProdId(config.drivers?.journal);
50
52
 
51
- // Only real SQL-consuming facets pull a SQL image. Clock/signal no longer
52
- // advertise a fictional postgres CronStore / LISTEN bus via compose.
53
+ // SQL-consuming facets pull a SQL image. Signal postgres still needs a
54
+ // LISTEN/NOTIFY client (not Bun.SQL); clock + journal postgres are real
55
+ // SKIP LOCKED stores (CronStore / durable-run journal).
53
56
  const needsSql =
54
57
  sql === "postgres" ||
55
58
  sql === "pgvector" ||
56
59
  index === "pgvector" ||
60
+ clock === "postgres" ||
61
+ journal === "postgres" ||
57
62
  (config.drivers?.prod ?? []).some((p) => p === "postgres" || p === "pgvector");
58
63
 
59
64
  if (needsSql) {
@@ -202,7 +202,7 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
202
202
  },
203
203
  {
204
204
  name: "db",
205
- summary: "domain schema — push · generate · migrate",
205
+ summary: "domain schema — push · generate · migrate · seed",
206
206
  subcommands: [
207
207
  {
208
208
  name: "push",
@@ -215,6 +215,12 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
215
215
  valueName: "path",
216
216
  summary: "drizzle.config.ts path",
217
217
  },
218
+ {
219
+ long: "--env",
220
+ takesValue: true,
221
+ valueName: "name",
222
+ summary: "Config env (local|docker|test|prod)",
223
+ },
218
224
  HELP,
219
225
  ],
220
226
  },
@@ -229,6 +235,12 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
229
235
  valueName: "path",
230
236
  summary: "drizzle.config.ts path",
231
237
  },
238
+ {
239
+ long: "--env",
240
+ takesValue: true,
241
+ valueName: "name",
242
+ summary: "Config env (local|docker|test|prod)",
243
+ },
232
244
  HELP,
233
245
  ],
234
246
  },
@@ -243,6 +255,30 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
243
255
  valueName: "path",
244
256
  summary: "drizzle.config.ts path",
245
257
  },
258
+ {
259
+ long: "--env",
260
+ takesValue: true,
261
+ valueName: "name",
262
+ summary: "Config env (local|docker|test|prod)",
263
+ },
264
+ HELP,
265
+ ],
266
+ },
267
+ {
268
+ name: "seed",
269
+ summary: "Run defineSeed (essential + env category); never at boot",
270
+ flags: [
271
+ {
272
+ long: "--env",
273
+ takesValue: true,
274
+ valueName: "name",
275
+ summary: "Config env (local|docker|test|prod)",
276
+ },
277
+ {
278
+ long: "--force",
279
+ summary: "Skip docker/prod confirmation prompt",
280
+ },
281
+ ENTRY,
246
282
  HELP,
247
283
  ],
248
284
  },
@@ -114,6 +114,7 @@ const TABLE_ARG_METHODS = new Set([
114
114
  "findById",
115
115
  "exists",
116
116
  "update",
117
+ "upsert",
117
118
  "increment",
118
119
  "delete",
119
120
  ]);
@@ -73,6 +73,8 @@ export interface DriversConfig {
73
73
  readonly store?: StoreDriversConfig;
74
74
  readonly signal?: EnvDriverMap;
75
75
  readonly clock?: EnvDriverMap;
76
+ /** Durable-run journal: `memory` · `file` · `postgres`. */
77
+ readonly journal?: EnvDriverMap;
76
78
  readonly vault?: EnvDriverMap;
77
79
  readonly channel?: ChannelDriversConfig;
78
80
  readonly ai?: EnvDriverMap;
@@ -283,6 +285,7 @@ export function normalizeDriversConfig(
283
285
  ...(store !== undefined ? { store } : {}),
284
286
  signal: normalizeEnvDriverMap(drivers.signal as RawEnvDriverMap),
285
287
  clock: normalizeEnvDriverMap(drivers.clock as RawEnvDriverMap),
288
+ journal: normalizeEnvDriverMap(drivers.journal as RawEnvDriverMap),
286
289
  vault: normalizeEnvDriverMap(drivers.vault as RawEnvDriverMap),
287
290
  ...(channel !== undefined ? { channel } : {}),
288
291
  ai: normalizeEnvDriverMap(drivers.ai as RawEnvDriverMap),
@@ -333,6 +336,7 @@ export function fillDriversDockerFromProd(
333
336
  ...(store !== undefined ? { store } : {}),
334
337
  signal: fillDockerFromProd(drivers.signal),
335
338
  clock: fillDockerFromProd(drivers.clock),
339
+ journal: fillDockerFromProd(drivers.journal),
336
340
  vault: fillDockerFromProd(drivers.vault),
337
341
  ...(channel !== undefined ? { channel } : {}),
338
342
  ai: fillDockerFromProd(drivers.ai),