Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/21259-undeclared-audit-timestamp-mysql.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/driver-sql': patch
---

On MySQL, a write to an object that does not declare `created_at` or `updated_at` no longer fails with `Incorrect datetime value … for column 'updated_at'`. The driver creates both columns on every table it builds, and the engine stamps both on every insert as ISO-8601 text (`2026-10-01T21:49:27.479Z`). Only a column the object declared as `Field.datetime` was rewritten into the `2026-10-01 21:49:27.479` form MySQL accepts. The engine declares both columns on most objects, but not on an object with `managedBy: 'better-auth'` or `systemFields: false`, so those writes were refused.

Clause-②: no

**What this fixes.** `sys_jwks` declares `created_at` only, so on MySQL the JWT signing key was never stored. `GET /api/v1/auth/jwks` and `GET /api/v1/auth/token` answered 500, `get-session` carried no `set-auth-jwt` header, and no OIDC or MCP token could be issued. `sys_member` failed the same way, so the seeded admin had no organization membership. Now both answer 200, the key is stored, and the membership is stored. In the CRM example's boot, 9 objects declare `created_at` without `updated_at` and 2 declare neither. Every write door formats the column: `create`, `bulkCreate`, `upsert`, `update` and `updateMany`.

**SQLite and PostgreSQL.** The value the engine stamps is bound unchanged on both, so their behaviour is the same. One input shape changes on SQLite: a JS `Date` written to an undeclared audit column is now stored as the canonical ISO text, as it already is for a declared `Field.datetime`. Before, it was stored as epoch milliseconds and read back as a number. A column the object declares keeps its declared type.
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#21259] A builtin audit timestamp the object did NOT declare is written in
* the storage form of the column this driver provisioned for it — on every
* dialect, through every write door.
*
* # The defect
*
* `initObjects` creates `created_at` and `updated_at` on EVERY managed table
* ({@link SqlDriver.createAuditTimestampColumn}: `DATETIME(3)` on MySQL), and
* the engine's `sys_stamp_audit_insert` hook stamps BOTH on every insert, as
* `new Date().toISOString()` — `YYYY-MM-DDTHH:MM:SS.sssZ`. `formatInput`
* rewrites that canonical text into the MySQL literal only for a column in
* `datetimeFields`, i.e. one the object DECLARED as `Field.datetime`. The
* registry declares both for most objects, but injects nothing for a
* `managedBy: 'better-auth'` or `systemFields: false` object, so a column such
* an object leaves undeclared reached MySQL as the raw ISO text, which MySQL
* refuses (`Incorrect datetime value … for column 'updated_at'`).
*
* Measured on live MySQL 8.0.46 at `8dea55d3`, `pnpm dev:crm -- --fresh
* --database mysql://…`: `sys_jwks` declares `created_at` and not `updated_at`,
* so the JWT signing-key row was never written (`created_at` bound as
* `2026-10-01 23:35:47.598`, `updated_at` as `2026-10-01T23:35:47.598Z`, one
* stamp), and `GET /api/v1/auth/jwks` and `GET /api/v1/auth/token` answered
* 500. `sys_member` failed the same way. Of the 81 objects that boot registers,
* 9 declare `created_at` without `updated_at` and 2 declare neither.
*
* # What is asserted, per cell
*
* Two objects in the two shapes the census found — `created_at` declared,
* `updated_at` not (the `sys_jwks` shape), and neither declared — written
* through `create`, `bulkCreate`, `upsert`, `update` and `updateMany` with the hook's exact
* value, and read back as the same instant. Each write records the value the
* driver BOUND for the undeclared column: the MySQL literal on MySQL, and on
* SQLite and PostgreSQL the hook's text unchanged, byte for byte — those two
* cells passed before the fix and are the control.
*
* A `Date` takes the same rule a declared `Field.datetime` takes, so it is
* stored as the canonical instant on every dialect too.
*
* Cells: SQLite always; live PostgreSQL and MySQL where provisioned (the
* `Temporal Conformance (live PG + MySQL)` job runs this package against both)
* and declared un-run otherwise.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { SqlDriver } from './index.js';
import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';

/** Driver options every write here uses — these fixtures are not tenant-scoped. */
const OPTS = { bypassTenantAudit: true } as any;

/** Declares `created_at` and not `updated_at` — the `sys_jwks` shape. */
const CREATED_ONLY = 'os21259_created_only';
/** Declares neither audit column — the `sys_oauth_client_assertion` shape. */
const NEITHER = 'os21259_neither';

const OBJECTS = [
{
name: CREATED_ONLY,
fields: {
label: { type: 'string' },
created_at: { type: 'datetime' },
},
},
{
name: NEITHER,
fields: {
label: { type: 'string' },
},
},
] as any[];

/** What the engine's audit hook stamps: `new Date().toISOString()`. */
const STAMP = '2026-10-01T21:49:27.479Z';
const LATER = '2026-10-02T03:04:05.006Z';

