okengine 0.19.8 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/AGENTS.md +1 -1
  2. package/package.json +1 -1
  3. package/site/content/docs/client/auth.mdx +1 -2
  4. package/site/content/docs/client/calling.mdx +8 -8
  5. package/site/content/docs/client/index.mdx +4 -4
  6. package/site/content/docs/client/react.mdx +5 -0
  7. package/site/content/docs/elements/channel/email.mdx +25 -36
  8. package/site/content/docs/elements/channel/index.mdx +88 -46
  9. package/site/content/docs/elements/channel/push.mdx +7 -9
  10. package/site/content/docs/elements/channel/sms.mdx +6 -4
  11. package/site/content/docs/elements/channel/whatsapp.mdx +11 -9
  12. package/site/content/docs/elements/clock/index.mdx +14 -25
  13. package/site/content/docs/elements/flow/http.mdx +24 -8
  14. package/site/content/docs/elements/flow/index.mdx +15 -12
  15. package/site/content/docs/elements/flow/routing.mdx +166 -122
  16. package/site/content/docs/elements/gate/rls.mdx +2 -2
  17. package/site/content/docs/elements/store/index.mdx +11 -3
  18. package/site/content/docs/elements/store/search.mdx +5 -5
  19. package/site/content/docs/elements/store/sql.mdx +91 -33
  20. package/site/content/docs/elements/vault/index.mdx +3 -5
  21. package/site/content/docs/plugins/magic-link.mdx +4 -3
  22. package/site/content/docs/plugins/otp.mdx +4 -3
  23. package/site/content/docs/plugins/two-factor.mdx +4 -0
  24. package/site/content/docs/providers/index.mdx +1 -1
  25. package/site/content/docs/recipes/index.mdx +1 -1
  26. package/site/content/docs/reference/cli.mdx +13 -4
  27. package/site/content/docs/reference/configuration.mdx +7 -7
  28. package/site/content/docs/reference/errors.mdx +30 -30
  29. package/site/content/docs/reference/fx.mdx +16 -8
  30. package/site/content/docs/reference/i18n.mdx +4 -4
  31. package/site/content/docs/reference/plugins.mdx +4 -4
  32. package/site/content/docs/understand/try-it.mdx +2 -0
  33. package/src/cli/ai-setup/ai-setup.test.ts +40 -0
  34. package/src/cli/ai-setup/apply.ts +28 -46
  35. package/src/cli/ask-vault-gaps.test.ts +60 -4
  36. package/src/cli/ask-vault-gaps.ts +59 -3
  37. package/src/cli/build.test.ts +3 -3
  38. package/src/cli/build.ts +5 -5
  39. package/src/cli/db-auto-push.test.ts +11 -0
  40. package/src/cli/db-auto-push.ts +6 -2
  41. package/src/cli/db.test.ts +1 -1
  42. package/src/cli/db.ts +6 -6
  43. package/src/cli/dev-app-runner.ts +12 -4
  44. package/src/cli/dev-db-push.test.ts +6 -2
  45. package/src/cli/dev-schema-sync.ts +1 -1
  46. package/src/cli/dev.test.ts +10 -7
  47. package/src/cli/dev.ts +18 -12
  48. package/src/cli/ensure-drizzle-config.ts +4 -3
  49. package/src/client-react/browser.test.ts +23 -0
  50. package/src/client-react/use-live-query.ts +1 -1
  51. package/src/compiler/flow-path.test.ts +1 -0
  52. package/src/compiler/flow-path.ts +1 -1
  53. package/src/compiler/generate-adopt.test.ts +55 -1
  54. package/src/compiler/generate-adopt.ts +111 -21
  55. package/src/config/index.ts +6 -4
  56. package/src/console/ui-next/dist/assets/{access-page-C5yG4aS2.js → access-page-DFLu0wTA.js} +1 -1
  57. package/src/console/ui-next/dist/assets/{agent-disclosure-D2ToVK86.js → agent-disclosure-DGscxaF5.js} +1 -1
  58. package/src/console/ui-next/dist/assets/{cache-glyph-7HUR0kCz.js → cache-glyph-BGmRZk7d.js} +1 -1
  59. package/src/console/ui-next/dist/assets/{call-pii-button-D0ky3aXt.js → call-pii-button--feUYxvG.js} +1 -1
  60. package/src/console/ui-next/dist/assets/{collapsible-BLUHH2dB.js → collapsible-JWvpaiGY.js} +1 -1
  61. package/src/console/ui-next/dist/assets/{duration-tone-BgHEFMtm.js → duration-tone-D9yCJG4n.js} +1 -1
  62. package/src/console/ui-next/dist/assets/{flows-page-DDybqshQ.js → flows-page-Bs6MD9GB.js} +1 -1
  63. package/src/console/ui-next/dist/assets/{highlighted-json-BFiJKYV4.js → highlighted-json-xH8MrEnv.js} +1 -1
  64. package/src/console/ui-next/dist/assets/{http-method-BVAcB0bI.js → http-method-C4vB6ZIw.js} +1 -1
  65. package/src/console/ui-next/dist/assets/{index-BID6LSYI.js → index-yTCY4AcS.js} +3 -3
  66. package/src/console/ui-next/dist/assets/{observability-page-CANo_mMK.js → observability-page-BxJ3R6dU.js} +1 -1
  67. package/src/console/ui-next/dist/assets/{replica-lag-CqQGCshp.js → replica-lag-QRKB_IE8.js} +1 -1
  68. package/src/console/ui-next/dist/assets/{request-meta-DyUDkEt4.js → request-meta-DqZ-fMu5.js} +1 -1
  69. package/src/console/ui-next/dist/assets/{store-page-CA0rJ_Ow.js → store-page-Dixb6L7a.js} +1 -1
  70. package/src/console/ui-next/dist/assets/{trace-detail-sheet-DjH_fMzI.js → trace-detail-sheet-CazhjtiU.js} +1 -1
  71. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWSomIeQ.js → tree-expand-toggle-DlnqYKfr.js} +1 -1
  72. package/src/console/ui-next/dist/assets/{units-page-Cz9TIViw.js → units-page-BXTLjU2-.js} +1 -1
  73. package/src/console/ui-next/dist/assets/{vault-page-Bb1o1O9p.js → vault-page-39KR__bc.js} +1 -1
  74. package/src/console/ui-next/dist/index.html +1 -1
  75. package/src/drivers/clock-postgres.test.ts +10 -2
  76. package/src/drivers/clock-postgres.ts +18 -2
  77. package/src/drivers/vault-driver-removal.test.ts +2 -2
  78. package/src/elements/channel/declare.ts +66 -3
  79. package/src/elements/channel/runtime.ts +9 -11
  80. package/src/elements/channel.test.ts +42 -0
  81. package/src/elements/channel.ts +4 -2
  82. package/src/elements/clock/reconcile.ts +45 -24
  83. package/src/elements/clock.test.ts +33 -0
  84. package/src/elements/store/emit-drizzle.ts +285 -65
  85. package/src/elements/store/load-plugin-tables.ts +1 -1
  86. package/src/elements/store/prepare-row.test.ts +57 -4
  87. package/src/elements/store/schema-decl.test.ts +178 -0
  88. package/src/elements/store/sql-session.ts +44 -2
  89. package/src/elements/store/table.ts +8 -6
  90. package/src/kernel/adopt-barrel-fresh.test.ts +1 -1
  91. package/src/kernel/app.ts +26 -32
  92. package/src/kernel/auto-registry.test.ts +26 -1
  93. package/src/kernel/boot.ts +2 -2
  94. package/src/kernel/boundary-contract.ts +6 -1
  95. package/src/kernel/errors.ts +3 -3
  96. package/src/kernel/flow-units.ts +3 -3
  97. package/src/kernel/fx.ts +12 -2
  98. package/src/kernel/mutation-id.ts +8 -0
  99. package/src/kernel/plugin.ts +4 -3
  100. package/src/kernel/project-out.test.ts +176 -0
  101. package/src/kernel/project-out.ts +91 -0
  102. package/src/kernel/realtime-bind.ts +2 -3
  103. package/src/kernel/router/linear.ts +12 -6
  104. package/src/kernel/router.test.ts +13 -0
  105. package/src/plugins/magic-link.ts +25 -24
  106. package/src/plugins/otp.ts +35 -24
  107. package/src/plugins/two-factor.ts +15 -0
  108. package/src/runs/duckdb.test.ts +2 -2
