|
44 | 44 | * That pin covers the FIELDS; this one covers the builtin column itself, which |
45 | 45 | * is not a vocabulary entry and so had no rule anywhere. |
46 | 46 | * |
47 | | - * ## ⚠️ Recorded divergence, NOT a ruling: the audit-stamp columns (#15040) |
| 47 | + * ## The audit-stamp columns: one half ruled (#15521), one half still recorded |
48 | 48 | * |
49 | | - * The last `it` below records — and deliberately does not correct — a THIRD |
50 | | - * disagreement measured in the same pass. It is recorded so it cannot change |
51 | | - * shape unnoticed, and so no reader mistakes this file for having decided it. |
| 49 | + * #15040 measured a THIRD disagreement in the same pass and recorded it here |
| 50 | + * without correcting it. #15521 split that record in two and ruled only the |
| 51 | + * half that was decidable: |
| 52 | + * |
| 53 | + * TYPE — RULED. The sql format spelled both columns bare `TIMESTAMP` |
| 54 | + * (`timestamp WITHOUT time zone`) while the driver and the typescript format |
| 55 | + * both build them with knex's `table.timestamp` = `timestamptz`. Driven on a |
| 56 | + * live PostgreSQL 16.13: two defaulted rows inserted 6 ms apart under |
| 57 | + * different session timezones landed NINE HOURS apart in the zone-naive |
| 58 | + * column and 3 ms apart in the aware one. One producer of three was wrong and |
| 59 | + * nothing had to be decided, so the sql format moved. |
| 60 | + * |
| 61 | + * NULLABILITY (and the `now()` / `CURRENT_TIMESTAMP` default spelling) — |
| 62 | + * STILL RECORDED, still not ruled: the driver leaves both columns nullable |
| 63 | + * and both generators say NOT NULL. Nothing fails either way, so which side |
| 64 | + * moves is a ruling #15521 holds open. |
52 | 65 | */ |
53 | 66 |
|
54 | 67 | import fs from 'node:fs'; |
@@ -173,40 +186,79 @@ describe('the builtin id column both migration generators emit (#15040)', () => |
173 | 186 | } |
174 | 187 | }); |
175 | 188 |
|
176 | | - // ── Recorded divergence, NOT coverage, and NOT a ruling ────────────────── |
| 189 | + // ── #15521, the TYPE half: ruled, and now asserted as agreement ──────── |
| 190 | + // |
| 191 | + // Bare `TIMESTAMP` is `timestamp WITHOUT time zone`. Both knex producers of |
| 192 | + // these same two columns — `driver-sql`'s `createAuditTimestampColumn` and |
| 193 | + // `generateMigrationTs`'s `table.timestamps(true, true)` — yield |
| 194 | + // `timestamptz`. Read back out of `information_schema.columns` on a live |
| 195 | + // PostgreSQL 16.13, all three producers driven: |
177 | 196 | // |
178 | | - // Measured in the same pass as the id column, on the same two generators. |
179 | | - // The audit-stamp columns disagree with the driver too, in a way the id |
180 | | - // column did not, and the disagreement is NULLABILITY rather than type: |
| 197 | + // driver created_at timestamp with time zone null=YES default=CURRENT_TIMESTAMP |
| 198 | + // ts gen created_at timestamp with time zone null=NO default=CURRENT_TIMESTAMP |
| 199 | + // sql gen created_at timestamp without time zone null=NO default=now() |
181 | 200 | // |
182 | | - // driver-sql `table.timestamp(name).defaultTo(knex.fn.now())` (nullable) |
183 | | - // sql gen `"created_at" TIMESTAMP NOT NULL DEFAULT now()` |
184 | | - // ts gen `table.timestamps(true, true)` — knex 3.3.0 compiles this |
185 | | - // to `.notNullable().defaultTo(CURRENT_TIMESTAMP)` on both |
186 | | - // columns (`knex/lib/schema/tablebuilder.js`). |
| 201 | + // Asserted as AGREEMENT with the driver rather than as the literal |
| 202 | + // `TIMESTAMPTZ` alone: the driver's builder is read where it lives, so the day |
| 203 | + // it stops emitting a knex `table.timestamp` this fails here instead of |
| 204 | + // leaving the generators quietly wrong again — the same discipline the id |
| 205 | + // column above already uses for its width. |
| 206 | + it('#15521 — the audit columns take the driver\'s zone-AWARE type in both generators', () => { |
| 207 | + const sql = generateMigrationSql(CONFIG as Record<string, unknown>); |
| 208 | + for (const col of ['created_at', 'updated_at']) { |
| 209 | + expect(sql).toContain(`"${col}" TIMESTAMPTZ NOT NULL DEFAULT now()`); |
| 210 | + // `\b` discriminates: `TIMESTAMPTZ` is not a match for `TIMESTAMP\b`. |
| 211 | + expect( |
| 212 | + sql, |
| 213 | + `the sql format spells ${col} zone-NAIVE again — a defaulted row then records the ` + |
| 214 | + 'wall clock of whatever session wrote it, with nothing left to recover the offset from', |
| 215 | + ).not.toMatch(new RegExp(`"${col}" TIMESTAMP\\b`)); |
| 216 | + } |
| 217 | + // Anti-vacuity: the predicate really does fire on the shape this replaced. |
| 218 | + expect(' "created_at" TIMESTAMP NOT NULL DEFAULT now()').toMatch(/"created_at" TIMESTAMP\b/); |
| 219 | + // The authority, read where it lives — both knex paths, neither transcribed. |
| 220 | + expect( |
| 221 | + SQL_DRIVER_SOURCE, |
| 222 | + 'driver-sql no longer builds the audit columns with knex\'s `table.timestamp`, which is ' + |
| 223 | + 'what makes them `timestamptz` on Postgres. Re-read #15521 before trusting the ' + |
| 224 | + 'generators\' TIMESTAMPTZ.', |
| 225 | + ).toContain('table.timestamp(name).defaultTo(this.knex.fn.now());'); |
| 226 | + expect(generateMigrationTs(CONFIG as Record<string, unknown>)).toContain('table.timestamps(true, true);'); |
| 227 | + }); |
| 228 | + |
| 229 | + // ── #15521, the NULLABILITY half: recorded, NOT coverage, and NOT a ruling ── |
187 | 230 | // |
188 | | - // Compiled offline against knex's pg dialect, the two shapes are: |
| 231 | + // The driver leaves both columns nullable; both generators say NOT NULL: |
189 | 232 | // |
190 | | - // driver "created_at" timestamptz default CURRENT_TIMESTAMP |
191 | | - // ts gen "created_at" timestamptz not null default CURRENT_TIMESTAMP |
| 233 | + // driver-sql `table.timestamp(name).defaultTo(knex.fn.now())` (nullable) |
| 234 | + // sql gen `"created_at" TIMESTAMPTZ NOT NULL DEFAULT now()` |
| 235 | + // ts gen `table.timestamps(true, true)` — knex 3.3.0 compiles this to |
| 236 | + // `.notNullable().defaultTo(CURRENT_TIMESTAMP)` on both columns |
| 237 | + // (`knex/lib/schema/tablebuilder.js`), which the live-Postgres |
| 238 | + // catalog read above confirms as `null=NO`. |
192 | 239 | // |
193 | | - // Unlike the id column this is not obviously a wrong value: the driver stamps |
194 | | - // both columns on every write, so NOT NULL is arguably the truer constraint — |
195 | | - // and the driver's own DDL is dialect-branched (`datetime(3)` on MySQL, a |
| 240 | + // Unlike the type half this is not a wrong value: the driver stamps both |
| 241 | + // columns on every write, so NOT NULL is arguably the truer constraint, and |
| 242 | + // the driver's own audit DDL is dialect-branched (`datetime(3)` on MySQL, a |
196 | 243 | // canonical ISO default on SQLite) in a way a Postgres-flavoured generated |
197 | | - // migration does not try to reproduce. Which side moves is not this card's to |
198 | | - // decide, so nothing here changes those lines. Asserted only so the |
199 | | - // divergence cannot change shape unnoticed. |
200 | | - it('#15040 record — the audit-stamp columns diverge from the driver, deliberately unresolved', () => { |
| 244 | + // migration does not try to reproduce — so "match the driver byte for byte" |
| 245 | + // is not even well-defined across dialects. Which side moves is #15521's to |
| 246 | + // decide; nothing here changes those lines. |
| 247 | + // |
| 248 | + // The DEFAULT spelling rides with it: this generator's `now()` and the |
| 249 | + // driver's `CURRENT_TIMESTAMP` are the same instant (both are |
| 250 | + // `transaction_timestamp()`), but Postgres keeps them textually apart in the |
| 251 | + // catalog, so they are two rows of the same schema-diff noise. |
| 252 | + it('#15521 record — the audit columns\' NULLABILITY diverges, deliberately unresolved', () => { |
201 | 253 | const sql = generateMigrationSql(CONFIG as Record<string, unknown>); |
202 | | - expect(sql).toContain('"created_at" TIMESTAMP NOT NULL DEFAULT now()'); |
203 | | - expect(sql).toContain('"updated_at" TIMESTAMP NOT NULL DEFAULT now()'); |
| 254 | + expect(sql).toContain('"created_at" TIMESTAMPTZ NOT NULL DEFAULT now()'); |
| 255 | + expect(sql).toContain('"updated_at" TIMESTAMPTZ NOT NULL DEFAULT now()'); |
204 | 256 | expect(generateMigrationTs(CONFIG as Record<string, unknown>)).toContain('table.timestamps(true, true);'); |
205 | 257 | // The driver side, read where it lives: one audit-column builder, and its |
206 | 258 | // default arm carries no `.notNullable()`. |
207 | 259 | expect( |
208 | 260 | SQL_DRIVER_SOURCE, |
209 | | - 'driver-sql\'s audit-column DDL moved — re-read the #15040 record above before trusting it.', |
| 261 | + 'driver-sql\'s audit-column DDL moved — re-read the #15521 record above before trusting it.', |
210 | 262 | ).toContain('table.timestamp(name).defaultTo(this.knex.fn.now());'); |
211 | 263 | const auditArm = SQL_DRIVER_SOURCE.slice( |
212 | 264 | SQL_DRIVER_SOURCE.indexOf('protected createAuditTimestampColumn('), |
|
0 commit comments