diff --git a/.changeset/generator-declared-column-default.md b/.changeset/generator-declared-column-default.md new file mode 100644 index 0000000000..831508a53d --- /dev/null +++ b/.changeset/generator-declared-column-default.md @@ -0,0 +1,51 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): a generated migration carries the column DEFAULT `driver-sql` puts on the same field (#16294) + +## What was wrong + +Neither `os generate migration` format read a field's `defaultValue`, so a table +created from a generated migration had no column DEFAULT where the platform's +own table has one. A row inserted out of band — by a database client, a seed +script, anything that does not go through the engine — got NULL where the +declared value belonged. + +Driven on live PostgreSQL 16.13: one object, three schemas, one producer each +(`driver-sql` through `initObjects`, `--format sql` through `db.raw`, +`--format ts` by importing the emitted module and calling `up(db)`), with +`information_schema.columns` read back per schema. + +``` +field driver sqlgen verdict +f_default null=YES default='hello'::text null=YES default=- DIVERGED +f_default_required null=YES default='hello'::text null=YES default=- DIVERGED +``` + +After: `diverged: 0 of 6` on the card's probe, and 22 of 23 on a wider one +covering every `defaultValue` shape. + +## What changed + +Both formats now render one shared verdict, taken from +`SqlDriver.applyDeclaredColumnDefault` — the single place a `defaultValue` +becomes DDL on the platform side: + +- a **literal** is emitted, quoted the way knex binds it (`DEFAULT '42'`, not + `DEFAULT 42` — PostgreSQL keeps those two textually apart forever in + `column_default`, and the driver's column carries the quoted form); +- **`'NOW()'`** becomes the driver's own translation, which is type-branched: + `CURRENT_TIMESTAMP` on a timestamp column, and a UTC-pinned expression on + `date` / `time`, because a bare `CURRENT_TIMESTAMP` resolves those in the + server's timezone; +- **any other runtime token** (`current_user`), an **Expression envelope** and + an **option-level `default: true`** emit nothing, each because the driver + emits nothing — the engine owns those, and a column DEFAULT would override a + decision it makes deliberately; +- a **`multiple: true`** field gets neither, because `createColumn` returns + before both questions. + +No authorable key, export or accepted-input set changes: `defaultValue` was +already declared, already parsed and already honoured by the driver. The +generators simply now read it. diff --git a/content/docs/protocol/objectql/types.mdx b/content/docs/protocol/objectql/types.mdx index 3c9855c99d..1ce5c6e1c6 100644 --- a/content/docs/protocol/objectql/types.mdx +++ b/content/docs/protocol/objectql/types.mdx @@ -508,6 +508,15 @@ directly. `datetime` on PostgreSQL/MySQL keeps the native `now()`, which is already UTC — the driver pins every MySQL connection with `SET time_zone = '+00:00'`. +Both `os generate migration` formats reproduce the PostgreSQL arm of this +translation, so a table built from a generated migration carries the same +default as one the driver creates. That holds for a plain literal +`defaultValue` too — and for the two shapes that deliberately get **no** column +default on either producer, because the engine resolves them per insert: +`defaultValue: 'current_user'` (which must stay unset when there is no +authenticated user) and an Expression envelope (`{ dialect, source }`). An +option-level `default: true` on a `select` is likewise engine-only. + A DDL default only governs **newly created** columns. A column created before this convention keeps its legacy default and can still emit a zone-naive value on diff --git a/packages/cli/src/commands/generate-declared-column-default.pin.test.ts b/packages/cli/src/commands/generate-declared-column-default.pin.test.ts new file mode 100644 index 0000000000..d969d6b93e --- /dev/null +++ b/packages/cli/src/commands/generate-declared-column-default.pin.test.ts @@ -0,0 +1,388 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * THE #16294 CAUSE-3 PIN: an authored `defaultValue` reaches the generated + * table as the same column DEFAULT `driver-sql` puts there. + * + * ## The defect + * + * Neither migration format read `defaultValue` at all, so a row inserted out of + * band into a generated table got NULL where the platform's own table supplies + * the declared value. Driven on live PostgreSQL 16.13 — one object, three + * schemas, one producer each, `information_schema.columns` read back per schema + * (the card's own six-column probe, re-run against `generate.ts` as #16887 and + * #17208 leave it): + * + * ``` + * field driver sqlgen verdict + * f_plain null=YES default=- null=YES default=- agree + * f_required null=YES default=- null=YES default=- agree + * f_storage_notnull null=NO default=- null=NO default=- agree + * f_required_and_st null=NO default=- null=NO default=- agree + * f_default null=YES default='hello'::text null=YES default=- DIVERGED + * f_default_required null=YES default='hello'::text null=YES default=- DIVERGED + * ``` + * + * ...and after the repair, on the same cluster, `diverged: 0 of 6`. + * + * ⭐ The six-column probe covers ONE `defaultValue` shape. A 23-column probe on + * the same cluster covers the rest, and two of its rows are why this file exists + * beyond "emit the value": + * + * ``` + * d_now_datetime driver CURRENT_TIMESTAMP (the token has a database counterpart) + * d_now_date driver (timezone('utc'::text, now()))::date (NOT the same expression) + * d_now_time driver (timezone('utc'::text, now()))::time(3) (nor this one) + * d_current_user driver - (a token with NO counterpart) + * d_expr driver - (an Expression envelope) + * d_select driver - (an option-level `default: true`) + * d_number 42 driver '42'::numeric sqlgen 42 DIVERGED on TEXT, same value + * ``` + * + * The last row is the one a "just emit the literal" repair gets wrong and never + * notices: `DEFAULT 42` and `DEFAULT '42'` are the same default and PostgreSQL + * keeps them textually apart forever in `column_default`, which is precisely the + * cost #15521 measured for the audit pair. knex quotes every bound default, so + * the driver's column carries the quoted form and the sql format now does too. + * + * ## What this pin does NOT claim + * + * 1. **`f_required` stays out of the repair.** A `required: true` field with no + * `storage.notNull` is nullable on all three producers — which is agreement, + * not divergence, because #16887 already took both generators off `required`. + * Whether a SCAFFOLD should nonetheless preserve the author's declaration is + * an open decision (#17218) and ⛔ is not settled here or by this file. + * 2. **`NOW()` on a `date` / `time` column is a PostgreSQL claim**, like every + * other literal in `generateMigrationSql` (its own header says so for `JSONB` + * / `TIMESTAMPTZ` / `CURRENT_TIMESTAMP`). `SqlDriver.nowColumnDefault` is + * dialect-branched and its SQLite arm is a canonical ISO string, so those two + * shapes are asserted in section B against the driver's PostgreSQL arm and + * are deliberately ABSENT from the SQLite chain in section A — a `db.raw` + * carrying `timezone('utc', now())` cannot create a table there at all. + * ⛔ Read that as the boundary of the claim, never as coverage. + * 3. **A `multiple: true` field's NULLABILITY.** `createColumn` short-circuits + * on the flag and returns before both nullability and defaults, so the driver + * leaves such a column nullable while both generators emit NOT NULL for + * `storage.notNull` — measured on the same cluster and the ONE row of the + * 23-column probe still diverging after this change. It is pinned as-is by + * `generate-multiple-json-column.pin.test.ts`, so moving it is a separate + * card, not a rider here. What this file does assert about that field is the + * half it owns: the driver emits no DEFAULT for it, and neither format does. + * + * ## Why this pin reads the driver instead of asserting the spellings + * + * The same reason `generate-string-family-width.pin.test.ts` and + * `generate-declared-unique-index.pin.test.ts` give: the whole shape of this + * card is "the generator disagrees with the driver", so a pin transcribing + * `CURRENT_TIMESTAMP` would re-create the defect one layer up and stay green the + * day the driver's spelling moves. Section A's authority is three real tables in + * one engine, compared through the engine's own catalog; section B recomputes + * every `NOW()` spelling from `SqlDriver.nowColumnDefault` itself. + */ + +import { SqlDriver } from '@objectstack/driver-sql'; +import { afterAll, describe, expect, it } from 'vitest'; + +import { generateMigrationSql, generateMigrationTs } from './generate.js'; + +// ── The oracle ────────────────────────────────────────────────────────────── + +/** + * The driver's own `protected` judgments, reached by widening rather than + * re-derived — the technique `generate-string-family-width.pin.test.ts` + * established: `protected` is a compile-time visibility rule, so a subclass + * publishes the driver's OWN body without copying a character of it. + */ +class DriverOracle extends SqlDriver { + protected override logger = { warn: () => {}, error: () => {} }; + + /** `SqlDriver.nowColumnDefault`, unmodified — the `'NOW()'` translation. */ + public nowDefaultFor(type: string): unknown { + return this.nowColumnDefault(type); + } + + /** The knex handle this driver opened — the database every producer writes into. */ + public get db(): any { + return this.knex; + } +} + +function newOracle(): DriverOracle { + return new DriverOracle({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + } as any); +} + +/** + * A PostgreSQL-configured driver that never connects. knex builds SQL lazily, so + * `nowColumnDefault`'s dialect branch and the DDL it compiles into are both + * readable without a server — which is what lets section B assert the arm the + * live cluster measured on a tier that runs everywhere. + */ +function newPostgresOracle(): DriverOracle { + return new DriverOracle({ client: 'pg', connection: 'postgres://pin@127.0.0.1:1/unused' } as any); +} + +/** One column as SQLite's own catalog reports it. */ +interface PhysicalColumn { + notnull: number; + dflt_value: string | null; +} + +async function physicalColumns(db: any, table: string): Promise> { + const rows = (await db.raw(`PRAGMA table_info("${table}")`)) as Array<{ + name: string; + notnull: number; + dflt_value: string | null; + }>; + const out: Record = {}; + for (const r of rows) out[r.name] = { notnull: r.notnull, dflt_value: r.dflt_value }; + return out; +} + +/** Statements of the sql format, comment lines removed. */ +function sqlStatements(sql: string): string[] { + const stripped = sql.split('\n').filter((l) => !l.trim().startsWith('--')).join('\n'); + return stripped.split(';').map((s) => s.trim()).filter(Boolean); +} + +/** + * The ts format's module, loaded WITHOUT touching the filesystem. + * + * The emitted source is TypeScript only in its two annotations; stripping them + * and turning the two `export`s into locals makes it an ordinary function body. + * ⛔ Not a parse check — `generate-emission-parses.test.ts` owns that; this is + * how the third producer gets RUN. + */ +function loadEmittedTs(ts: string): { up: (db: any) => Promise } { + const js = ts + .replace(/: any/g, '') + .replace(/: Promise/g, '') + .replace(/export async function/g, 'async function'); + // eslint-disable-next-line no-new-func + return new Function(`${js}\nreturn { up, down };`)() as { up: (db: any) => Promise }; +} + +const configFor = (object: Record) => ({ objects: { o: object } }) as Record; + +// ── The corpus ────────────────────────────────────────────────────────────── +// +// Every `defaultValue` shape whose verdict is dialect-INDEPENDENT: a literal is +// a literal on both engines, and the three shapes that emit nothing emit nothing +// everywhere. The `NOW()` family is section B's, for the reason the header gives. + +const FIELDS: Record = { + c_none: { type: 'text' }, + c_text: { type: 'text', defaultValue: 'hello' }, + c_empty: { type: 'text', defaultValue: '' }, + c_quote: { type: 'text', defaultValue: "it's" }, + c_newline: { type: 'text', defaultValue: 'a\nb' }, + c_backslash: { type: 'text', defaultValue: 'a\\b' }, + c_number: { type: 'number', defaultValue: 42 }, + c_zero: { type: 'number', defaultValue: 0 }, + c_negative: { type: 'number', defaultValue: -3.5 }, + c_integer: { type: 'rating', defaultValue: 7 }, + c_true: { type: 'boolean', defaultValue: true }, + c_false: { type: 'boolean', defaultValue: false }, + c_notnull_default: { type: 'text', storage: { notNull: true }, defaultValue: 'hi' }, + c_required_default: { type: 'text', required: true, defaultValue: 'hi' }, + // The three shapes the driver deliberately gives NO column default. + c_token_user: { type: 'lookup', referenceTo: 'sys_user', defaultValue: 'current_user' }, + c_expression: { type: 'text', defaultValue: { dialect: 'cel', source: 'today()' } }, + c_option_default: { type: 'select', options: [{ label: 'A', value: 'a', default: true }] }, + // ...and the flag that returns before `createColumn` reaches either question. + c_multiple: { type: 'text', multiple: true, defaultValue: 'x' }, +}; + +/** The columns whose DEFAULT the driver is expected to emit — the non-vacuity set. */ +const DEFAULTED = [ + 'c_text', 'c_empty', 'c_quote', 'c_newline', 'c_backslash', + 'c_number', 'c_zero', 'c_negative', 'c_integer', 'c_true', 'c_false', + 'c_notnull_default', 'c_required_default', +]; + +/** The columns that must carry NO default, each for its own recorded reason. */ +const UNDEFAULTED = ['c_none', 'c_token_user', 'c_expression', 'c_option_default', 'c_multiple']; + +const OBJECT = { name: 'probe', fields: FIELDS }; + +// ──────────────────────────────────────────────────────────────────────────── +// A. THE REAL CHAIN — three producers, one database, the catalog as the witness +// ──────────────────────────────────────────────────────────────────────────── + +describe('#16294 — the DEFAULT each generator emits is the DEFAULT the driver CREATES', () => { + const drivers: DriverOracle[] = []; + afterAll(async () => { for (const d of drivers) await d.db.destroy(); }); + + async function threeTables(object: Record) { + const driverDrv = newOracle(); + const sqlDrv = newOracle(); + const tsDrv = newOracle(); + drivers.push(driverDrv, sqlDrv, tsDrv); + + await driverDrv.initObjects([object as any]); + + const sqlText = generateMigrationSql(configFor(object)); + for (const stmt of sqlStatements(sqlText)) await sqlDrv.db.raw(stmt); + + const tsText = generateMigrationTs(configFor(object)); + await loadEmittedTs(tsText).up(tsDrv.db); + + const table = String(object.name); + return { + sqlText, + tsText, + driver: await physicalColumns(driverDrv.db, table), + sqlgen: await physicalColumns(sqlDrv.db, table), + tsgen: await physicalColumns(tsDrv.db, table), + dbs: { driver: driverDrv.db, sqlgen: sqlDrv.db, tsgen: tsDrv.db }, + }; + } + + it('every corpus column carries the driver\'s own DEFAULT text in both formats', async () => { + const built = await threeTables(OBJECT); + for (const name of Object.keys(FIELDS)) { + // ⭐ THE AUTHORITY: whatever the driver's own table records for this + // column, both generated tables record too — read out of the engine's + // catalog, never out of the emitted text. + expect({ column: name, ...built.sqlgen[name] }).toEqual({ column: name, ...built.driver[name] }); + expect({ column: name, ...built.tsgen[name] }).toEqual({ column: name, ...built.driver[name] }); + } + }); + + /** + * NON-VACUITY, and the half that makes the equality above mean something: a + * corpus in which the driver emitted no default at all would satisfy every + * `toEqual` by agreeing on `null`. + */ + it('the corpus really produces defaults, and really produces absences', async () => { + const built = await threeTables(OBJECT); + for (const name of DEFAULTED) { + expect({ column: name, dflt: built.driver[name]?.dflt_value }).not.toEqual({ column: name, dflt: null }); + expect({ column: name, dflt: built.sqlgen[name]?.dflt_value }).not.toEqual({ column: name, dflt: null }); + expect({ column: name, dflt: built.tsgen[name]?.dflt_value }).not.toEqual({ column: name, dflt: null }); + } + for (const name of UNDEFAULTED) { + expect({ column: name, dflt: built.driver[name]?.dflt_value }).toEqual({ column: name, dflt: null }); + expect({ column: name, dflt: built.sqlgen[name]?.dflt_value }).toEqual({ column: name, dflt: null }); + expect({ column: name, dflt: built.tsgen[name]?.dflt_value }).toEqual({ column: name, dflt: null }); + } + expect(DEFAULTED.length + UNDEFAULTED.length).toBe(Object.keys(FIELDS).length); + }); + + /** + * ⭐ THE CARD'S OWN CONSEQUENCE, as behaviour rather than as a catalog row: + * "a row inserted out of band into a generated table gets NULL where the + * platform's own table would have supplied the declared value". + */ + it('an out-of-band insert gets the declared value from ALL THREE tables', async () => { + const built = await threeTables(OBJECT); + const seen: Record = {}; + for (const [who, db] of Object.entries(built.dbs)) { + await db.raw(`INSERT INTO "probe" ("id", "c_notnull_default") VALUES ('a', 'nn')`); + const rows = (await db.raw(`SELECT "c_text", "c_number", "c_true" FROM "probe" WHERE "id" = 'a'`)) as any[]; + seen[who] = rows[0]; + } + expect(seen.sqlgen).toEqual(seen.driver); + expect(seen.tsgen).toEqual(seen.driver); + // ...and the row really carries the declared value, not a shared NULL. + expect((seen.driver as any).c_text).toBe('hello'); + }); + + /** + * The FIRING CONTROL for the block above: the same comparison run against a + * table built from a mutated declaration must FAIL. Without it, "all three + * agree" is a claim no observation could contradict. + */ + it('the comparison can fail — a changed declaration moves the driver\'s column', async () => { + const moved = await threeTables({ + name: 'probe', + fields: { ...FIELDS, c_text: { type: 'text', defaultValue: 'MOVED' } }, + }); + const base = await threeTables(OBJECT); + expect(moved.driver.c_text).not.toEqual(base.driver.c_text); + // ...and the generators followed it there, which is the whole claim. + expect(moved.sqlgen.c_text).toEqual(moved.driver.c_text); + expect(moved.tsgen.c_text).toEqual(moved.driver.c_text); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// B. THE `NOW()` TRANSLATION — recomputed from the driver's own PostgreSQL arm +// ──────────────────────────────────────────────────────────────────────────── + +describe('#16294 — the `NOW()` spellings are `SqlDriver.nowColumnDefault`\'s own', () => { + const pg = newPostgresOracle(); + afterAll(async () => { await pg.db.destroy(); }); + + const sqlFor = (fields: Record) => + generateMigrationSql(configFor({ name: 'probe', fields })); + const tsFor = (fields: Record) => + generateMigrationTs(configFor({ name: 'probe', fields })); + + /** + * One row per branch of `nowColumnDefault`, with the expected text taken FROM + * that method rather than written here. `time` and `date` are the two the + * driver refuses to answer with a bare `CURRENT_TIMESTAMP`: it resolves the + * calendar day in the server's timezone (#4022) and the time-of-day in the + * server's or the session's clock (#3994). + */ + for (const type of ['datetime', 'date', 'time'] as const) { + it(`\`NOW()\` on a ${type} column emits the driver's PostgreSQL default`, () => { + const expected = String(pg.nowDefaultFor(type)); + const sql = sqlFor({ f: { type, defaultValue: 'NOW()' } }); + expect(sql).toContain(`DEFAULT ${expected}`); + + // The ts format reaches the same text through knex — asserted by + // COMPILING the emitted call's argument the way the driver compiles its + // own, rather than by matching the source string. + const ts = tsFor({ f: { type, defaultValue: 'NOW()' } }); + const compiled = type === 'datetime' + ? String(pg.db.fn.now()) + : String(pg.db.raw(expected)); + expect(String(pg.db.raw(compiled))).toBe(expected); + expect(ts).toContain('.defaultTo('); + expect(ts).toMatch(type === 'datetime' ? /\.defaultTo\(db\.fn\.now\(\)\)/ : /\.defaultTo\(db\.raw\(/); + if (type !== 'datetime') expect(ts).toContain(expected.replace(/"/g, '')); + }); + } + + /** NON-VACUITY: the three branches really are three different spellings. */ + it('the three branches are three distinct spellings', () => { + const spellings = new Set(['datetime', 'date', 'time'].map((t) => String(pg.nowDefaultFor(t)))); + expect(spellings.size).toBe(3); + }); + + /** + * The token vocabulary is IMPORTED, so its tolerance comes along: the driver + * accepts `'now()'` and `' NOW() '` for the same token, and so must both + * formats. A generator that string-compared `'NOW()'` would emit the literal + * text `'now()'` as a default value into a timestamp column. + */ + it('the token match is the spec\'s — case- and whitespace-tolerant', () => { + const expected = String(pg.nowDefaultFor('datetime')); + for (const spelling of ['NOW()', 'now()', ' NOW() ', 'Now()']) { + expect(sqlFor({ f: { type: 'datetime', defaultValue: spelling } })).toContain(`DEFAULT ${expected}`); + expect(tsFor({ f: { type: 'datetime', defaultValue: spelling } })).toContain('.defaultTo(db.fn.now())'); + } + // ...and a near-miss is NOT the token: it is an ordinary string literal. + expect(sqlFor({ f: { type: 'text', defaultValue: 'NOW' } })).toContain("DEFAULT 'NOW'"); + }); + + /** + * The OTHER token, with no database counterpart. Asserted here beside its + * sibling so the pair reads as one rule rather than two coincidences. + */ + it('a token with no database counterpart emits no DEFAULT in either format', () => { + const fields = { f: { type: 'lookup', referenceTo: 'sys_user', defaultValue: 'current_user' } }; + expect(sqlFor(fields)).not.toContain('DEFAULT current_user'); + expect(sqlFor(fields)).not.toContain("DEFAULT 'current_user'"); + // Read on the FIELD's own line: the two audit columns below it legitimately + // carry `.defaultTo(db.fn.now())`, so a whole-file `not.toContain` would be + // a test that could never pass rather than one that could never fail. + const fieldLine = tsFor(fields).split('\n').find((l) => l.includes("'f'")); + expect(fieldLine).toBe(" table.string('f').nullable();"); + }); +}); diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index b5078da01b..e1fa07b059 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -18,7 +18,13 @@ import type { FieldType } from '@objectstack/spec/data'; // `computeTenantField` — are spelled here in the driver's own terms ON TOP of // these, so the part that can be shared is shared and only the part that // genuinely lives on `driver-sql` is mirrored. -import { isTenancyDisabled, isUniqueDeclared, numericColumnFor } from '@objectstack/spec/data'; +import { + isNowDefaultToken, + isRuntimeDefaultToken, + isTenancyDisabled, + isUniqueDeclared, + numericColumnFor, +} from '@objectstack/spec/data'; import { printHeader, printSuccess, printError, printInfo, printStep, createTimer, isReportedError, CLI_ALIAS } from '../utils/format.js'; import { metadataFileName } from '../utils/metadata-file-name.js'; import { findEmissionParseFailures } from '../utils/emitted-source-parses.js'; @@ -1336,6 +1342,163 @@ function declaredNotNull(field: unknown): boolean { return (field as { storage?: { notNull?: boolean } } | undefined)?.storage?.notNull === true; } +/** + * The physical column DEFAULT a field's `defaultValue` calls for — or, exactly + * as deliberately, none at all. + * + * `SqlDriver.applyDeclaredColumnDefault` is the single place a `defaultValue` + * becomes DDL on the platform side, and this is its decision, format-free. Both + * emitters below render THIS verdict, so the two formats cannot answer the + * question differently from each other — which is the shape #16294 measured: + * both generators agreed with each other and disagreed with the platform. + * + * The driver's four cases, in its own order: + * + * 1. **`'NOW()'`** — the one runtime token with a database counterpart, + * translated to the driver-native canonical default (`nowColumnDefault`). + * That translation is TYPE-branched, which is why the three `now-*` + * verdicts are distinguished here rather than collapsed: a bare + * `CURRENT_TIMESTAMP` in a `date` column resolves the calendar day in the + * SERVER's timezone (a UTC-12 server records YESTERDAY, #4022) and in a + * `time` column resolves it in the server's or the session's clock (#3994). + * 2. **Any other runtime token** (`current_user`, and whatever the spec adds + * to `DEFAULT_VALUE_TOKENS` later) — resolved by the ENGINE at insert time + * against the request context, with NO database counterpart, so nothing is + * emitted. That omission is the contract: the engine deliberately leaves a + * `current_user` field UNSET when there is no authenticated user, and a + * column DEFAULT silently overrode that decision by writing the literal + * string `'current_user'` into `lookup('sys_user')` columns (#4560). + * 3. **Objects** — Expression envelopes (`{ dialect, source }`), evaluated + * app-side; never a column DEFAULT. + * 4. **Everything else** — a real literal, emitted verbatim. + * + * ⛔ The two token predicates are IMPORTED, never re-spelled: `isNowDefaultToken` + * is case- and whitespace-tolerant, and `isRuntimeDefaultToken` is what makes a + * token added tomorrow degrade to "no column default" instead of leaking its own + * spelling into the database. A transcription here would be a second vocabulary + * for one contract — the defect class this family of pins exists to close. + * + * ## What this deliberately does NOT emit, each because the driver does not + * + * - **A `multiple: true` field.** `createColumn` short-circuits on the flag and + * returns before both the nullability line and this one, so a multi-value + * column carries no DEFAULT on the platform either. + * - **An option-level `default: true`** on a `select`. `applyDeclaredColumnDefault` + * states at length why that stays out of DDL (one resolver owns the precedence; + * the `multiple` shape has no scalar DDL form; a retrofit would divide + * deployments silently) — ⛔ do not restate the reasoning here, read it there. + * - **A non-finite number**, and any `typeof` a parsed config cannot hold at all + * (`symbol`, `function`). `Infinity` and `NaN` have no literal that round-trips + * through a numeric column; named rather than left to the renderer so the + * omission is a decision and not a malformed statement. + */ +type DeclaredColumnDefault = + | { kind: 'none' } + | { kind: 'now' } + | { kind: 'now-date' } + | { kind: 'now-time' } + | { kind: 'literal'; value: string | number | bigint | boolean }; + +function declaredColumnDefault(field: unknown, type: string): DeclaredColumnDefault { + const declaring = field as { defaultValue?: unknown; multiple?: unknown } | undefined; + if (declaring?.multiple) return { kind: 'none' }; + const dv = declaring?.defaultValue; + if (dv === undefined || dv === null) return { kind: 'none' }; + if (isNowDefaultToken(dv)) { + if (type === 'date') return { kind: 'now-date' }; + if (type === 'time') return { kind: 'now-time' }; + return { kind: 'now' }; + } + if (isRuntimeDefaultToken(dv)) return { kind: 'none' }; + if (typeof dv === 'object') return { kind: 'none' }; + if (typeof dv === 'number' && !Number.isFinite(dv)) return { kind: 'none' }; + if (typeof dv !== 'string' && typeof dv !== 'number' && typeof dv !== 'bigint' && typeof dv !== 'boolean') { + return { kind: 'none' }; + } + return { kind: 'literal', value: dv }; +} + +/** + * {@link declaredColumnDefault} as the `--format sql` emitter spells it: the + * ` DEFAULT …` tail of a column definition, or `''`. + * + * PostgreSQL, and only PostgreSQL — the same claim `generateMigrationSql`'s own + * header already makes for `JSONB` / `TIMESTAMPTZ` / `CURRENT_TIMESTAMP`. The + * three `now-*` spellings are `SqlDriver.nowColumnDefault`'s Postgres arm, and + * `generate-declared-column-default.pin.test.ts` recomputes them from the + * driver's own builder rather than trusting these literals. + * + * ⭐ EVERY literal is quoted — number and boolean included — and that is a + * measurement, not a style choice. knex binds every default it is given as a + * quoted literal (`default '42'`, `default '9.99'`, `default '1'` for `true`), + * which is the form the driver's own tables therefore carry, and the two + * spellings do NOT collapse: PostgreSQL records an unquoted `DEFAULT 42` on a + * `DECIMAL(18,2)` column as `42` and the quoted one as `'42'::numeric`. Same + * value, permanently different default TEXT — the row #15521 already paid for + * once, where a schema differ comparing default text reported the audit pair + * forever. Booleans are the case where quoting looks wrong and is not: `'1'` and + * `'0'` are what knex emits, PostgreSQL normalises both to `true` / `false`, and + * a SQLite table built by the driver carries the quoted form verbatim — so one + * rule agrees with the driver on both dialects where two rules agree on one. + * + * The quote itself is doubled, SQL's own escape and the form + * `information_schema.column_default` reads back for the driver's own column. + */ +function columnDefaultSql(field: unknown, type: string): string { + const declared = declaredColumnDefault(field, type); + switch (declared.kind) { + case 'none': return ''; + case 'now': return ' DEFAULT CURRENT_TIMESTAMP'; + case 'now-date': return " DEFAULT (timezone('utc', now())::date)"; + case 'now-time': return " DEFAULT (timezone('utc', now())::time(3))"; + case 'literal': { + const bound = typeof declared.value === 'boolean' + ? (declared.value ? '1' : '0') + : String(declared.value); + return ` DEFAULT '${bound.replace(/'/g, "''")}'`; + } + } +} + +/** + * {@link declaredColumnDefault} as the `--format ts` emitter spells it: the + * `.defaultTo(…)` link of the knex column chain, or `''`. + * + * Appended AFTER the nullability call because that is the order + * `SqlDriver.createColumn` applies them in — `col.notNullable()`, then + * `applyDeclaredColumnDefault`. knex builds both as independent modifiers on one + * `ColumnBuilder`, so there is no coupling to reconcile on this line; the + * coupling the audit-column block below records belongs to + * `table.timestamps(true, true)` alone — that HELPER compiles its second + * argument to `.notNullable().defaultTo(…)` and offers no spelling for one + * without the other, which is why those two columns are written out longhand. + * ⛔ Do not read that note as a constraint here. + * + * The two expression defaults are `db.raw` for the reason + * `generate-declared-unique-index.pin.test.ts` already measured one property + * over: knex's builder has no expression spelling, and `db.raw` is the seam the + * driver itself uses. They carry the PostgreSQL arm — this file's declared claim + * — so a `date`/`time` field defaulted to `NOW()` is the one emitted line that + * is not dialect-portable, and it is the line whose portable spelling + * (`db.fn.now()`) is measurably WRONG on the dialect the file does claim. + * + * A string is emitted through `JSON.stringify`, a valid TypeScript expression + * for every string, which escapes the quote, the backslash and the newline an + * authored default may legally contain. This emitter single-quotes IDENTIFIERS + * it has validated; a default VALUE is neither. + */ +function columnDefaultTs(field: unknown, type: string): string { + const declared = declaredColumnDefault(field, type); + switch (declared.kind) { + case 'none': return ''; + case 'now': return '.defaultTo(db.fn.now())'; + case 'now-date': return '.defaultTo(db.raw("(timezone(\'utc\', now())::date)"))'; + case 'now-time': return '.defaultTo(db.raw("(timezone(\'utc\', now())::time(3))"))'; + case 'literal': + return `.defaultTo(${typeof declared.value === 'string' ? JSON.stringify(declared.value) : String(declared.value)})`; + } +} + /** * The widest `varchar(n)` any dialect this platform speaks will declare — * `SqlDriver.MAX_VARCHAR_CHARS`, whose own comment records the measurement @@ -1922,8 +2085,9 @@ export function generateMigrationSql(config: Record): string { // as DDL that cannot run. Filled by the loop below, read after it. const emittedColumns = new Set(['id']); for (const [fieldName, fieldDef] of Object.entries(fields)) { + const fType = String(fieldDef.type || 'text'); const sqlType = fieldTypeToSql( - String(fieldDef.type || 'text'), + fType, !!fieldDef.multiple, fieldDef.maxLength, keyColumns.has(fieldName), @@ -1936,7 +2100,12 @@ export function generateMigrationSql(config: Record): string { // storage constraint, never from `required`. See {@link declaredNotNull} // for the driver's own recorded reason; ⛔ do not restate it here. const notNull = declaredNotNull(fieldDef) ? ' NOT NULL' : ''; - fieldLines.push(` "${fieldName}" ${sqlType}${notNull}`); + // [#16294 cause 3] The column DEFAULT the field's `defaultValue` calls + // for. See {@link declaredColumnDefault} for the driver's own four cases + // and for the three it deliberately does not emit; ⛔ do not restate them + // here. Ordered after NOT NULL to match `createColumn`'s own sequence. + const columnDefault = columnDefaultSql(fieldDef, fType); + fieldLines.push(` "${fieldName}" ${sqlType}${notNull}${columnDefault}`); emittedColumns.add(fieldName); } @@ -2263,7 +2432,9 @@ export function generateMigrationTs(config: Record): string { // #14828 — the virtual answer: emit nothing at all for this field. if (colMethod === null) continue; - lines.push(` ${colMethod}${required};`); + // [#16294 cause 3] The same verdict the sql format above renders, in + // knex's spelling. See {@link columnDefaultTs}. + lines.push(` ${colMethod}${required}${columnDefaultTs(fieldDef, fType)};`); emittedColumns.add(fieldName); }