@@ -75,6 +75,14 @@ export interface CronStore {
75
75
  * @param leaseMs - Lease TTL
76
76
  */
77
77
  acquireLease(name: string, instanceId: string, now: number, leaseMs: number): Promise<boolean>;
78
+ /**
79
+ * Atomic read-modify-write. Reconcile uses this so a concurrent
80
+ * {@link CronStore.acquireLease} cannot be clobbered by a stale `get` + `put`.
81
+ *
82
+ * @param name - Cron name
83
+ * @param fn - Receives the current row (or `undefined`); return the next row
84
+ */
85
+ update(name: string, fn: (prev: CronRow | undefined) => CronRow): Promise<void>;
78
86
  }
79
87
 
80
88
  /** Result of one reconciliation pass. */
@@ -200,30 +208,29 @@ export async function orphanPerTenantCronRows(
200
208
  }
201
209
 
202
210
  async function putActiveRow(store: CronStore, decl: ClockDecl, name: string): Promise<void> {
203
- const prev = await store.get(name);
204
- const overrideCron =
205
- decl.overridable && prev?.overrideCron !== undefined ? prev.overrideCron : undefined;
206
- const overrideEvery =
207
- decl.overridable && prev?.overrideEvery !== undefined ? prev.overrideEvery : undefined;
208
-
209
- const row: CronRow = {
210
- name,
211
- declaredCron: decl.cron,
212
- declaredEvery: decl.every,
213
- overrideCron,
214
- overrideEvery,
215
- effectiveCron: overrideCron ?? decl.cron,
216
- effectiveEvery: overrideEvery ?? decl.every,
217
- timezone: decl.timezone,
218
- overridable: decl.overridable,
219
- status: "active",
220
- leaderInstanceId: prev?.leaderInstanceId,
221
- leaderLeaseUntil: prev?.leaderLeaseUntil,
222
- lastRunAt: prev?.lastRunAt,
223
- nextRunAt: prev?.nextRunAt,
224
- dstAmbiguity: prev?.dstAmbiguity,
225
- };
226
- await store.put(row);
211
+ await store.update(name, (prev) => {
212
+ const overrideCron =
213
+ decl.overridable && prev?.overrideCron !== undefined ? prev.overrideCron : undefined;
214
+ const overrideEvery =
215
+ decl.overridable && prev?.overrideEvery !== undefined ? prev.overrideEvery : undefined;
216
+ return {
217
+ name,
218
+ declaredCron: decl.cron,
219
+ declaredEvery: decl.every,
220
+ overrideCron,
221
+ overrideEvery,
222
+ effectiveCron: overrideCron ?? decl.cron,
223
+ effectiveEvery: overrideEvery ?? decl.every,
224
+ timezone: decl.timezone,
225
+ overridable: decl.overridable,
226
+ status: "active",
227
+ leaderInstanceId: prev?.leaderInstanceId,
228
+ leaderLeaseUntil: prev?.leaderLeaseUntil,
229
+ lastRunAt: prev?.lastRunAt,
230
+ nextRunAt: prev?.nextRunAt,
231
+ dstAmbiguity: prev?.dstAmbiguity,
232
+ };
233
+ });
227
234
  }