/** The physical spelling the driver binds for `STAMP`'s instant on `cell`. */
function boundFor(cell: DialectCell, iso: string): string {
return cell.id === 'mysql' ? iso.replace('T', ' ').replace('Z', '') : iso;
}

function suite(cell: DialectCell) {
describe(`sql-driver — an undeclared builtin audit timestamp is written in its column's form (${cell.label}) [#21259]`, () => {
let driver: SqlDriver;

/** Bindings of the writes this test issued against the fixture tables, in order. */
let writes: { sql: string; bindings: unknown[] }[] = [];
const record = (q: { sql?: string; bindings?: unknown[] }) => {
const sql = String(q?.sql ?? '');
if (!/^\s*(insert|update)/i.test(sql)) return;
if (!sql.includes(CREATED_ONLY) && !sql.includes(NEITHER)) return;
writes.push({ sql, bindings: [...(q.bindings ?? [])] });
};

/** Every value bound in the recorded writes. */
const bound = () => writes.flatMap((w) => w.bindings);

const stored = async (object: string, id: string) => {
const row = await driver.findOne(object, { where: { id } }, OPTS);
expect(row, `row ${id} was not stored`).not.toBeNull();
return row!;
};

beforeEach(async () => {
driver = new SqlDriver(cell.config());
for (const o of OBJECTS) await driver.getKnex().schema.dropTableIfExists(o.name);
await driver.initObjects(OBJECTS);
writes = [];
driver.getKnex().on('query', record);
});

afterEach(async () => {
driver.getKnex().removeListener('query', record);
for (const o of OBJECTS) await driver.getKnex().schema.dropTableIfExists(o.name);
await driver.disconnect();
});

it('measures the shape it claims to: `updated_at` is undeclared and its column exists', async () => {
const fields = (driver as any).declaredFieldsFor(CREATED_ONLY);
expect(Object.keys(fields)).toEqual(['label', 'created_at']);
expect((driver as any).datetimeFields[CREATED_ONLY]?.has('updated_at') ?? false).toBe(false);
const columns = Object.keys(await driver.getKnex()(CREATED_ONLY).columnInfo());
expect(columns).toEqual(expect.arrayContaining(['created_at', 'updated_at']));
});

it('create: the hook-stamped pair lands on the `sys_jwks` shape, as one instant', async () => {
await driver.create(CREATED_ONLY, { id: 'c1', label: 'one', created_at: STAMP, updated_at: STAMP }, OPTS);

const row = await stored(CREATED_ONLY, 'c1');
expect(row.created_at).toBe(STAMP);
expect(row.updated_at).toBe(STAMP);
// Both columns are bound in the column's form — the declared one always
// was; the undeclared one is the fix. On SQLite and Postgres this is the
// hook's text, unchanged.
expect(bound().filter((v) => v === boundFor(cell, STAMP))).toHaveLength(2);
if (cell.id === 'mysql') expect(bound()).not.toContain(STAMP);
});

it('create: an object that declares neither audit column takes both', async () => {
await driver.create(NEITHER, { id: 'n1', label: 'one', created_at: STAMP, updated_at: LATER }, OPTS);

const row = await stored(NEITHER, 'n1');
expect(row.created_at).toBe(STAMP);
expect(row.updated_at).toBe(LATER);
expect(bound()).toEqual(expect.arrayContaining([boundFor(cell, STAMP), boundFor(cell, LATER)]));
});

it('bulkCreate: every row of the batch lands', async () => {
await driver.bulkCreate(
CREATED_ONLY,
[
{ id: 'b1', label: 'one', created_at: STAMP, updated_at: STAMP },
{ id: 'b2', label: 'two', created_at: LATER, updated_at: LATER },
],
OPTS,
);

expect((await stored(CREATED_ONLY, 'b1')).updated_at).toBe(STAMP);
expect((await stored(CREATED_ONLY, 'b2')).updated_at).toBe(LATER);
});

it('upsert: the INSERT branch lands the supplied instant', async () => {
await driver.upsert(NEITHER, { id: 'u1', label: 'one', created_at: STAMP, updated_at: STAMP }, undefined, OPTS);

const row = await stored(NEITHER, 'u1');
expect(row.created_at).toBe(STAMP);
expect(row.updated_at).toBe(STAMP);
});

it('update under `preserveAudit`: the supplied `updated_at` is kept, in the column form', async () => {
// Seeded with no audit value, so this case measures the UPDATE door alone.
await driver.create(CREATED_ONLY, { id: 'p1', label: 'one' }, OPTS);
writes = [];

await driver.update(CREATED_ONLY, 'p1', { label: 'one!', updated_at: LATER }, { ...OPTS, preserveAudit: true });

const row = await stored(CREATED_ONLY, 'p1');
expect(row.label).toBe('one!');
expect(row.updated_at).toBe(LATER);
expect(bound()).toContain(boundFor(cell, LATER));
});

it('updateMany under `preserveAudit`: the supplied `updated_at` is kept, in the column form', async () => {
await driver.create(NEITHER, { id: 'm1', label: 'bulk' }, OPTS);
await driver.create(NEITHER, { id: 'm2', label: 'bulk' }, OPTS);
writes = [];

await driver.updateMany(NEITHER, { where: { label: 'bulk' } }, { updated_at: LATER }, { ...OPTS, preserveAudit: true });

expect((await stored(NEITHER, 'm1')).updated_at).toBe(LATER);
expect((await stored(NEITHER, 'm2')).updated_at).toBe(LATER);
expect(bound()).toContain(boundFor(cell, LATER));
});

it('a `Date` is stored as the same canonical instant, as a declared `Field.datetime` is', async () => {
await driver.create(NEITHER, { id: 'd1', label: 'one', created_at: new Date(STAMP), updated_at: new Date(LATER) }, OPTS);

const row = await stored(NEITHER, 'd1');
expect(row.created_at).toBe(STAMP);
expect(row.updated_at).toBe(LATER);
});
});
}

