@@ -18,7 +18,13 @@ import type { FieldType } from '@objectstack/spec/data';
1818// `computeTenantField` — are spelled here in the driver's own terms ON TOP of
1919// these, so the part that can be shared is shared and only the part that
2020// genuinely lives on `driver-sql` is mirrored.
21- import { isTenancyDisabled , isUniqueDeclared , numericColumnFor } from '@objectstack/spec/data' ;
21+ import {
22+ isNowDefaultToken ,
23+ isRuntimeDefaultToken ,
24+ isTenancyDisabled ,
25+ isUniqueDeclared ,
26+ numericColumnFor ,
27+ } from '@objectstack/spec/data' ;
2228import { printHeader , printSuccess , printError , printInfo , printStep , createTimer , isReportedError , CLI_ALIAS } from '../utils/format.js' ;
2329import { metadataFileName } from '../utils/metadata-file-name.js' ;
2430import { findEmissionParseFailures } from '../utils/emitted-source-parses.js' ;
@@ -1336,6 +1342,160 @@ function declaredNotNull(field: unknown): boolean {
13361342 return ( field as { storage ?: { notNull ?: boolean } } | undefined ) ?. storage ?. notNull === true ;
13371343}
13381344
1345+ /**
1346+ * The physical column DEFAULT a field's `defaultValue` calls for — or, exactly
1347+ * as deliberately, none at all.
1348+ *
1349+ * `SqlDriver.applyDeclaredColumnDefault` is the single place a `defaultValue`
1350+ * becomes DDL on the platform side, and this is its decision, format-free. Both
1351+ * emitters below render THIS verdict, so the two formats cannot answer the
1352+ * question differently from each other — which is the shape #16294 measured:
1353+ * both generators agreed with each other and disagreed with the platform.
1354+ *
1355+ * The driver's four cases, in its own order:
1356+ *
1357+ * 1. **`'NOW()'`** — the one runtime token with a database counterpart,
1358+ * translated to the driver-native canonical default (`nowColumnDefault`).
1359+ * That translation is TYPE-branched, which is why the three `now-*`
1360+ * verdicts are distinguished here rather than collapsed: a bare
1361+ * `CURRENT_TIMESTAMP` in a `date` column resolves the calendar day in the
1362+ * SERVER's timezone (a UTC-12 server records YESTERDAY, #4022) and in a
1363+ * `time` column resolves it in the server's or the session's clock (#3994).
1364+ * 2. **Any other runtime token** (`current_user`, and whatever the spec adds
1365+ * to `DEFAULT_VALUE_TOKENS` later) — resolved by the ENGINE at insert time
1366+ * against the request context, with NO database counterpart, so nothing is
1367+ * emitted. That omission is the contract: the engine deliberately leaves a
1368+ * `current_user` field UNSET when there is no authenticated user, and a
1369+ * column DEFAULT silently overrode that decision by writing the literal
1370+ * string `'current_user'` into `lookup('sys_user')` columns (#4560).
1371+ * 3. **Objects** — Expression envelopes (`{ dialect, source }`), evaluated
1372+ * app-side; never a column DEFAULT.
1373+ * 4. **Everything else** — a real literal, emitted verbatim.
1374+ *
1375+ * ⛔ The two token predicates are IMPORTED, never re-spelled: `isNowDefaultToken`
1376+ * is case- and whitespace-tolerant, and `isRuntimeDefaultToken` is what makes a
1377+ * token added tomorrow degrade to "no column default" instead of leaking its own
1378+ * spelling into the database. A transcription here would be a second vocabulary
1379+ * for one contract — the defect class this family of pins exists to close.
1380+ *
1381+ * ## What this deliberately does NOT emit, each because the driver does not
1382+ *
1383+ * - **A `multiple: true` field.** `createColumn` short-circuits on the flag and
1384+ * returns before both the nullability line and this one, so a multi-value
1385+ * column carries no DEFAULT on the platform either.
1386+ * - **An option-level `default: true`** on a `select`. `applyDeclaredColumnDefault`
1387+ * states at length why that stays out of DDL (one resolver owns the precedence;
1388+ * the `multiple` shape has no scalar DDL form; a retrofit would divide
1389+ * deployments silently) — ⛔ do not restate the reasoning here, read it there.
1390+ * - **A non-finite number**, and any `typeof` a parsed config cannot hold at all
1391+ * (`symbol`, `function`). `Infinity` and `NaN` have no literal that round-trips
1392+ * through a numeric column; named rather than left to the renderer so the
1393+ * omission is a decision and not a malformed statement.
1394+ */
1395+ type DeclaredColumnDefault =
1396+ | { kind : 'none' }
1397+ | { kind : 'now' }
1398+ | { kind : 'now-date' }
1399+ | { kind : 'now-time' }
1400+ | { kind : 'literal' ; value : string | number | bigint | boolean } ;
1401+
1402+ function declaredColumnDefault ( field : unknown , type : string ) : DeclaredColumnDefault {
1403+ const declaring = field as { defaultValue ?: unknown ; multiple ?: unknown } | undefined ;
1404+ if ( declaring ?. multiple ) return { kind : 'none' } ;
1405+ const dv = declaring ?. defaultValue ;
1406+ if ( dv === undefined || dv === null ) return { kind : 'none' } ;
1407+ if ( isNowDefaultToken ( dv ) ) {
1408+ if ( type === 'date' ) return { kind : 'now-date' } ;
1409+ if ( type === 'time' ) return { kind : 'now-time' } ;
1410+ return { kind : 'now' } ;
1411+ }
1412+ if ( isRuntimeDefaultToken ( dv ) ) return { kind : 'none' } ;
1413+ if ( typeof dv === 'object' ) return { kind : 'none' } ;
1414+ if ( typeof dv === 'number' && ! Number . isFinite ( dv ) ) return { kind : 'none' } ;
1415+ if ( typeof dv !== 'string' && typeof dv !== 'number' && typeof dv !== 'bigint' && typeof dv !== 'boolean' ) {
1416+ return { kind : 'none' } ;
1417+ }
1418+ return { kind : 'literal' , value : dv } ;
1419+ }
1420+
1421+ /**
1422+ * {@link declaredColumnDefault } as the `--format sql` emitter spells it: the
1423+ * ` DEFAULT …` tail of a column definition, or `''`.
1424+ *
1425+ * PostgreSQL, and only PostgreSQL — the same claim `generateMigrationSql`'s own
1426+ * header already makes for `JSONB` / `TIMESTAMPTZ` / `CURRENT_TIMESTAMP`. The
1427+ * three `now-*` spellings are `SqlDriver.nowColumnDefault`'s Postgres arm, and
1428+ * `generate-declared-column-default.pin.test.ts` recomputes them from the
1429+ * driver's own builder rather than trusting these literals.
1430+ *
1431+ * ⭐ A NUMBER is quoted too, and that is a measurement rather than a style
1432+ * choice. knex quotes every bound default (`default '42'`, `default '9.99'`),
1433+ * and PostgreSQL keeps the two forms textually APART in
1434+ * `information_schema.column_default`: an unquoted `DEFAULT 42` on a
1435+ * `DECIMAL(18,2)` column is recorded as `42`, the quoted one as `'42'::numeric`
1436+ * — which is what the driver's own column carries. Same value, permanently
1437+ * different default TEXT, and #15521 already paid for that exact row once (a
1438+ * schema differ comparing default text reported the audit pair forever). A
1439+ * BOOLEAN is left bare: knex binds it as `'1'`, PostgreSQL records `true` for
1440+ * both spellings, so the catalog agrees either way and the readable one wins in
1441+ * a file a human opens.
1442+ *
1443+ * A string literal is single-quoted with `'` doubled — SQL's own escape, and the
1444+ * form `information_schema.column_default` reads back for the driver's own.
1445+ */
1446+ function columnDefaultSql ( field : unknown , type : string ) : string {
1447+ const declared = declaredColumnDefault ( field , type ) ;
1448+ switch ( declared . kind ) {
1449+ case 'none' : return '' ;
1450+ case 'now' : return ' DEFAULT CURRENT_TIMESTAMP' ;
1451+ case 'now-date' : return " DEFAULT (timezone('utc', now())::date)" ;
1452+ case 'now-time' : return " DEFAULT (timezone('utc', now())::time(3))" ;
1453+ case 'literal' :
1454+ return typeof declared . value === 'boolean'
1455+ ? ` DEFAULT ${ String ( declared . value ) } `
1456+ : ` DEFAULT '${ String ( declared . value ) . replace ( / ' / g, "''" ) } '` ;
1457+ }
1458+ }
1459+
1460+ /**
1461+ * {@link declaredColumnDefault } as the `--format ts` emitter spells it: the
1462+ * `.defaultTo(…)` link of the knex column chain, or `''`.
1463+ *
1464+ * Appended AFTER the nullability call because that is the order
1465+ * `SqlDriver.createColumn` applies them in — `col.notNullable()`, then
1466+ * `applyDeclaredColumnDefault`. knex builds both as independent modifiers on one
1467+ * `ColumnBuilder`, so there is no coupling to reconcile on this line; the
1468+ * coupling the audit-column block below records belongs to
1469+ * `table.timestamps(true, true)` alone — that HELPER compiles its second
1470+ * argument to `.notNullable().defaultTo(…)` and offers no spelling for one
1471+ * without the other, which is why those two columns are written out longhand.
1472+ * ⛔ Do not read that note as a constraint here.
1473+ *
1474+ * The two expression defaults are `db.raw` for the reason
1475+ * `generate-declared-unique-index.pin.test.ts` already measured one property
1476+ * over: knex's builder has no expression spelling, and `db.raw` is the seam the
1477+ * driver itself uses. They carry the PostgreSQL arm — this file's declared claim
1478+ * — so a `date`/`time` field defaulted to `NOW()` is the one emitted line that
1479+ * is not dialect-portable, and it is the line whose portable spelling
1480+ * (`db.fn.now()`) is measurably WRONG on the dialect the file does claim.
1481+ *
1482+ * A string is emitted through `JSON.stringify`, a valid TypeScript expression
1483+ * for every string, which escapes the quote, the backslash and the newline an
1484+ * authored default may legally contain. This emitter single-quotes IDENTIFIERS
1485+ * it has validated; a default VALUE is neither.
1486+ */
1487+ function columnDefaultTs ( field : unknown , type : string ) : string {
1488+ const declared = declaredColumnDefault ( field , type ) ;
1489+ switch ( declared . kind ) {
1490+ case 'none' : return '' ;
1491+ case 'now' : return '.defaultTo(db.fn.now())' ;
1492+ case 'now-date' : return '.defaultTo(db.raw("(timezone(\'utc\', now())::date)"))' ;
1493+ case 'now-time' : return '.defaultTo(db.raw("(timezone(\'utc\', now())::time(3))"))' ;
1494+ case 'literal' :
1495+ return `.defaultTo(${ typeof declared . value === 'string' ? JSON . stringify ( declared . value ) : String ( declared . value ) } )` ;
1496+ }
1497+ }
1498+
13391499/**
13401500 * The widest `varchar(n)` any dialect this platform speaks will declare —
13411501 * `SqlDriver.MAX_VARCHAR_CHARS`, whose own comment records the measurement
@@ -1922,8 +2082,9 @@ export function generateMigrationSql(config: Record<string, unknown>): string {
19222082 // as DDL that cannot run. Filled by the loop below, read after it.
19232083 const emittedColumns = new Set < string > ( [ 'id' ] ) ;
19242084 for ( const [ fieldName , fieldDef ] of Object . entries ( fields ) ) {
2085+ const fType = String ( fieldDef . type || 'text' ) ;
19252086 const sqlType = fieldTypeToSql (
1926- String ( fieldDef . type || 'text' ) ,
2087+ fType ,
19272088 ! ! fieldDef . multiple ,
19282089 fieldDef . maxLength ,
19292090 keyColumns . has ( fieldName ) ,
@@ -1936,7 +2097,12 @@ export function generateMigrationSql(config: Record<string, unknown>): string {
19362097 // storage constraint, never from `required`. See {@link declaredNotNull}
19372098 // for the driver's own recorded reason; ⛔ do not restate it here.
19382099 const notNull = declaredNotNull ( fieldDef ) ? ' NOT NULL' : '' ;
1939- fieldLines . push ( ` "${ fieldName } " ${ sqlType } ${ notNull } ` ) ;
2100+ // [#16294 cause 3] The column DEFAULT the field's `defaultValue` calls
2101+ // for. See {@link declaredColumnDefault } for the driver's own four cases
2102+ // and for the three it deliberately does not emit; ⛔ do not restate them
2103+ // here. Ordered after NOT NULL to match `createColumn`'s own sequence.
2104+ const columnDefault = columnDefaultSql ( fieldDef , fType ) ;
2105+ fieldLines . push ( ` "${ fieldName } " ${ sqlType } ${ notNull } ${ columnDefault } ` ) ;
19402106 emittedColumns . add ( fieldName ) ;
19412107 }
19422108
@@ -2263,7 +2429,9 @@ export function generateMigrationTs(config: Record<string, unknown>): string {
22632429 // #14828 — the virtual answer: emit nothing at all for this field.
22642430 if ( colMethod === null ) continue ;
22652431
2266- lines . push ( ` ${ colMethod } ${ required } ;` ) ;
2432+ // [#16294 cause 3] The same verdict the sql format above renders, in
2433+ // knex's spelling. See {@link columnDefaultTs}.
2434+ lines . push ( ` ${ colMethod } ${ required } ${ columnDefaultTs ( fieldDef , fType ) } ;` ) ;
22672435 emittedColumns . add ( fieldName ) ;
22682436 }
22692437
0 commit comments