228
235
 
229
236
  /**
@@ -305,6 +312,12 @@ export function createMemoryCronStore(seed?: readonly CronRow[]): CronStore {
305
312
  return true;
306
313
  });
307
314
  },
315
+ async update(name, fn) {
316
+ return withLock(name, () => {
317
+ const prev = rows.get(name);
318
+ rows.set(name, structuredClone(fn(prev ? structuredClone(prev) : undefined)));
319
+ });
320
+ },
308
321
  };
309
322
  }
310
323
 
@@ -409,5 +422,13 @@ export function createFileCronStore(path: string): CronStore {
409
422
  return true;
410
423
  });
411
424
  },
425
+ async update(name, fn) {
426
+ await withFileLock(async () => {
427
+ const map = await loadUnlocked();
428
+ const prev = map.get(name);
429
+ map.set(name, structuredClone(fn(prev ? structuredClone(prev) : undefined)));
430
+ await flushUnlocked(map);
431
+ });
432
+ },
412
433
  };
413
434
  }
@@ -25,6 +25,7 @@ import {
25
25
  import {
26
26
  clock,
27
27
  createClockRuntime,
28
+ createFileCronStore,
28
29
  createMemoryCronStore,
29
30
  createTestClockRuntime,
30
31
  createTimeTravel,
@@ -218,6 +219,38 @@ describe("leader election", () => {
218
219
  expect(fired).toEqual([winner, "i-b"]);
219
220
  });
220
221
 
222
+ test("file store: overlapping reconcile cannot double-fire a due tick", async () => {
223
+ const dir = await mkdtemp(join(tmpdir(), "oke-clock-file-race-"));
224
+ try {
225
+ const store = createFileCronStore(join(dir, "crons.json"));
226
+ const fired: string[] = [];
227
+ const mk = (instanceId: string) => {
228
+ const rt = createClockRuntime({ instanceId, store, leaseMs: 200 });
229
+ rt.register(clock("once", { every: "1h" }));
230
+ rt.onCron("once", () => {
231
+ fired.push(instanceId);
232
+ });
233
+ return rt;
234
+ };
235
+ const a = mk("a");
236
+ const b = mk("b");
237
+ const results = await Promise.all([
238
+ (async () => {
239
+ await a.reconcile();
240
+ return a.tick();
241
+ })(),
242
+ (async () => {
243
+ await b.reconcile();
244
+ return b.tick();
245
+ })(),
246
+ ]);
247
+ expect(results.flatMap((r) => r.ran).filter((n) => n === "once")).toHaveLength(1);
248
+ expect(fired).toHaveLength(1);
249
+ } finally {
250
+ await rm(dir, { recursive: true, force: true });
251
+ }
252
+ });
253
+
221
254
  test("tryAcquireLease is exclusive within the lease window", async () => {
222
255
  const store = createMemoryCronStore([
223
256
  {
@@ -6,8 +6,8 @@
6
6
  */
7
7
 
8
8
  import { existsSync } from "node:fs";
9
- import { mkdir, writeFile } from "node:fs/promises";
10
- import { dirname, join } from "node:path";
9
+ import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
10
+ import { basename, dirname, join } from "node:path";
11
11
  import type { TableContribution } from "../../kernel/plugin.ts";
