Skip to content

Commit 95b91cc

Browse files
fix(driver-sql): write an undeclared builtin audit timestamp in its column datetime form (MySQL: sys_jwks never stored, /auth/jwks 500) (#21272)
Fixes #21259 Clause-②: no ## What was wrong `initObjects` creates `created_at` and `updated_at` on every managed table (`createAuditTimestampColumn`, `DATETIME(3)` on MySQL). The engine's `sys_stamp_audit_insert` hook stamps both on every insert as `new Date().toISOString()` text. `formatInput` rewrote that text into the MySQL literal only for a column the object DECLARED as `Field.datetime`. The registry declares both columns on most objects, but injects nothing for `managedBy: 'better-auth'` or `systemFields: false`. On those objects an undeclared audit column reached MySQL as `2026-10-01T23:35:47.598Z`, and MySQL refused it (`ER_TRUNCATED_WRONG_VALUE`). ## Producer, measured - **Bound values.** The MySQL general log at `8dea55d3` shows every `sys_jwks` INSERT bound `created_at` as `2026-10-01 23:35:47.598` and `updated_at` as `2026-10-01T23:35:47.598Z`: one instant in two spellings. Only the non-key columns were extracted; key material was not read. - **Who writes `updated_at`.** I ran a scratch vitest (never committed): `ObjectQLPlugin`, a capturing driver, and the real `SysJwks` from `platform-objects`. - The caller payload (the better-auth jwt plugin shape) has the keys `id, public_key, private_key, alg, crv, created_at`. - `driver.create` received the same keys **plus `updated_at`**, a `string` in `toISOString()` form, equal to the `created_at` the hook re-stamped. - The registered object's fields are `id, public_key, private_key, alg, crv, created_at, expires_at`. - **Where that leaves the producer.** better-auth's jwks schema has no `updatedAt` (`better-auth/dist/plugins/jwt/schema.mjs`). The producer is the engine's audit hook (`packages/objectql/src/plugin.ts`, `applyToRecord`), not the auth adapter. The value is a correct instant in the platform's canonical form. What was missing is the driver formatting the column it provisioned. So the fix is in `driver-sql`, and `plugin-auth` is untouched. ## The class, counted The objects that boot registers: `GET /api/v1/meta/object` on the CRM boot at `8dea55d3`, MySQL, 81 objects. | declares | objects | |:--|--:| | `created_at` and `updated_at` | 70 | | `created_at` only | 9: `sys_member`, `sys_invitation`, `sys_team_member`, `sys_oauth_access_token`, `sys_oauth_refresh_token`, `sys_oauth_client_resource`, `sys_jwks`, `sys_scim_connection_binding`, `sys_scim_group_member` | | `updated_at` only | 0 | | neither | 2: `sys_oauth_client_assertion`, `sys_scim_identity_tombstone` | Every declared audit column is `datetime`. In the baseline boot, two of the eleven objects failed at a door: `sys_jwks` 5 times and `sys_member` 2 times. The seeded admin had no organization membership on MySQL. Declaring `updated_at` on `sys_jwks` alone would have closed 1 of the 11. ## The fix The fix is in `packages/drivers/driver-sql/src/sql-driver.ts`: - `registerManagedObjectMetadata` records the builtin audit columns a managed object does not declare (`undeclaredAuditTimestampFields`). This runs at every registration, so a later declaration clears the entry. External objects are excluded: their remote columns are not this driver's. - `formatInput` writes those columns through `storageDatetimeValue`, the same rule a declared `Field.datetime` takes. Every write door goes through it: `create`, `bulkCreate`, `upsert`, `update` and `updateMany`. - On SQLite and PostgreSQL the hook's text is already canonical and is bound unchanged, byte for byte. - One input shape changes on SQLite: a JS `Date` written to an undeclared audit column is now stored as canonical ISO text, the same as for a declared `Field.datetime`. Before, it was stored as an epoch INTEGER and read back as a number. A column the object declares keeps its declared type. - `nowColumnDefault` and `migrateMysqlDatetimeColumns` are not touched. ## Pins `packages/drivers/driver-sql/src/sql-driver-21259-undeclared-audit-timestamp-write.test.ts` declares one cell per dialect through `declareDialectCell`: SQLite always, live PostgreSQL and MySQL where provisioned. CI's `Temporal Conformance (live PG + MySQL)` runs it. Each cell: - covers the two shapes the census found (`created_at` only, and neither); - writes the hook's exact value through `create`, `bulkCreate`, `upsert`, `update` and `updateMany` (the last two under `preserveAudit`) and reads back the same instant; - asserts the value the driver BOUND: the MySQL literal on MySQL, and the hook's text unchanged on SQLite and PostgreSQL (the control); - includes a `Date` case. The door pin follows the precedent of the seat's answer on #21227 (5940180378, option B): a driver pin of record plus the one-off door measurement below. No live-dialect harness boots `plugin-auth`. ## Door measurement All runs used `pnpm dev:crm -- --fresh` and the seeded admin's session. The MySQL server was 8.0.46 with zone `+08:00`; the PostgreSQL server was 16.14 with zone `Asia/Shanghai`. | door | MySQL, before (`8dea55d3`) | MySQL, after (`d18427f4`, driver-sql rebuilt) | PostgreSQL, after | SQLite, after | |:--|:--|:--|:--|:--| | `GET /api/v1/auth/jwks` | 500 | 200: one public key (OKP, Ed25519, EdDSA), no private member | 200 | 200 | | `GET /api/v1/auth/token` | 500 | 200: a three-segment JWT, header alg EdDSA with a kid | 200 | 200 | | `GET /api/v1/auth/get-session` | 200, **no** `set-auth-jwt` header | 200, `set-auth-jwt` present | 200, present | 200, present | | `sys_jwks` / `sys_member` rows | 0 / 0 | 1 / 1 (`created_at` = `updated_at`, UTC wall clock) | 1 / 1 | not read | | `Insert operation failed` WARN lines naming `Incorrect datetime value` | 7 (5 `sys_jwks`, 2 `sys_member`) | 0 | 0 failed inserts | 0 failed inserts | The PostgreSQL and SQLite doors were not measured before the fix (`NOT MEASURED: door-before on pg/sqlite`). Their control of record is the driver pin's PostgreSQL and SQLite cells, which were green before the fix as well as after it. ## Reverse verification - **Before the fix.** The pin ran against the unfixed driver with 21 cases, before the `updateMany` case was added. 6 failed: 5 on live MySQL, each `Incorrect datetime value: '2026-10-01T21:49:27.479Z'`, and the SQLite `Date` case, which received `1790891367479`. Live PostgreSQL had 0 failures. - **Ablation at `e7586a892e`.** `node scripts/ablation-replace.mjs` replaced the arm's registry read with `undefined`. The anchor hit went 1 to 0 and the blob changed `d08cb7e95a4a` to `5e6e99e8b352`. Result: 7 failed and 17 passed. The failures were 6 on live MySQL (create ×2, bulkCreate, upsert, update, updateMany) and 1 on SQLite (the `Date` case). Live PostgreSQL had 0, as predicted. - **Restore.** The restore was proven by blob identity with HEAD, an empty `git diff HEAD` and a clean `git status`. The pin imports the driver from source (`./index.js`), so no `dist/` leg applies. ## Local verification (HEAD `e7586a892e`) - **The pin.** 24/24 at `e7586a892e`: 8 cases × 3 cells, live PostgreSQL 16.14 and MySQL 8.0.46, `TZ=America/New_York`, `OS_EXPECT_LIVE_DIALECT_MATRIX=1`. - **`@objectstack/driver-sql`, whole suite, same environment.** Run at `d18427f4`. Since then `e7586a892e` adds only a test case and the changeset, and `sql-driver.ts` is unchanged. Result: 224 files, 5390 passed, 1 skipped. The reporter printed "all 3 dialects were exercised". - **Inheritors.** `@objectstack/driver-sqlite-wasm`: 36 files, 675 passed. `@objectstack/driver-turso`: 87 files, 2349 passed, 33 skipped. - **Typecheck.** `typecheck` exit 0 for `driver-sql`, `driver-sqlite-wasm` and `driver-turso`. `driver-sql`'s program includes the new test file (`tsc --listFiles`). - **Gates.** `pnpm check:driver-conformance` exited 0 before the first edit and after the last commit. The gate list is `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, run at `e7586a892e`; per-gate results are in the dev report on #21259. ## Acceptance notes - **Filters.** A filter comparand on an undeclared audit column was measured on live MySQL, with `$gt` and equality against `…Z` text. Both answered the right rows, so the read side needs no change. - **An existing fixture.** `sql-driver-11176-bulk-and-merge-updated-at.test.ts` §2 routes around this class in its fixture: it sends `toISOString()` on SQLite and a `Date` elsewhere. Left untouched. - **Clause-②.** No authorable or API accept set moves, and the audit columns are readonly to clients. The write door stops producing a literal MySQL refuses, for a value the declared-`datetime` path already took on every dialect. - **Out of scope.** The `sys_activity` table is missing on MySQL in this boot; #21241 is not addressed here. - **A log finding, reported for the seat to file.** On MySQL, the `[Better Auth]` ERROR log line printed the failing INSERT with its bound values, while the engine's WARN line for the same failure printed `[statement and bound values redacted]`. Details are in the dev report. --- _Generated by [Claude Code](https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9dae475 commit 95b91cc

3 files changed

Lines changed: 262 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
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.
6+
7+
Clause-②: no
8+
9+
**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`.
10+
11+
**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.
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21259] A builtin audit timestamp the object did NOT declare is written in
5+
* the storage form of the column this driver provisioned for it — on every
6+
* dialect, through every write door.
7+
*
8+
* # The defect
9+
*
10+
* `initObjects` creates `created_at` and `updated_at` on EVERY managed table
11+
* ({@link SqlDriver.createAuditTimestampColumn}: `DATETIME(3)` on MySQL), and
12+
* the engine's `sys_stamp_audit_insert` hook stamps BOTH on every insert, as
13+
* `new Date().toISOString()` — `YYYY-MM-DDTHH:MM:SS.sssZ`. `formatInput`
14+
* rewrites that canonical text into the MySQL literal only for a column in
15+
* `datetimeFields`, i.e. one the object DECLARED as `Field.datetime`. The
16+
* registry declares both for most objects, but injects nothing for a
17+
* `managedBy: 'better-auth'` or `systemFields: false` object, so a column such
18+
* an object leaves undeclared reached MySQL as the raw ISO text, which MySQL
19+
* refuses (`Incorrect datetime value … for column 'updated_at'`).
20+
*
21+
* Measured on live MySQL 8.0.46 at `8dea55d3`, `pnpm dev:crm -- --fresh
22+
* --database mysql://…`: `sys_jwks` declares `created_at` and not `updated_at`,
23+
* so the JWT signing-key row was never written (`created_at` bound as
24+
* `2026-10-01 23:35:47.598`, `updated_at` as `2026-10-01T23:35:47.598Z`, one
25+
* stamp), and `GET /api/v1/auth/jwks` and `GET /api/v1/auth/token` answered
26+
* 500. `sys_member` failed the same way. Of the 81 objects that boot registers,
27+
* 9 declare `created_at` without `updated_at` and 2 declare neither.
28+
*
29+
* # What is asserted, per cell
30+
*
31+
* Two objects in the two shapes the census found — `created_at` declared,
32+
* `updated_at` not (the `sys_jwks` shape), and neither declared — written
33+
* through `create`, `bulkCreate`, `upsert`, `update` and `updateMany` with the hook's exact
34+
* value, and read back as the same instant. Each write records the value the
35+
* driver BOUND for the undeclared column: the MySQL literal on MySQL, and on
36+
* SQLite and PostgreSQL the hook's text unchanged, byte for byte — those two
37+
* cells passed before the fix and are the control.
38+
*
39+
* A `Date` takes the same rule a declared `Field.datetime` takes, so it is
40+
* stored as the canonical instant on every dialect too.
41+
*
42+
* Cells: SQLite always; live PostgreSQL and MySQL where provisioned (the
43+
* `Temporal Conformance (live PG + MySQL)` job runs this package against both)
44+
* and declared un-run otherwise.
45+
*/
46+
47+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
48+
import { SqlDriver } from './index.js';
49+
import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';
50+
51+
/** Driver options every write here uses — these fixtures are not tenant-scoped. */
52+
const OPTS = { bypassTenantAudit: true } as any;
53+
54+
/** Declares `created_at` and not `updated_at` — the `sys_jwks` shape. */
55+
const CREATED_ONLY = 'os21259_created_only';
56+
/** Declares neither audit column — the `sys_oauth_client_assertion` shape. */
57+
const NEITHER = 'os21259_neither';
58+
59+
const OBJECTS = [
60+
{
61+
name: CREATED_ONLY,
62+
fields: {
63+
label: { type: 'string' },
64+
created_at: { type: 'datetime' },
65+
},
66+
},
67+
{
68+
name: NEITHER,
69+
fields: {
70+
label: { type: 'string' },
71+
},
72+
},
73+
] as any[];
74+
75+
/** What the engine's audit hook stamps: `new Date().toISOString()`. */
76+
const STAMP = '2026-10-01T21:49:27.479Z';
77+
const LATER = '2026-10-02T03:04:05.006Z';
78+
79+
/** The physical spelling the driver binds for `STAMP`'s instant on `cell`. */
80+
function boundFor(cell: DialectCell, iso: string): string {
81+
return cell.id === 'mysql' ? iso.replace('T', ' ').replace('Z', '') : iso;
82+
}
83+
84+
function suite(cell: DialectCell) {
85+
describe(`sql-driver — an undeclared builtin audit timestamp is written in its column's form (${cell.label}) [#21259]`, () => {
86+
let driver: SqlDriver;
87+
88+
/** Bindings of the writes this test issued against the fixture tables, in order. */
89+
let writes: { sql: string; bindings: unknown[] }[] = [];
90+
const record = (q: { sql?: string; bindings?: unknown[] }) => {
91+
const sql = String(q?.sql ?? '');
92+
if (!/^\s*(insert|update)/i.test(sql)) return;
93+
if (!sql.includes(CREATED_ONLY) && !sql.includes(NEITHER)) return;
94+
writes.push({ sql, bindings: [...(q.bindings ?? [])] });
95+
};
96+
97+
/** Every value bound in the recorded writes. */
98+
const bound = () => writes.flatMap((w) => w.bindings);
99+
100+
const stored = async (object: string, id: string) => {
101+
const row = await driver.findOne(object, { where: { id } }, OPTS);
102+
expect(row, `row ${id} was not stored`).not.toBeNull();
103+
return row!;
104+
};
105+
106+
beforeEach(async () => {
107+
driver = new SqlDriver(cell.config());
108+
for (const o of OBJECTS) await driver.getKnex().schema.dropTableIfExists(o.name);
109+
await driver.initObjects(OBJECTS);
110+
writes = [];
111+
driver.getKnex().on('query', record);
112+
});
113+
114+
afterEach(async () => {
115+
driver.getKnex().removeListener('query', record);
116+
for (const o of OBJECTS) await driver.getKnex().schema.dropTableIfExists(o.name);
117+
await driver.disconnect();
118+
});
119+
120+
it('measures the shape it claims to: `updated_at` is undeclared and its column exists', async () => {
121+
const fields = (driver as any).declaredFieldsFor(CREATED_ONLY);
122+
expect(Object.keys(fields)).toEqual(['label', 'created_at']);
123+
expect((driver as any).datetimeFields[CREATED_ONLY]?.has('updated_at') ?? false).toBe(false);
124+
const columns = Object.keys(await driver.getKnex()(CREATED_ONLY).columnInfo());
125+
expect(columns).toEqual(expect.arrayContaining(['created_at', 'updated_at']));
126+
});
127+
128+
it('create: the hook-stamped pair lands on the `sys_jwks` shape, as one instant', async () => {
129+
await driver.create(CREATED_ONLY, { id: 'c1', label: 'one', created_at: STAMP, updated_at: STAMP }, OPTS);
130+
131+
const row = await stored(CREATED_ONLY, 'c1');
132+
expect(row.created_at).toBe(STAMP);
133+
expect(row.updated_at).toBe(STAMP);
134+
// Both columns are bound in the column's form — the declared one always
135+
// was; the undeclared one is the fix. On SQLite and Postgres this is the
136+
// hook's text, unchanged.
137+
expect(bound().filter((v) => v === boundFor(cell, STAMP))).toHaveLength(2);
138+
if (cell.id === 'mysql') expect(bound()).not.toContain(STAMP);
139+
});
140+
141+
it('create: an object that declares neither audit column takes both', async () => {
142+
await driver.create(NEITHER, { id: 'n1', label: 'one', created_at: STAMP, updated_at: LATER }, OPTS);
143+
144+
const row = await stored(NEITHER, 'n1');
145+
expect(row.created_at).toBe(STAMP);
146+
expect(row.updated_at).toBe(LATER);
147+
expect(bound()).toEqual(expect.arrayContaining([boundFor(cell, STAMP), boundFor(cell, LATER)]));
148+
});
149+
150+
it('bulkCreate: every row of the batch lands', async () => {
151+
await driver.bulkCreate(
152+
CREATED_ONLY,
153+
[
154+
{ id: 'b1', label: 'one', created_at: STAMP, updated_at: STAMP },
155+
{ id: 'b2', label: 'two', created_at: LATER, updated_at: LATER },
156+
],
157+
OPTS,
158+
);
159+
160+
expect((await stored(CREATED_ONLY, 'b1')).updated_at).toBe(STAMP);
161+
expect((await stored(CREATED_ONLY, 'b2')).updated_at).toBe(LATER);
162+
});
163+
164+
it('upsert: the INSERT branch lands the supplied instant', async () => {
165+
await driver.upsert(NEITHER, { id: 'u1', label: 'one', created_at: STAMP, updated_at: STAMP }, undefined, OPTS);
166+
167+
const row = await stored(NEITHER, 'u1');
168+
expect(row.created_at).toBe(STAMP);
169+
expect(row.updated_at).toBe(STAMP);
170+
});
171+
172+
it('update under `preserveAudit`: the supplied `updated_at` is kept, in the column form', async () => {
173+
// Seeded with no audit value, so this case measures the UPDATE door alone.
174+
await driver.create(CREATED_ONLY, { id: 'p1', label: 'one' }, OPTS);
175+
writes = [];
176+
177+
await driver.update(CREATED_ONLY, 'p1', { label: 'one!', updated_at: LATER }, { ...OPTS, preserveAudit: true });
178+
179+
const row = await stored(CREATED_ONLY, 'p1');
180+
expect(row.label).toBe('one!');
181+
expect(row.updated_at).toBe(LATER);
182+
expect(bound()).toContain(boundFor(cell, LATER));
183+
});
184+
185+
it('updateMany under `preserveAudit`: the supplied `updated_at` is kept, in the column form', async () => {
186+
await driver.create(NEITHER, { id: 'm1', label: 'bulk' }, OPTS);
187+
await driver.create(NEITHER, { id: 'm2', label: 'bulk' }, OPTS);
188+
writes = [];
189+
190+
await driver.updateMany(NEITHER, { where: { label: 'bulk' } }, { updated_at: LATER }, { ...OPTS, preserveAudit: true });
191+
192+
expect((await stored(NEITHER, 'm1')).updated_at).toBe(LATER);
193+
expect((await stored(NEITHER, 'm2')).updated_at).toBe(LATER);
194+
expect(bound()).toContain(boundFor(cell, LATER));
195+
});
196+
197+
it('a `Date` is stored as the same canonical instant, as a declared `Field.datetime` is', async () => {
198+
await driver.create(NEITHER, { id: 'd1', label: 'one', created_at: new Date(STAMP), updated_at: new Date(LATER) }, OPTS);
199+
200+
const row = await stored(NEITHER, 'd1');
201+
expect(row.created_at).toBe(STAMP);
202+
expect(row.updated_at).toBe(LATER);
203+
});
204+
});
205+
}
206+
207+
for (const cell of DIALECT_CELLS) {
208+
declareDialectCell(cell, 'undeclared builtin audit timestamp write (#21259)', (c: DialectCell) => suite(c));
209+
}

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5495,6 +5495,21 @@ export class SqlDriver implements IDataDriver {
54955495
protected fractionalNumericFields: Record<string, string[]> = {};
54965496
protected dateFields: Record<string, Set<string>> = {};
54975497
protected datetimeFields: Record<string, Set<string>> = {};
5498+
/**
5499+
* [#21259] The builtin audit timestamps ({@link AUDIT_TIMESTAMP_COLUMNS}) a
5500+
* MANAGED object does not declare — the columns this driver provisions on
5501+
* every table it builds ({@link createAuditTimestampColumn}) that no
5502+
* declaration types. `formatInput` writes them in the `Field.datetime` form,
5503+
* the type their column was created with.
5504+
*
5505+
* Most objects declare both, because the engine's registry injects them as
5506+
* `Field.datetime`; a `managedBy: 'better-auth'` or `systemFields: false`
5507+
* object gets nothing injected, and the engine's audit hook stamps both
5508+
* anyway. A declared column keeps its declaration, whatever its type.
5509+
* Installed by {@link registerManagedObjectMetadata} only: an external object
5510+
* (ADR-0015) maps a remote table whose columns this driver did not create.
5511+
*/
5512+
protected undeclaredAuditTimestampFields: Record<string, readonly string[]> = {};
54985513
/**
54995514
* SQLite `Field.datetime` columns proven to hold ONLY canonical UTC text —
55005515
* either backfilled by {@link backfillCanonicalDatetimes} or created empty in
@@ -12050,6 +12065,11 @@ export class SqlDriver implements IDataDriver {
1205012065
// #2186: remember the authoritative metadata field set for this table so
1205112066
// drift detection / `os migrate` can diff the physical schema against it.
1205212067
this.managedObjectFields.set(tableName, obj.fields ?? {});
12068+
// [#21259] Recomputed on every registration, so a re-registration that now
12069+
// declares a column stops treating it as undeclared.
12070+
this.undeclaredAuditTimestampFields[tableName] = AUDIT_TIMESTAMP_COLUMNS.filter(
12071+
(col) => !Object.prototype.hasOwnProperty.call(obj.fields ?? {}, col),
12072+
);
1205312073
// Always overwrite — a metadata change that REMOVES `indexes` must clear
1205412074
// the previous entry, or drift detection keeps expecting an index nobody
1205512075
// declares any more (and never reports it as orphaned).
@@ -20024,6 +20044,28 @@ export class SqlDriver implements IDataDriver {
2002420044
}
2002520045
}
2002620046

20047+
// [#21259] The same rule for a builtin audit timestamp the object does NOT
20048+
// declare (see {@link undeclaredAuditTimestampFields}). Its column is the
20049+
// `DATETIME(3)` / `timestamptz` / canonical-text column this driver created,
20050+
// and the engine's audit hook stamps it as `toISOString()` text, which
20051+
// MySQL refuses as a datetime literal. Measured on live MySQL 8.0.46: every
20052+
// insert into `sys_jwks` (declares `created_at` only) and `sys_member` was
20053+
// refused on `updated_at`, so no JWT signing key was ever stored. On SQLite
20054+
// and Postgres the hook's text is already the canonical form and is bound
20055+
// unchanged; a `Date` lands as that same text instead of an epoch INTEGER.
20056+
const undeclaredAudit = this.undeclaredAuditTimestampFields[object];
20057+
if (undeclaredAudit && undeclaredAudit.length > 0 && copy && typeof copy === 'object') {
20058+
for (const field of undeclaredAudit) {
20059+
const v = copy[field];
20060+
if (v == null) continue;
20061+
const normalized = this.storageDatetimeValue(v);
20062+
if (normalized !== v) {
20063+
if (!copied) { copy = { ...copy }; copied = true; }
20064+
copy[field] = normalized;
20065+
}
20066+
}
20067+
}
20068+
2002720069
// ADR-0053 Phase 1: a `Field.date` is a timezone-naive calendar day, not
2002820070
// an instant. Collapse any `Date` or full-ISO value to `YYYY-MM-DD` before
2002920071
// it hits the wire so storage matches the date-only contract the filter

0 commit comments

Comments
 (0)