From c76c7f70fb00e55143a66daa234473d201c905f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 14:57:20 +0000 Subject: [PATCH 1/2] fix(driver-sql): give the SQLite builtin audit columns the canonical ISO-8601 DEFAULT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createAuditTimestampColumn`'s non-MySQL branch was `table.timestamp(name).defaultTo(this.knex.fn.now())`, which on SQLite compiles to an unqualified `CURRENT_TIMESTAMP` — a zone-naive, space-separated, second-precision string. A declared `Field.datetime` with `defaultValue: 'NOW()'` in the SAME table already got the canonical ISO-8601 form from `nowColumnDefault`, so one table carried two spellings of one conceptual value. Route the SQLite branch through `nowColumnDefault('datetime')` — the existing single source for "what does NOW() mean in DDL on this dialect" — rather than restating the expression, so the two cannot drift apart again. `rebuildSqliteTablePatched` carried the same `knex.fn.now()` for the audit columns. That method is SQLite-only, so leaving it would have silently REVERTED a canonically-created table the moment any unrelated drift triggered a rebuild. Fixed together; a rebuild must hand back the column `initObjects` would build. Postgres is untouched (`knex.fn.now()` there is a real zone-aware TIMESTAMP); MySQL keeps `now(3)` from #11224. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y --- packages/drivers/driver-sql/src/sql-driver.ts | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index cb45fd5477..b600386074 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -9794,7 +9794,16 @@ export class SqlDriver implements IDataDriver { const nullable = relax.has(c.name) ? true : tighten.has(c.name) ? false : c.nullable; if (!nullable && c.name !== 'id') col.notNullable(); if (c.name === 'created_at' || c.name === 'updated_at') { - col.defaultTo(this.knex.fn.now()); + // #11321: the SAME canonical default {@link createAuditTimestampColumn} + // emits, not `knex.fn.now()`. This method is SQLite-only (see the + // `isSqlite` branch in `applyMigrationEntries`), where + // `knex.fn.now()` is the zone-naive `CURRENT_TIMESTAMP` — so + // re-emitting it here would silently REVERT a table that was + // created with the canonical default the moment any unrelated + // drift (a relaxed NOT NULL, a dropped column) triggered a + // rebuild. A rebuild must hand back the column `initObjects` + // would have built. + col.defaultTo(this.nowColumnDefault('datetime')); } else if (!dropDefault.has(c.name)) { // Re-emit the METADATA-declared default. The rebuild dropped the // original table, so a column whose default is not restated here @@ -12448,12 +12457,52 @@ export class SqlDriver implements IDataDriver { * `TIMESTAMP` problems on MySQL: no milliseconds, and a 2038 ceiling on the * column every list view sorts by (#3942). `CURRENT_TIMESTAMP` has to carry * matching precision for a `DATETIME(3)` default, hence `now(3)`. + * + * ## SQLite takes the SAME canonical default a declared field gets (#11321) + * + * "Must take the same physical type as a declared `Field.datetime`" is the + * paragraph above; the DEFAULT is the other half of that sentence, and on + * SQLite it used to diverge. `knex.fn.now()` compiles to an unqualified + * `CURRENT_TIMESTAMP`, which SQLite renders as a zone-NAIVE, space-separated, + * second-precision `'YYYY-MM-DD HH:MM:SS'` — the exact string + * {@link updatedAtStamp}'s docblock condemns, because `Date.parse` reads a + * zone-less string as LOCAL time and silently shifts the instant by the host + * offset. A declared `defaultValue: 'NOW()'` field in the SAME table already + * gets the canonical ISO-8601 form from {@link nowColumnDefault}, so one table + * carried two spellings of one conceptual value: + * + * ``` + * created_at "2026-08-23 14:54:17" <- builtin audit (naive) + * when "2026-08-23T14:54:17.796Z" <- declared Field.datetime NOW() + * ``` + * + * Routed through {@link nowColumnDefault} rather than restating the + * expression, so the two can never drift apart again — one source for "what + * does NOW() mean in DDL on this dialect". + * + * Postgres is deliberately untouched: `knex.fn.now()` there is a real + * zone-aware `TIMESTAMP` that never had the ambiguity. MySQL keeps `now(3)` + * (#11224) — a `DATETIME(3)` default must carry matching precision. + * + * ⚠️ Scope: a DDL default governs only NEWLY-created tables. A table already + * on disk keeps its legacy `CURRENT_TIMESTAMP` default; `formatOutput`'s read + * repair (`repairNaiveUtcAuditTimestamp`) folds those rows to canonical on + * read, which is why this needs no migration — the same disposition + * {@link nowColumnDefault} already documents for declared fields. Measured: + * schema-drift never compares this default, so an existing deployment does + * NOT start reporting drift on `created_at`/`updated_at` — the audit columns + * are skipped outright ({@link BUILTIN_COLUMNS}), and the only + * `default_mismatch` producer is the #4560 runtime-token check. */ protected createAuditTimestampColumn(table: Knex.CreateTableBuilder, name: string): void { if (this.isMysql) { table.datetime(name, { precision: 3 }).defaultTo(this.knex.fn.now(3)); return; } + if (this.isSqlite) { + table.timestamp(name).defaultTo(this.nowColumnDefault('datetime')); + return; + } table.timestamp(name).defaultTo(this.knex.fn.now()); } From 1bf96b8a699d3ab1a281ccf6898b5e7ef0fdfabc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 15:00:30 +0000 Subject: [PATCH 2/2] test(driver-sql): pin the SQLite audit-column canonical DEFAULT, and drift's indifference to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six legs on real better-sqlite3 through the driver's own initObjects: 1. emitted DDL — audit columns and a declared Field.datetime NOW() sibling carry the SAME default expression (compared to the sibling, not to a literal, so a future respelling moves both sides together); 2. a DEFAULT-firing insert stores a canonical instant in every column; 3. the blast-radius question — a table still carrying the OLD CURRENT_TIMESTAMP default reports NO drift, WITH a positive control proving the default-reading dimension is live in the same call; 4. a drift-triggered SQLite rebuild hands back the canonical default; 5. on a skipSchemaSync boot the driver's own create() door — which reaches the column DEFAULT because tablesWithTimestamps is empty — now stores canonical; 6. dialect gate: Postgres keeps CURRENT_TIMESTAMP, MySQL keeps CURRENT_TIMESTAMP(3), neither leaks strftime. Compiled offline. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y --- .../sqlite-audit-column-canonical-default.md | 28 ++ ...321-sqlite-audit-default-canonical.test.ts | 314 ++++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 .changeset/sqlite-audit-column-canonical-default.md create mode 100644 packages/drivers/driver-sql/src/sql-driver-11321-sqlite-audit-default-canonical.test.ts diff --git a/.changeset/sqlite-audit-column-canonical-default.md b/.changeset/sqlite-audit-column-canonical-default.md new file mode 100644 index 0000000000..021619067b --- /dev/null +++ b/.changeset/sqlite-audit-column-canonical-default.md @@ -0,0 +1,28 @@ +--- +"@objectstack/driver-sql": patch +--- + +**Fix:** on SQLite the builtin `created_at`/`updated_at` audit columns now take the same canonical ISO-8601 `DEFAULT` a declared `Field.datetime` NOW() column in the same table already gets (#11321). + +`createAuditTimestampColumn`'s non-MySQL branch was `table.timestamp(name).defaultTo(this.knex.fn.now())`. On SQLite `knex.fn.now()` compiles to an unqualified `CURRENT_TIMESTAMP`, which renders a zone-**naive**, space-separated, second-precision `'YYYY-MM-DD HH:MM:SS'`. A declared `defaultValue: 'NOW()'` field in the **same table** already got `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))` from `nowColumnDefault`, so one table carried two spellings of one conceptual value: + +``` +created_at "2026-08-23 14:54:17" <- builtin audit (naive) +when "2026-08-23T14:54:17.796Z" <- declared field (canonical) +``` + +That naive spelling is the one `updatedAtStamp()`'s own docblock condemns: `Date.parse` reads a zone-less string as LOCAL time, silently shifting the instant by the host offset on a non-UTC runtime. It is also the pre-canonical storage form `backfillCanonicalDatetimes` exists to converge — reached here by a path writing it *today*, not by legacy data. + +The SQLite branch is now routed through `nowColumnDefault('datetime')` — the existing single source for "what does NOW() mean in DDL on this dialect" — rather than restating the expression, so the two cannot drift apart again. **Postgres and MySQL are untouched**: `knex.fn.now()` on Postgres is a real zone-aware `TIMESTAMP` that never had the ambiguity, and MySQL keeps the `now(3)` precision match from #11224. + +`rebuildSqliteTablePatched` — the whole-table rebuild SQLite drift reconciliation uses — re-emitted the audit default itself as `knex.fn.now()`. That method is SQLite-only, so leaving it would have silently **reverted** a canonically-created table the moment any unrelated drift (a relaxed NOT NULL, an orphaned column) triggered a rebuild. Fixed in the same change: a rebuild hands back the column `initObjects` would have built. + +**Graded `patch`, not `minor`, on a measurement rather than a judgement.** The change alters emitted DDL, so the question that decides the grade is what it does to databases that already exist: + +- **Existing tables are not altered.** Both call sites are `CREATE TABLE` only; `initObjects`' `alterTable` branch adds declared fields and never the audit columns. A table already on disk keeps `default CURRENT_TIMESTAMP`. +- **They do not start reporting drift.** Measured on live in-memory SQLite through the real `detectManagedDrift` entry point against a table carrying the old default: **zero** entries. Two independent guards — `BUILTIN_COLUMNS` skips `created_at`/`updated_at` in both of `diffManagedTable`'s loops, and the only `default_mismatch` producer is the #4560 runtime-token check, for which `isAppResolvedDefaultToken('NOW()')` is pinned `false`. The measurement carries a positive control: in the same call on the same table, drift reports `unmapped_column` **and** `default_mismatch` for a `current_user` column, so the default-reading dimension is demonstrably live and still says nothing about the audit columns. +- **Rows already written naive keep reading correctly.** `formatOutput`'s `repairNaiveUtcAuditTimestamp` folds them to canonical on read — the same disposition `nowColumnDefault` already documents for declared fields. + +So no deployment changes behaviour on upgrade; only newly-created tables get the corrected default. + +The population this actually repairs is wider than "writes that bypass the driver". `stampInsertTimestamps` fills both columns app-side, but it gates on `tablesWithTimestamps`, which only DDL-running paths populate. On the documented `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1` posture, `registerObjectMetadata` (the DDL-free registration door) deliberately does not touch that set — so the set is empty, the stamp returns early, and **the driver's own `create()` door reaches the column DEFAULT**. Measured, one table, one row per boot posture: `created_at "2026-08-23T14:54:17.791Z"` on a normal boot versus `"2026-08-23 14:54:17"` on a `skipSchemaSync` boot, with the declared NOW() sibling canonical in both — because its canonical shape lives in the column DEFAULT rather than in an app-side stamp. That asymmetry is the argument for fixing this in DDL, and it is now closed. diff --git a/packages/drivers/driver-sql/src/sql-driver-11321-sqlite-audit-default-canonical.test.ts b/packages/drivers/driver-sql/src/sql-driver-11321-sqlite-audit-default-canonical.test.ts new file mode 100644 index 0000000000..1a46a1b770 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-11321-sqlite-audit-default-canonical.test.ts @@ -0,0 +1,314 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11321] On SQLite the builtin audit columns must take the SAME canonical + * ISO-8601 DEFAULT a declared `Field.datetime` NOW() column in the same table + * gets — not the zone-naive `CURRENT_TIMESTAMP`. + * + * ## The defect + * + * `createAuditTimestampColumn`'s non-MySQL branch was + * `table.timestamp(name).defaultTo(this.knex.fn.now())`. On SQLite + * `knex.fn.now()` compiles to an unqualified `CURRENT_TIMESTAMP`, which renders + * a zone-NAIVE, space-separated, second-precision `'YYYY-MM-DD HH:MM:SS'`. + * `nowColumnDefault` — what a DECLARED `defaultValue: 'NOW()'` field gets — + * already emitted `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`. Measured on + * better-sqlite3 before the fix, one table, one defaulted insert: + * + * ``` + * created_at "2026-08-23 14:54:17" <- builtin audit (naive) + * updated_at "2026-08-23 14:54:17" <- builtin audit (naive) + * when "2026-08-23T14:54:17.796Z" <- declared field (canonical) + * ``` + * + * `updatedAtStamp()`'s own docblock condemns exactly that spelling: `Date.parse` + * reads a zone-less string as LOCAL time, silently shifting the instant by the + * host offset on a non-UTC runtime. + * + * ## Why this is asserted as AGREEMENT, not as a string + * + * The property that matters is that one table cannot carry two shapes for one + * conceptual value. So §1 and §2 compare the audit columns to a declared NOW() + * sibling *in the same table* rather than to a hard-coded literal: if + * `nowColumnDefault` ever changes its canonical spelling, this suite must keep + * passing (both sides move together) and must still fail if only one side moves. + * + * ## §3 exists because this changes emitted DDL + * + * Every SQLite table already on disk keeps `default CURRENT_TIMESTAMP`. If + * schema-drift compared column defaults, this fix would make every existing + * deployment report drift on `created_at`/`updated_at` — a narrow correctness + * fix turned into a broad false alarm. It does not, by two independent guards + * (`BUILTIN_COLUMNS` skips the audit columns outright; the only + * `default_mismatch` producer is the #4560 runtime-token check, and `NOW()` is + * not an app-resolved token). §3 pins that, WITH a positive control — a bare + * "drift is empty" assertion would pass just as well against an inert harness. + * + * ## §4: the second site + * + * `rebuildSqliteTablePatched` re-materializes a whole table when SQLite drift is + * reconciled, and it re-emitted the audit default itself. That method is + * SQLite-only, so leaving it as `knex.fn.now()` would have silently REVERTED a + * canonically-created table on the first unrelated drift rebuild. §4 pins that a + * rebuild hands back the column `initObjects` would have built. + * + * ## §5: the reachability this actually repairs + * + * `stampInsertTimestamps` fills both audit columns app-side, so on an ordinary + * boot the DEFAULT never fires. It gates on `tablesWithTimestamps`, which only + * DDL-running paths populate — so on the documented `skipSchemaSync` / + * `OS_SKIP_SCHEMA_SYNC=1` posture (`registerObjectMetadata`, the DDL-free + * registration door) the set is EMPTY and the driver's own `create()` door hits + * the column DEFAULT. That is the population this fix repairs, and it is wider + * than "writes that bypass the driver". + * + * ## Reverse verification (direction predicted BEFORE each leg) + * + * Restoring `main`'s `createAuditTimestampColumn` body turns §1, §2 and §5 red + * (audit columns naive, declared sibling canonical — the defect itself as the + * received value) and leaves §3 GREEN, because §3's subject is drift's + * indifference to the default, which the fix never changed. Restoring `main`'s + * `rebuildSqliteTablePatched` line turns §4 red ONLY. + */ + +import { describe, it, expect } from 'vitest'; +import knexFactory from 'knex'; +import { SqlDriver } from '../src/index.js'; + +/** Canonical instant: ISO-8601, explicit `Z`, milliseconds. */ +const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; +/** The pre-canonical shape this card removes: zone-naive, space-separated. */ +const NAIVE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; + +const OPTS = { bypassTenantAudit: true } as any; + +/** An object carrying a declared `Field.datetime` NOW() sibling of the audit columns. */ +const FIELDS = { + title: { type: 'string' }, + when: { type: 'datetime', defaultValue: 'NOW()' }, +} as Record; + +function sqliteDriver(filename = ':memory:'): SqlDriver { + return new SqlDriver({ + client: 'better-sqlite3', + connection: { filename }, + useNullAsDefault: true, + } as any); +} + +/** The `CREATE TABLE` text SQLite itself recorded for `table`. */ +async function emittedDdl(driver: SqlDriver, table: string): Promise { + const raw = (driver as any).knex; + const rows = await raw.raw(`select sql from sqlite_master where type='table' and name=?`, [table]); + const sql = (Array.isArray(rows) ? rows[0]?.sql : rows?.sql) ?? ''; + expect(sql, `no CREATE TABLE recorded for ${table}`).toBeTruthy(); + return String(sql); +} + +/** The `default …` clause SQLite recorded for one column, normalized to one line. */ +function defaultClauseOf(ddl: string, column: string): string { + // `\`col\` default ` up to whatever ends the column definition: + // the next quoted column, the table-level `primary key (…)` clause, or the + // close of the CREATE TABLE. Without the `primary key` alternative the LAST + // column's clause swallows it and no two columns ever compare equal. + const m = new RegExp( + '`' + column + '`\\s+\\w+\\s+default\\s+(.+?)(?:,\\s*`|,\\s*primary key|\\s*\\)\\s*$)', + 'i', + ).exec(ddl); + expect(m, `no DEFAULT clause for ${column} in: ${ddl}`).not.toBeNull(); + return m![1].trim().replace(/\s+/g, ' '); +} + +describe('#11321 SQLite builtin audit columns take the canonical NOW() default', () => { + // ── §1 emitted DDL ──────────────────────────────────────────────────────── + it('§1 the audit columns and a declared Field.datetime NOW() sibling get the SAME default expression', async () => { + const driver = sqliteDriver(); + try { + await driver.initObjects([{ name: 'audit_ddl_t', fields: FIELDS } as any]); + const ddl = await emittedDdl(driver, 'audit_ddl_t'); + + const declared = defaultClauseOf(ddl, 'when'); + const createdAt = defaultClauseOf(ddl, 'created_at'); + const updatedAt = defaultClauseOf(ddl, 'updated_at'); + + // The property: one table, ONE default shape. Compared to the declared + // sibling rather than to a literal, so a future respelling of the + // canonical expression moves both sides together. + expect(createdAt).toBe(declared); + expect(updatedAt).toBe(declared); + + // And the shape is the canonical one, not the naive fallback — otherwise + // "they agree" would also be satisfied by both being CURRENT_TIMESTAMP. + expect(createdAt.toUpperCase()).not.toBe('CURRENT_TIMESTAMP'); + expect(createdAt).toContain('strftime'); + expect(createdAt).toContain('%Y-%m-%dT%H:%M:%fZ'); + } finally { + await driver.disconnect(); + } + }); + + // ── §2 stored value on a DEFAULT-firing write ───────────────────────────── + it('§2 a write that lets the DEFAULTs fire stores a canonical instant in every column', async () => { + const driver = sqliteDriver(); + try { + await driver.initObjects([{ name: 'audit_val_t', fields: FIELDS } as any]); + const raw = (driver as any).knex; + + // Deliberately NOT driver.create(): this is the door that reaches the + // column DEFAULT — raw SQL that names neither audit column. + await raw.raw(`insert into "audit_val_t" ("id","title") values ('x','y')`); + const rows = await raw.raw(`select created_at, updated_at, "when" from "audit_val_t"`); + const row = (Array.isArray(rows) ? rows[0] : rows) as Record; + + for (const col of ['created_at', 'updated_at', 'when']) { + expect(row[col], `${col} = ${row[col]}`).toMatch(ISO_Z); + expect(row[col], `${col} is still the naive shape`).not.toMatch(NAIVE); + } + // Same statement, same SQLite `'now'` — the instants agree to the second, + // which is what "one conceptual value" means here. + expect(row.created_at.slice(0, 19)).toBe(row.when.slice(0, 19)); + } finally { + await driver.disconnect(); + } + }); + + // ── §3 the blast-radius question: does drift read this default? ─────────── + it('§3 a table still carrying the OLD CURRENT_TIMESTAMP default reports NO drift (with a positive control)', async () => { + const driver = sqliteDriver(); + try { + const raw = (driver as any).knex; + // A table exactly as an already-deployed database holds it: audit columns + // defaulted the OLD way, created before this fix. + await raw.raw( + `create table "legacy_t" (` + + `"id" varchar(255), ` + + `"created_at" datetime default CURRENT_TIMESTAMP, ` + + `"updated_at" datetime default CURRENT_TIMESTAMP, ` + + `"title" varchar(255), ` + + `"when" datetime default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), ` + + `primary key ("id"))`, + ); + + // Introspection DOES surface the legacy default — so a green below is + // drift declining to compare it, not drift failing to see it. + const cols = await (driver as any).introspectColumns('legacy_t'); + const createdAtCol = cols.find((c: any) => c.name === 'created_at'); + expect(String(createdAtCol.defaultValue).toUpperCase()).toContain('CURRENT_TIMESTAMP'); + + const drift = await driver.detectManagedDrift([{ name: 'legacy_t', fields: FIELDS } as any]); + expect(drift.filter((d: any) => d.column === 'created_at' || d.column === 'updated_at')).toEqual([]); + expect(drift).toEqual([]); + + // POSITIVE CONTROL — same table, same call. Without this, the empty array + // above is satisfied by a drift path that reports nothing at all. + await raw.raw(`alter table "legacy_t" add column "orphan_col" varchar(255)`); + await raw.raw(`alter table "legacy_t" add column "owner" varchar(255) default 'current_user'`); + const controlled = await driver.detectManagedDrift([ + { name: 'legacy_t', fields: { ...FIELDS, owner: { type: 'lookup', defaultValue: 'current_user' } } } as any, + ]); + const kinds = controlled.map((d: any) => `${d.kind}:${d.column}`); + expect(kinds).toContain('unmapped_column:orphan_col'); + // The default-READING dimension is demonstrably live in the same call … + expect(kinds).toContain('default_mismatch:owner'); + // … and still says nothing about the audit columns. + expect(controlled.filter((d: any) => d.column === 'created_at' || d.column === 'updated_at')).toEqual([]); + } finally { + await driver.disconnect(); + } + }); + + // ── §4 the SQLite rebuild must not revert the default ───────────────────── + it('§4 a drift-triggered SQLite table rebuild hands back the canonical default, not CURRENT_TIMESTAMP', async () => { + const driver = sqliteDriver(); + try { + await driver.initObjects([{ name: 'rebuild_t', fields: FIELDS } as any]); + const before = defaultClauseOf(await emittedDdl(driver, 'rebuild_t'), 'created_at'); + + // An UNRELATED drift on the same table — an orphaned column — is what + // triggers the whole-table rebuild. The audit columns are not the subject. + const raw = (driver as any).knex; + await raw.raw(`alter table "rebuild_t" add column "orphan_col" varchar(255)`); + const drift = await driver.detectManagedDrift([{ name: 'rebuild_t', fields: FIELDS } as any]); + expect(drift.map((d: any) => d.op.type)).toContain('drop_column'); + + await (driver as any).applyMigrationEntries(drift, { allowDestructive: true }); + + const after = defaultClauseOf(await emittedDdl(driver, 'rebuild_t'), 'created_at'); + expect(after).toBe(before); + expect(after.toUpperCase()).not.toBe('CURRENT_TIMESTAMP'); + expect(after).toContain('%Y-%m-%dT%H:%M:%fZ'); + + // And it still behaves: a defaulted insert into the rebuilt table. + await raw.raw(`insert into "rebuild_t" ("id","title") values ('r','y')`); + const rows = await raw.raw(`select created_at from "rebuild_t" where id='r'`); + const row = (Array.isArray(rows) ? rows[0] : rows) as Record; + expect(row.created_at).toMatch(ISO_Z); + } finally { + await driver.disconnect(); + } + }); + + // ── §5 the reachability the fix actually repairs ────────────────────────── + it('§5 on a skipSchemaSync boot the driver own create() door now stores a canonical created_at', async () => { + const { mkdtempSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const filename = join(mkdtempSync(join(tmpdir(), 'os11321-')), 'probe.sqlite'); + + // Boot 1 — an ordinary boot. initObjects runs the DDL and records the table + // in `tablesWithTimestamps`, so the app-side stamp fires. + const a = sqliteDriver(filename); + await a.initObjects([{ name: 'reach_t', fields: FIELDS } as any]); + await a.create('reach_t', { id: 'ddl-boot', title: 'a' } as any, OPTS); + expect([...(a as any).tablesWithTimestamps]).toContain('reach_t'); + await a.disconnect(); + + // Boot 2 — the documented out-of-band posture: metadata registered, DDL + // skipped. `tablesWithTimestamps` stays EMPTY, so `stampInsertTimestamps` + // returns early and `create()` reaches the column DEFAULT. + const b = sqliteDriver(filename); + (b as any).registerObjectMetadata([{ name: 'reach_t', fields: FIELDS }]); + expect([...(b as any).tablesWithTimestamps]).toEqual([]); + await b.create('reach_t', { id: 'skip-boot', title: 'b' } as any, OPTS); + + try { + const raw = (b as any).knex; + const rows = await raw.raw(`select id, created_at, "when" from "reach_t" order by id`); + const byId = Object.fromEntries( + (Array.isArray(rows) ? rows : [rows]).map((r: any) => [r.id, r]), + ); + // The row written on the posture where the app-side stamp does NOT run — + // the one that used to be naive. + expect(byId['skip-boot'].created_at, 'skipSchemaSync boot created_at').toMatch(ISO_Z); + expect(byId['skip-boot'].created_at).not.toMatch(NAIVE); + // Both boot postures now agree, and both agree with the declared sibling. + expect(byId['ddl-boot'].created_at).toMatch(ISO_Z); + expect(byId['skip-boot'].when).toMatch(ISO_Z); + } finally { + await b.disconnect(); + } + }); + + // ── §6 dialect gate — Postgres and MySQL are untouched ──────────────────── + it('§6 Postgres keeps native now() and MySQL keeps now(3) — compiled without a connection', () => { + for (const [client, expected] of [ + ['pg', 'CURRENT_TIMESTAMP'], + ['mysql2', 'CURRENT_TIMESTAMP(3)'], + ] as const) { + const k = knexFactory({ client } as any); + const driver = new SqlDriver({ client, connection: {} } as any); + (driver as any).knex = k; + + const ddl = k.schema + .createTable('t', (table: any) => { + (driver as any).createAuditTimestampColumn(table, 'created_at'); + }) + .toString(); + + expect(ddl.toUpperCase()).toContain(expected); + // The SQLite-only canonical expression must not have leaked across. + expect(ddl).not.toContain('strftime'); + k.destroy(); + } + }); +});