12
12
  import {
13
13
  isFieldBuilder,
@@ -34,11 +34,35 @@ export type SqlDialect = "postgres";
34
34
  /** Header written at the top of generated files. */
35
35
  export const GENERATED_SCHEMA_HEADER = "// generated by oke — do not edit";
36
36
 
37
- /** Default output path (relative to project root). */
38
- export const DEFAULT_GENERATED_SCHEMA_REL = "src/db/schema.drizzle.ts";
37
+ /** Default generated Drizzle barrel (relative to project root). */
38
+ export const DEFAULT_GENERATED_SCHEMA_REL = "src/db/drizzle/index.ts";
39
39
 
40
- /** Default declaration module path (relative to project root). */
41
- export const DEFAULT_DECLARE_SCHEMA_REL = "src/db/schema.decl.ts";
40
+ /**
41
+ * Prior single-file emit (`src/db/drizzle.ts`). Folder emit deletes this
42
+ * sibling when it still carries the generated header.
43
+ */
44
+ export const PREV_SINGLE_DRIZZLE_REL = "src/db/drizzle.ts";
45
+
46
+ /**
47
+ * Prior emit filename (`src/db/schema.drizzle.ts`) — still resolved when
48
+ * present and the new default does not exist yet.
49
+ */
50
+ export const PREV_DRIZZLE_EMIT_REL = "src/db/schema.drizzle.ts";
51
+
52
+ /** Single-file declare module (takes priority when both layouts exist). */
53
+ export const DEFAULT_DECLARE_SCHEMA_REL = "src/db/schema.ts";
54
+
55
+ /**
56
+ * Prior single-file declare (`src/db/schema.decl.ts`) — still resolved when
57
+ * present and `schema.ts` does not exist yet.
58
+ */
59
+ export const PREV_DECLARE_SCHEMA_REL = "src/db/schema.decl.ts";
60
+
61
+ /**
62
+ * Folder declare module — `src/db/schema/index.ts`.
63
+ * Used when {@link DEFAULT_DECLARE_SCHEMA_REL} is absent.
64
+ */
65
+ export const DEFAULT_DECLARE_SCHEMA_DIR_REL = "src/db/schema/index.ts";
42
66
 
43
67
  /**
44
68
  * Prior `src/db/` emit filename — still resolved when present and the new
@@ -289,19 +313,26 @@ export interface EmitDrizzleSourceOptions {
289
313
  readonly relations?: readonly SchemaRelationsDecl[];
290
314
  }
291
315
 
292
- /**
293
- * Emit a full Drizzle schema module from abstract table decls.
294
- *
295
- * @param tables - App + plugin tables (already merged)
296
- * @param dialect - Always `postgres` (`pgTable`); retained for call-site clarity
297
- * @param options - Optional relations to emit as `defineRelations`
298
- */
299
- export function emitDrizzleSource(
316
+ function tableExportName(table: SchemaTableDecl): string {
317
+ return exportNameForTable(schemaTableSqlName(table) ?? table.name);
318
+ }
319
+
320
+ function referencedExportNames(table: SchemaTableDecl): string[] {
321
+ const self = tableExportName(table);
322
+ const names = new Set<string>();
323
+ for (const col of Object.values(table.columns)) {
324
+ if (!col.references) continue;
325
+ const target = col.references.ref();
326
+ const targetName = exportNameForTable(target.tableName ?? "unknown");
327
+ if (targetName !== self) names.add(targetName);
328
+ }
329
+ return [...names].sort();
330
+ }
331
+
332
+ function emitImportLines(
300
333
  tables: readonly SchemaTableDecl[],
301
- dialect: SqlDialect = "postgres",
302
- options: EmitDrizzleSourceOptions = {},
303
- ): string {
304
- void dialect;
334
+ options: { readonly relations?: boolean } = {},
335
+ ): string[] {
305
336
  const usedFactories = new Set<string>();
306
337
  for (const table of tables) {
307
338
  for (const col of Object.values(table.columns)) {
@@ -316,8 +347,9 @@ export function emitDrizzleSource(
316
347
  const coreNames = ["pgTable", ...pgIntImport, needsPolicy ? "pgPolicy" : null].filter(
317
348
  (name): name is string => name !== null,
318
349
  );
319
- const coreImport = `import { ${coreNames.join(", ")} } from "drizzle-orm/pg-core";`;
320
- const sqlImport = needsPolicy ? `import { sql } from "drizzle-orm";\n` : "";
350
+ const lines = [`import { ${coreNames.join(", ")} } from "drizzle-orm/pg-core";`];
351
+ if (needsPolicy) lines.push(`import { sql } from "drizzle-orm";`);
352
+ if (options.relations) lines.push(`import { defineRelations } from "drizzle-orm";`);
321
353
 
322
354
  const needsId = tables.some((t) =>
323
355
  Object.values(t.columns).some((c) => c.defaultFnKind === "id"),
@@ -337,30 +369,146 @@ export function emitDrizzleSource(
337
369
  needsNowIso ? "nowIso" : null,
338
370
  needsNowDate ? "nowDate" : null,
339
371
  ].filter((x): x is string => x !== null);
340
- const helperImport =
341
- helperNames.length > 0 ? `import { ${helperNames.join(", ")} } from "okengine/store";\n` : "";
372
+ if (helperNames.length > 0) {
373
+ lines.push(`import { ${helperNames.join(", ")} } from "okengine/store";`);
374
+ }
375
+ return lines;
376
+ }
342
377
 
343
- const relations = options.relations ?? [];
344
- const relationsImport =
345
- relations.length > 0 ? `import { defineRelations } from "drizzle-orm";\n` : "";
378
+ function joinGeneratedModule(parts: readonly string[]): string {
379
+ return parts.filter((line, i, arr) => !(line === "" && arr[i - 1] === "")).join("\n");
380
+ }
346
381
 
382
+ /**
383
+ * Emit a full Drizzle schema module from abstract table decls.
384
+ *
385
+ * @param tables - App + plugin tables (already merged)
386
+ * @param dialect - Always `postgres` (`pgTable`); retained for call-site clarity
387
+ * @param options - Optional relations to emit as `defineRelations`
388
+ */
389
+ export function emitDrizzleSource(
390
+ tables: readonly SchemaTableDecl[],
391
+ dialect: SqlDialect = "postgres",
392
+ options: EmitDrizzleSourceOptions = {},
393
+ ): string {
394
+ const relations = options.relations ?? [];
347
395
  const blocks = tables.map((table) => emitTableBlock(table, dialect));
348
-
349
396
  const relationsBlock = emitRelationsSource(relations);
350
-
351
- return [
397
+ return joinGeneratedModule([
352
398
  GENERATED_SCHEMA_HEADER,
353
- coreImport,
354
- sqlImport.trimEnd(),
355
- relationsImport.trimEnd(),
356
- helperImport.trimEnd(),
399
+ ...emitImportLines(tables, { relations: relations.length > 0 }),
357
400
  "",
358
401
  blocks.join("\n\n"),
359
402
  relationsBlock ? `\n${relationsBlock}` : "",
360
403
  "",
361
- ]
362
- .filter((line, i, arr) => !(line === "" && arr[i - 1] === ""))
363
- .join("\n");
404
+ ]);
405
+ }
406
+
407
+ /**
408
+ * One generated table module (`drizzle/<export>.ts`).
409
+ *
410
+ * @param table - Table declaration
411
+ * @param dialect - Always `postgres`
412
+ */
413
+ export function emitDrizzleTableSource(
414
+ table: SchemaTableDecl,
415
+ dialect: SqlDialect = "postgres",
416
+ ): string {
417
+ const refs = referencedExportNames(table).map(
418
+ (name) => `import { ${name} } from ${JSON.stringify(`./${name}.ts`)};`,
419
+ );
420
+ return joinGeneratedModule([
421
+ GENERATED_SCHEMA_HEADER,
422
+ ...emitImportLines([table]),
423
+ ...refs,
424
+ "",
425
+ emitTableBlock(table, dialect),
426
+ "",
427
+ ]);
428
+ }
429
+
430
+ /**
431
+ * Generated `drizzle/relations.ts` when declare relations exist.
432
+ *
433
+ * @param relations - Relations declarations
434
+ */
435
+ export function emitDrizzleRelationsFileSource(relations: readonly SchemaRelationsDecl[]): string {
436
+ if (relations.length === 0) return "";
437
+ const keys = new Set<string>();
438
+ for (const rel of relations) {
439
+ for (const k of rel.tableKeys) keys.add(k);
440
+ }
441
+ const imports = [...keys]
442
+ .sort()
443
+ .map((name) => `import { ${name} } from ${JSON.stringify(`./${name}.ts`)};`);
444
+ return joinGeneratedModule([
445
+ GENERATED_SCHEMA_HEADER,
446
+ `import { defineRelations } from "drizzle-orm";`,
447
+ ...imports,
448
+ "",
449
+ emitRelationsSource(relations),
450
+ "",
451
+ ]);
452
+ }
453
+
454
+ /**
455
+ * Generated `drizzle/index.ts` barrel.
456
+ *
457
+ * @param fileNames - Table (and relations) filenames to re-export
458
+ */
459
+ export function emitDrizzleIndexSource(fileNames: readonly string[]): string {
460
+ const exports = fileNames.map((name) => `export * from ${JSON.stringify(`./${name}`)};`);
461
+ return `${GENERATED_SCHEMA_HEADER}\n${exports.join("\n")}\n`;
462
+ }
463
+
464
+ /**
465
+ * Filename → source for a generated `drizzle/` folder (one table per file).
466
+ *
467
+ * @param tables - App + plugin tables (already merged)
468
+ * @param dialect - Always `postgres`
469
+ * @param options - Optional relations file
470
+ */
471
+ export function emitDrizzleFolderSources(
472
+ tables: readonly SchemaTableDecl[],
473
+ dialect: SqlDialect = "postgres",
474
+ options: EmitDrizzleSourceOptions = {},
475
+ ): ReadonlyMap<string, string> {
476
+ const files = new Map<string, string>();
477
+ const tableFiles: string[] = [];
478
+ for (const table of tables) {
479
+ const name = `${tableExportName(table)}.ts`;
480
+ files.set(name, emitDrizzleTableSource(table, dialect));
481
+ tableFiles.push(name);
482
+ }
483
+ const relations = options.relations ?? [];
484
+ const extra: string[] = [];
485
+ if (relations.length > 0) {
486
+ files.set("relations.ts", emitDrizzleRelationsFileSource(relations));
487
+ extra.push("relations.ts");
488
+ }
489
+ files.set("index.ts", emitDrizzleIndexSource([...tableFiles, ...extra]));
490
+ return files;
491
+ }
492
+
493
+ /**
494
+ * Whether `db.generated` is the folder barrel (`drizzle/index.ts`).
495
+ *
496
+ * @param rel - Path relative to project root
497
+ */
498
+ export function isFolderGeneratedRel(rel: string): boolean {
499
+ const n = rel.replace(/\\/g, "/").replace(/\/$/, "");
500
+ return /(?:^|\/)drizzle\/index\.tsx?$/.test(n) || /(?:^|\/)drizzle$/.test(n);
501
+ }
502
+
503
+ /**
504
+ * Normalize `db.generated` so a directory `drizzle` becomes `drizzle/index.ts`.
505
+ *
506
+ * @param rel - Path relative to project root
507
+ */
508
+ export function normalizeGeneratedRel(rel: string): string {
509
+ const n = rel.replace(/\\/g, "/").replace(/\/$/, "");
510
+ if (n.endsWith("/drizzle") || n === "drizzle") return `${n}/index.ts`;
511
+ return n;
364
512
  }
365
513
 
366
514
  function emitTableBlock(table: SchemaTableDecl, dialect: SqlDialect): string {
@@ -534,6 +682,19 @@ export async function maybeEmitDomainSchema(
534
682
  }
535
683
 
536
684
  const tables = mergeSchemaTables(appTables, fromPlugins);
685
+ if (isFolderGeneratedRel(outRel)) {
686
+ const dirAbs = dirname(outAbs);
687
+ const files = emitDrizzleFolderSources(tables, options.dialect, {
688
+ relations: appRelations,
689
+ });
690
+ await writeGeneratedFolder(dirAbs, files);
691
+ await removeLegacySingleDrizzleFile(dirAbs);
692
+ options.write?.(
693
+ `oke db: emitted ${outRel} (${tables.length} table${tables.length === 1 ? "" : "s"}, ${options.dialect})\n`,
694
+ );
695
+ return { emitted: true, path: outAbs, tableCount: tables.length };
696
+ }
697
+
537
698
  const source = emitDrizzleSource(tables, options.dialect, { relations: appRelations });
538
699
  await mkdir(dirname(outAbs), { recursive: true });
539
700
  await writeFile(outAbs, source, "utf8");
@@ -543,9 +704,95 @@ export async function maybeEmitDomainSchema(
543
704
  return { emitted: true, path: outAbs, tableCount: tables.length };
544
705
  }
545
706
 
707
+ async function writeGeneratedFolder(
708
+ dirAbs: string,
709
+ files: ReadonlyMap<string, string>,
710
+ ): Promise<void> {
711
+ await mkdir(dirAbs, { recursive: true });
712
+ for (const [name, source] of files) {
713
+ await writeFile(join(dirAbs, name), source, "utf8");
714
+ }
715
+ let existing: string[] = [];
716
+ try {
717
+ existing = await readdir(dirAbs);
718
+ } catch {
719
+ return;
720
+ }
721
+ for (const name of existing) {
722
+ if (!name.endsWith(".ts") && !name.endsWith(".tsx")) continue;
723
+ if (files.has(name)) continue;
724
+ const abs = join(dirAbs, name);
725
+ try {
726
+ const content = await readFile(abs, "utf8");
727
+ if (content.startsWith(GENERATED_SCHEMA_HEADER)) await unlink(abs);
728
+ } catch {
729
+ // ignore prune failures
730
+ }
731
+ }
732
+ }
733
+
734
+ async function removeLegacySingleDrizzleFile(dirAbs: string): Promise<void> {
735
+ if (basename(dirAbs) !== "drizzle") return;
736
+ const sibling = join(dirname(dirAbs), basename(PREV_SINGLE_DRIZZLE_REL));
737
+ if (!existsSync(sibling)) return;
738
+ try {
739
+ const content = await readFile(sibling, "utf8");
740
+ if (content.startsWith(GENERATED_SCHEMA_HEADER)) await unlink(sibling);
741
+ } catch {
742
+ // ignore
743
+ }
744
+ }
745
+
746
+ /**
747
+ * Discover the declare module when `db.declare` is omitted.
748
+ *
749
+ * Order: `schema.ts` (single file) → `schema.decl.ts` (prior name) →
750
+ * `schema/index.ts` (folder) → legacy `src/schema.decl.ts` → default
751
+ * `schema.ts` (may not exist yet).
752
+ *
753
+ * @param cwd - Project root
754
+ */
755
+ export function discoverDeclareRel(cwd: string): string {
756
+ if (existsSync(join(cwd, DEFAULT_DECLARE_SCHEMA_REL))) {
757
+ return DEFAULT_DECLARE_SCHEMA_REL;
758
+ }
759
+ if (existsSync(join(cwd, PREV_DECLARE_SCHEMA_REL))) {
760
+ return PREV_DECLARE_SCHEMA_REL;
761
+ }
762
+ if (existsSync(join(cwd, DEFAULT_DECLARE_SCHEMA_DIR_REL))) {
763
+ return DEFAULT_DECLARE_SCHEMA_DIR_REL;
764
+ }
765
+ if (existsSync(join(cwd, LEGACY_DECLARE_SCHEMA_REL))) {
766
+ return LEGACY_DECLARE_SCHEMA_REL;
767
+ }
768
+ return DEFAULT_DECLARE_SCHEMA_REL;
769
+ }
770
+
771
+ /**
772
+ * Discover the generated Drizzle emit path when `db.generated` is omitted.
773
+ *
774
+ * Order: `src/db/drizzle/index.ts`, except legacy `src/schema.generated.ts`
775
+ * when the declare module is still on the old `src/` layout. Older single
776
+ * files (`drizzle.ts`, `schema.drizzle.ts`) are not re-targeted — set
777
+ * `db.generated` to keep emitting them.
778
+ *
779
+ * @param cwd - Project root
780
+ * @param declareRel - Resolved declare relative path
781
+ */
782
+ export function discoverGeneratedRel(cwd: string, declareRel: string): string {
783
+ const preferSrcLegacy =
784
+ declareRel === LEGACY_DECLARE_SCHEMA_REL ||
785
+ (!existsSync(join(cwd, DEFAULT_DECLARE_SCHEMA_REL)) &&
786
+ !existsSync(join(cwd, PREV_DECLARE_SCHEMA_REL)) &&
787
+ !existsSync(join(cwd, DEFAULT_DECLARE_SCHEMA_DIR_REL)) &&
788
+ existsSync(join(cwd, LEGACY_GENERATED_SCHEMA_REL)));
789
+ if (preferSrcLegacy) return LEGACY_GENERATED_SCHEMA_REL;
790
+ return DEFAULT_GENERATED_SCHEMA_REL;
791
+ }
792
+
546
793
  /**
547
794
  * Resolve declare/out paths from an optional db config slice.
548
- * Prefers explicit config, then new `src/db/*` defaults, then legacy `src/*`.
795
+ * Prefers explicit `db.declare` / `db.generated`, then discovery.
549
796
  *
550
797
  * @param cwd - Project root
551
798
  * @param db - Config `db` block
@@ -554,35 +801,8 @@ export function resolveEmitPaths(
554
801
  cwd: string,
555
802
  db?: { readonly declare?: string; readonly generated?: string },
556
803
  ): { readonly declarePath: string; readonly outPath: string } {
557
- let declareRel = db?.declare;
558
- if (!declareRel) {
559
- if (existsSync(join(cwd, DEFAULT_DECLARE_SCHEMA_REL))) {
560
- declareRel = DEFAULT_DECLARE_SCHEMA_REL;
561
- } else if (existsSync(join(cwd, LEGACY_DECLARE_SCHEMA_REL))) {
562
- declareRel = LEGACY_DECLARE_SCHEMA_REL;
563
- } else {
564
- declareRel = DEFAULT_DECLARE_SCHEMA_REL;
565
- }
566
- }
567
-
568
- let outRel = db?.generated;
569
- if (!outRel) {
570
- const preferSrcLegacy =
571
- declareRel === LEGACY_DECLARE_SCHEMA_REL ||
572
- (!existsSync(join(cwd, DEFAULT_DECLARE_SCHEMA_REL)) &&
573
- existsSync(join(cwd, LEGACY_GENERATED_SCHEMA_REL)));
574
- if (preferSrcLegacy) {
575
- outRel = LEGACY_GENERATED_SCHEMA_REL;
576
- } else if (
577
- !existsSync(join(cwd, DEFAULT_GENERATED_SCHEMA_REL)) &&
578
- existsSync(join(cwd, PREV_GENERATED_SCHEMA_REL))
579
- ) {
580
- outRel = PREV_GENERATED_SCHEMA_REL;
581
- } else {
582
- outRel = DEFAULT_GENERATED_SCHEMA_REL;
583
- }
584
- }
585
-
804
+ const declareRel = db?.declare ?? discoverDeclareRel(cwd);
805
+ const outRel = normalizeGeneratedRel(db?.generated ?? discoverGeneratedRel(cwd, declareRel));
586
806
  return {
587
807
  declarePath: join(cwd, declareRel),
588
808
  outPath: join(cwd, outRel),
@@ -2,7 +2,7 @@
2
2
  * Load plugin table contributions from a live `oke()` app entry.
3
3
  *
4
4
  * Used by `oke db` emit so `.plug()`-registered `field.*` tables merge into
5
- * `schema.drizzle.ts` — not only at unit-test merge call sites.
5
+ * `drizzle.ts` — not only at unit-test merge call sites.
6
6
  */
7
7
 
8
8
  import { resolve } from "node:path";
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
6
+ import { lte } from "drizzle-orm";
6
7
  import { timestamp, text, pgTable, integer } from "drizzle-orm/pg-core";
7
8
  import { pgliteDriver } from "../../drivers/pglite.ts";
8
9
  import type { SqlConnection } from "../../drivers/types.ts";
@@ -21,11 +22,19 @@ describe("coerceTemporalBindValue", () => {
21
22
  expect((day as Date).getTime()).toBe(0);
22
23
  });
23
24
 
24
- test("leaves non-temporal and non-number values alone", () => {
25
+ test("ISO datetime string → Date for TIMESTAMP and DATE", () => {
26
+ const at = coerceTemporalBindValue("TIMESTAMP", "2024-01-01T00:00:00.000Z");
27
+ expect(at).toBeInstanceOf(Date);
28
+ expect((at as Date).toISOString()).toBe("2024-01-01T00:00:00.000Z");
29
+
30
+ const day = coerceTemporalBindValue("DATE", "2024-01-01T00:00:00.000Z");
31
+ expect(day).toBeInstanceOf(Date);
32
+ expect((day as Date).toISOString()).toBe("2024-01-01T00:00:00.000Z");
33
+ });
34
+
35
+ test("leaves non-temporal and unparseable values alone", () => {
25
36
  expect(coerceTemporalBindValue("INTEGER", 42)).toBe(42);
26
- expect(coerceTemporalBindValue("TIMESTAMP", "2024-01-01T00:00:00.000Z")).toBe(
27
- "2024-01-01T00:00:00.000Z",
28
- );
37
+ expect(coerceTemporalBindValue("TIMESTAMP", "not-an-instant")).toBe("not-an-instant");
29
38
  const d = new Date(5);
30
39
  expect(coerceTemporalBindValue("TIMESTAMP", d)).toBe(d);
31
40
  expect(coerceTemporalBindValue("TIMESTAMP", null)).toBe(null);
@@ -73,6 +82,17 @@ describe("prepareInsertRow / prepareUpdateRow — timestamp columns", () => {
73
82
  expect((prepared.created_at as Date).getTime()).toBe(42);
74
83
  expect(prepared.hits).toBe(7);
75
84
  });
85
+
86
+ test("abstract schema: insert coerces ISO datetime on timestamp columns", () => {
87
+ const prepared = prepareInsertRow(notes, {
88
+ id: "welcome",
89
+ createdAt: "2026-09-14T12:00:00.000Z",
90
+ archivedAt: null,
91
+ });
92
+ expect(prepared.created_at).toBeInstanceOf(Date);
93
+ expect((prepared.created_at as Date).toISOString()).toBe("2026-09-14T12:00:00.000Z");
94
+ expect(prepared.archived_at).toBe(null);
95
+ });
76
96
  });
77
97
 
78
98
  describe("SqlStoreHandle upsert — epoch-ms into timestamp (Postgres)", () => {
@@ -120,4 +140,37 @@ describe("SqlStoreHandle upsert — epoch-ms into timestamp (Postgres)", () => {
120
140
  expect(rows).toHaveLength(1);
121
141
  expect(Number(rows[0]!.createdAt)).toBe(1);
122
142
  });
143
+
144
+ test("select/update WHERE coerces epoch-ms on timestamp columns", async () => {
145
+ await handle.insert(notesTs).values({ id: "old", title: "old", createdAt: 1 });
146
+ await handle.insert(notesTs).values({ id: "new", title: "new", createdAt: 100 });
147
+
148
+ // Drizzle types timestamp `{ mode: "date" }` as Date; the store still
149
+ // coerces epoch-ms binds (`fx.clock.now()`) at WHERE compile time.
150
+ const before = 50 as unknown as Date;
151
+ const rows = await handle.select().from(notesTs).where(lte(notesTs.createdAt, before));
152
+ expect(rows).toHaveLength(1);
153
+ expect(rows[0]!.id).toBe("old");
154
+
155
+ const n = await handle
156
+ .update(notesTs)
157
+ .set({ title: "archived" })
158
+ .where(lte(notesTs.createdAt, before));
159
+ expect(n).toBe(1);
160
+ const archived = await handle.findById(notesTs, "old");
161
+ expect(archived?.title).toBe("archived");
162
+ });
163
+
164
+ test("upsert accepts ISO datetime strings without Postgres type error", async () => {
165
+ const first = await handle.upsert(
166
+ notesTs,
167
+ { id: "welcome" },
168
+ { id: "welcome", title: "Welcome", createdAt: "2026-09-14T12:00:00.000Z" },
169
+ );
170
+ expect(first.status).toBe("upserted");
171
+
172
+ const rows = await handle.select().from(notesTs);
173
+ expect(rows).toHaveLength(1);
174
+ expect(new Date(rows[0]!.createdAt as Date).toISOString()).toBe("2026-09-14T12:00:00.000Z");
175
+ });
123
176
  });