for (const cell of DIALECT_CELLS) {
declareDialectCell(cell, 'undeclared builtin audit timestamp write (#21259)', (c: DialectCell) => suite(c));
}
42 changes: 42 additions & 0 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5470,6 +5470,21 @@ export class SqlDriver implements IDataDriver {
protected fractionalNumericFields: Record<string, string[]> = {};
protected dateFields: Record<string, Set<string>> = {};
protected datetimeFields: Record<string, Set<string>> = {};
/**
* [#21259] The builtin audit timestamps ({@link AUDIT_TIMESTAMP_COLUMNS}) a
* MANAGED object does not declare — the columns this driver provisions on
* every table it builds ({@link createAuditTimestampColumn}) that no
* declaration types. `formatInput` writes them in the `Field.datetime` form,
* the type their column was created with.
*
* Most objects declare both, because the engine's registry injects them as
* `Field.datetime`; a `managedBy: 'better-auth'` or `systemFields: false`
* object gets nothing injected, and the engine's audit hook stamps both
* anyway. A declared column keeps its declaration, whatever its type.
* Installed by {@link registerManagedObjectMetadata} only: an external object
* (ADR-0015) maps a remote table whose columns this driver did not create.
*/
protected undeclaredAuditTimestampFields: Record<string, readonly string[]> = {};
/**
* SQLite `Field.datetime` columns proven to hold ONLY canonical UTC text —
* either backfilled by {@link backfillCanonicalDatetimes} or created empty in
Expand Down Expand Up @@ -12025,6 +12040,11 @@ export class SqlDriver implements IDataDriver {
// #2186: remember the authoritative metadata field set for this table so
// drift detection / `os migrate` can diff the physical schema against it.
this.managedObjectFields.set(tableName, obj.fields ?? {});
// [#21259] Recomputed on every registration, so a re-registration that now
// declares a column stops treating it as undeclared.
this.undeclaredAuditTimestampFields[tableName] = AUDIT_TIMESTAMP_COLUMNS.filter(
(col) => !Object.prototype.hasOwnProperty.call(obj.fields ?? {}, col),
);
// Always overwrite — a metadata change that REMOVES `indexes` must clear
// the previous entry, or drift detection keeps expecting an index nobody
// declares any more (and never reports it as orphaned).
Expand Down Expand Up @@ -19965,6 +19985,28 @@ export class SqlDriver implements IDataDriver {
}
}

// [#21259] The same rule for a builtin audit timestamp the object does NOT
// declare (see {@link undeclaredAuditTimestampFields}). Its column is the
// `DATETIME(3)` / `timestamptz` / canonical-text column this driver created,
// and the engine's audit hook stamps it as `toISOString()` text, which
// MySQL refuses as a datetime literal. Measured on live MySQL 8.0.46: every
// insert into `sys_jwks` (declares `created_at` only) and `sys_member` was
// refused on `updated_at`, so no JWT signing key was ever stored. On SQLite
// and Postgres the hook's text is already the canonical form and is bound
// unchanged; a `Date` lands as that same text instead of an epoch INTEGER.
const undeclaredAudit = this.undeclaredAuditTimestampFields[object];
if (undeclaredAudit && undeclaredAudit.length > 0 && copy && typeof copy === 'object') {
for (const field of undeclaredAudit) {
const v = copy[field];
if (v == null) continue;
const normalized = this.storageDatetimeValue(v);
if (normalized !== v) {
if (!copied) { copy = { ...copy }; copied = true; }
copy[field] = normalized;
}
}
}

// ADR-0053 Phase 1: a `Field.date` is a timezone-naive calendar day, not
// an instant. Collapse any `Date` or full-ISO value to `YYYY-MM-DD` before
// it hits the wire so storage matches the date-only contract the filter
Expand Down
Loading