diff --git a/packages/cli/src/commands/generate-builtin-id-column.pin.test.ts b/packages/cli/src/commands/generate-builtin-id-column.pin.test.ts index 5e4175154ea..7c2f504d167 100644 --- a/packages/cli/src/commands/generate-builtin-id-column.pin.test.ts +++ b/packages/cli/src/commands/generate-builtin-id-column.pin.test.ts @@ -89,6 +89,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { readSqlDriverSource } from '../../../../scripts/sql-driver-split-source.mjs'; import { describe, expect, it } from 'vitest'; import { generateMigrationSql, generateMigrationTs } from './generate.js'; @@ -99,7 +100,9 @@ const GENERATE_SOURCE = fs.readFileSync(GENERATE_TS, 'utf8'); /** The authority, read where it lives — never transcribed here. */ const DRIVER_SQL_SRC = path.resolve(HERE, '../../../drivers/driver-sql/src'); -const SQL_DRIVER_SOURCE = fs.readFileSync(path.join(DRIVER_SQL_SRC, 'sql-driver.ts'), 'utf8'); +// `SqlDriver` as one text: `sql-driver.ts` with each codemod stub's body read +// from the facet module it moved to, and the leaf ahead of the class (#30147). +const SQL_DRIVER_SOURCE = readSqlDriverSource(DRIVER_SQL_SRC).text; /** The one line the driver emits for a managed table's primary key. */ const DRIVER_ID_COLUMN = "table.string('id').primary();"; diff --git a/packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts b/packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts index 087c335ac9e..71b734f1aea 100644 --- a/packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts +++ b/packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts @@ -136,6 +136,7 @@ import { REFERENCE_VALUE_TYPES, STRUCTURED_JSON_TYPES, } from '@objectstack/spec/data'; +import { readSqlDriverSource } from '../../../../scripts/sql-driver-split-source.mjs'; import { describe, expect, it } from 'vitest'; import { generateMigrationSql, generateMigrationTs, generateTypesFromConfig } from './generate.js'; @@ -318,7 +319,9 @@ describe('generate.ts field-type vocabularies (#13871)', () => { /** `packages/drivers/driver-sql/src` — declared for `@objectstack/cli#test` in `turbo.json`. */ const DRIVER_SQL_SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../drivers/driver-sql/src'); -const SQL_DRIVER_SOURCE = fs.readFileSync(path.join(DRIVER_SQL_SRC, 'sql-driver.ts'), 'utf8'); +// `SqlDriver` as one text: `sql-driver.ts` with each codemod stub's body read +// from the facet module it moved to, and the leaf ahead of the class (#30147). +const SQL_DRIVER_SOURCE = readSqlDriverSource(DRIVER_SQL_SRC).text; const SCHEMA_DRIFT_SOURCE = fs.readFileSync(path.join(DRIVER_SQL_SRC, 'schema-drift.ts'), 'utf8'); /** diff --git a/packages/cli/src/commands/generate-file-reference-width.pin.test.ts b/packages/cli/src/commands/generate-file-reference-width.pin.test.ts index 8c0126b70a3..60a15747de7 100644 --- a/packages/cli/src/commands/generate-file-reference-width.pin.test.ts +++ b/packages/cli/src/commands/generate-file-reference-width.pin.test.ts @@ -67,13 +67,16 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES } from '@objectstack/spec/data'; +import { readSqlDriverSource } from '../../../../scripts/sql-driver-split-source.mjs'; import { describe, expect, it } from 'vitest'; import { generateMigrationSql, generateMigrationTs } from './generate.js'; /** `packages/drivers/driver-sql/src` — declared for `@objectstack/cli#test` in `turbo.json`. */ const DRIVER_SQL_SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../drivers/driver-sql/src'); -const SQL_DRIVER_SOURCE = fs.readFileSync(path.join(DRIVER_SQL_SRC, 'sql-driver.ts'), 'utf8'); +// `SqlDriver` as one text: `sql-driver.ts` with each codemod stub's body read +// from the facet module it moved to, and the leaf ahead of the class (#30147). +const SQL_DRIVER_SOURCE = readSqlDriverSource(DRIVER_SQL_SRC).text; /** The control the generator documents, plus the arm the family used to ride. */ const CONTROL_TYPES = ['text', ...REFERENCE_VALUE_TYPES, 'autonumber'] as const; diff --git a/packages/cli/src/commands/generate-multiple-json-column.pin.test.ts b/packages/cli/src/commands/generate-multiple-json-column.pin.test.ts index f9d08e8a456..451fed81ae6 100644 --- a/packages/cli/src/commands/generate-multiple-json-column.pin.test.ts +++ b/packages/cli/src/commands/generate-multiple-json-column.pin.test.ts @@ -88,6 +88,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { isMultiValueField, MULTI_CAPABLE_TYPES, MULTI_OPTION_TYPES } from '@objectstack/spec/data'; +import { readSqlDriverSource } from '../../../../scripts/sql-driver-split-source.mjs'; import { describe, expect, it } from 'vitest'; import { @@ -377,7 +378,8 @@ describe('#14829 — `multiple: true` is one answer across all three surfaces', // this rule, these fail and whoever moved it re-derives the generators. it('driver-sql `createColumn` still decides `multiple` BEFORE the type switch', () => { - const source = fs.readFileSync(path.join(DRIVER_SQL_SRC, 'sql-driver.ts'), 'utf8'); + // `SqlDriver` as one text, each codemod stub's body read where it moved (#30147). + const source = readSqlDriverSource(DRIVER_SQL_SRC).text; // Non-vacuity: the file was really read, and the two landmarks really found. expect(source.length).toBeGreaterThan(10_000); const start = source.indexOf('protected createColumn('); diff --git a/packages/cli/src/commands/generate-string-family-width.pin.test.ts b/packages/cli/src/commands/generate-string-family-width.pin.test.ts index d3c1ea9abe7..f3e7540d332 100644 --- a/packages/cli/src/commands/generate-string-family-width.pin.test.ts +++ b/packages/cli/src/commands/generate-string-family-width.pin.test.ts @@ -251,6 +251,7 @@ import { MULTI_OPTION_TYPES, STRUCTURED_JSON_TYPES, } from '@objectstack/spec/data'; +import { readSqlDriverSource } from '../../../../scripts/sql-driver-split-source.mjs'; import { afterAll, describe, expect, it } from 'vitest'; import { generateMigrationSql, generateMigrationTs } from './generate.js'; @@ -260,7 +261,9 @@ const GENERATE_SOURCE = fs.readFileSync(path.resolve(HERE, 'generate.ts'), 'utf8 /** The authority, read where it lives — never transcribed here. */ const DRIVER_SQL_SRC = path.resolve(HERE, '../../../drivers/driver-sql/src'); -const SQL_DRIVER_SOURCE = fs.readFileSync(path.join(DRIVER_SQL_SRC, 'sql-driver.ts'), 'utf8'); +// `SqlDriver` as one text: `sql-driver.ts` with each codemod stub's body read +// from the facet module it moved to, and the leaf ahead of the class (#30147). +const SQL_DRIVER_SOURCE = readSqlDriverSource(DRIVER_SQL_SRC).text; const SCHEMA_DRIFT_SOURCE = fs.readFileSync(path.join(DRIVER_SQL_SRC, 'schema-drift.ts'), 'utf8'); /** The authority for which types exist at all. Imported, never listed here. */ diff --git a/packages/drivers/driver-sql/src/sql-driver-column-creation.ts b/packages/drivers/driver-sql/src/sql-driver-column-creation.ts index 227e45d2dec..cd5a3a9146d 100644 --- a/packages/drivers/driver-sql/src/sql-driver-column-creation.ts +++ b/packages/drivers/driver-sql/src/sql-driver-column-creation.ts @@ -2,14 +2,15 @@ /** * [#29520 codemod] The "Column creation helper" section of `SqlDriver`: the - * bodies of `declaredVarcharLength`, `keyableTextLength`, - * `explainUnkeyableTextColumn`, `hashShadowColumnFor`, + * bodies of `createAuditTimestampColumn`, `declaredVarcharLength`, + * `keyableTextLength`, `explainUnkeyableTextColumn`, `hashShadowColumnFor`, * `createHashShadowUniqueIndex`, `hashShadowColumnState`, * `dropOrphanedHashShadowColumn`, `explainHashShadowDuplicate`, * `decorateHashShadowDuplicate`, `hashShadowSourceColumns`, - * `varcharPackLength`, `rowWidthProfile`, `schemaBytesPerChar`, - * `explainRowSizeOverflow`, `rethrowWithRowSizeExplanation`, `jsonColumn` moved - * out of `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`. + * `varcharPackLength`, `varcharColumnChars`, `rowWidthProfile`, + * `schemaBytesPerChar`, `explainRowSizeOverflow`, + * `rethrowWithRowSizeExplanation`, `jsonColumn`, `createColumn` moved out of + * `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`. * * A MOVE, not a redesign. {@link ColumnCreationFacet} is a stateless facet of `SqlDriver` * (ruling 6624345766): each static method of it is the body of the `SqlDriver` @@ -40,10 +41,14 @@ * this module. */ +import { FILE_REFERENCE_TYPES, numericColumnFor } from '@objectstack/spec/data'; import { GLOBAL_TENANT, canonicalIndexKey, hashShadowColumnFor, parseHashShadowKeyParts } from './schema-drift.js'; import type { HashShadowKeyPart } from './schema-drift.js'; import type { Knex } from 'knex'; -import { AUDIT_TIMESTAMP_COLUMNS } from './sql-driver-top-level.js'; +import { + AUDIT_TIMESTAMP_COLUMNS, JSON_COLUMN_TYPES, MEDIA_ID_VARCHAR_CHARS, MYSQL_DATETIME_PRECISION, declaresColumnNotNull, + isDeclaredFieldType, isMultiValuedColumn, refuseRejectedReferenceAlias, refuseUndeclaredFieldType, +} from './sql-driver-top-level.js'; import type { RowWidthContribution } from './sql-driver-top-level.js'; import { SqlDriverStatics as SqlDriver } from './sql-driver.js'; import type { SqlDriver as SqlDriverClass } from './sql-driver.js'; @@ -56,17 +61,22 @@ import type { SqlDriver as SqlDriverClass } from './sql-driver.js'; * assigns. */ export interface ColumnCreationHost { + applyDeclaredColumnDefault: SqlDriverClass['applyDeclaredColumnDefault']; declaredFieldsFor: SqlDriverClass['declaredFieldsFor']; + declaredVarcharLength: SqlDriverClass['declaredVarcharLength']; explainHashShadowDuplicate: SqlDriverClass['explainHashShadowDuplicate']; explainRowSizeOverflow: SqlDriverClass['explainRowSizeOverflow']; hashShadowColumnState: SqlDriverClass['hashShadowColumnState']; hashShadowSourceColumns: SqlDriverClass['hashShadowSourceColumns']; readonly isMysql: SqlDriverClass['isMysql']; readonly isSqlite: SqlDriverClass['isSqlite']; + jsonColumn: SqlDriverClass['jsonColumn']; keyableTextLength: SqlDriverClass['keyableTextLength']; readonly knex: SqlDriverClass['knex']; logDurabilityFailure: SqlDriverClass['logDurabilityFailure']; readonly logger: SqlDriverClass['logger']; + mediaColumnIsJson: SqlDriverClass['mediaColumnIsJson']; + nowColumnDefault: SqlDriverClass['nowColumnDefault']; rowWidthProfile: SqlDriverClass['rowWidthProfile']; schemaBytesPerChar: SqlDriverClass['schemaBytesPerChar']; varcharColumnChars: SqlDriverClass['varcharColumnChars']; @@ -74,6 +84,19 @@ export interface ColumnCreationHost { /** The bodies of the "Column creation helper" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ export class ColumnCreationFacet { + /** The body of `SqlDriver#createAuditTimestampColumn`; the method's docblock there is its contract. */ + static createAuditTimestampColumn(this: Pick, table: Knex.CreateTableBuilder, name: string): void { + if (this.isMysql) { + table.datetime(name, { precision: MYSQL_DATETIME_PRECISION }).defaultTo(this.nowColumnDefault('datetime')); + return; + } + if (this.isSqlite) { + table.timestamp(name).defaultTo(this.nowColumnDefault('datetime')); + return; + } + table.timestamp(name).defaultTo(this.knex.fn.now()); + } + /** The body of `SqlDriver#declaredVarcharLength`; the method's docblock there is its contract. */ static declaredVarcharLength(field: any): number | null { const declared = (field as { maxLength?: unknown }).maxLength; @@ -504,6 +527,74 @@ export class ColumnCreationFacet { return payload + (payload < 256 ? 1 : 2); } + /** The body of `SqlDriver#varcharColumnChars`; the method's docblock there is its contract. */ + static varcharColumnChars(this: Pick, field: any, keyed?: { unique: boolean }): number | null { + // Multi-value is decided before the type switch in `createColumn` — a JSON + // column, whatever the element type would have been. [#17469] The question + // is {@link isMultiValuedColumn}'s, exactly as it is there, so this mirror + // cannot answer a different one. + const type = field?.type || 'string'; + if (isMultiValuedColumn(String(type), field)) return null; + switch (type) { + case 'string': + case 'email': + case 'url': + case 'phone': + case 'password': + return this.declaredVarcharLength(field); + case 'text': + case 'textarea': + case 'html': + case 'markdown': + case 'richtext': + case 'code': + // #11875 — moved out of the catch-all together with `createColumn`'s + // text-family case: the write seam now enforces their declared bound. + case 'signature': + case 'qrcode': + return keyed ? this.keyableTextLength(field) : null; + // Virtual — `createColumn` returns without emitting anything. + case 'formula': + return null; + // The non-string primitives: INTEGER / REAL / DECIMAL / BOOLEAN / DATE / + // DATETIME / TIME columns. None of them is a varchar, so none is sized + // from `maxLength` — which is the only question this mirror answers. + // ⚠️ Since #16318 the numeric members ARE sized, by `numericColumnFor`, + // but from the field's TYPE and not from any declaration; `null` here + // stays the correct answer to the question actually asked. + case 'integer': + case 'int': + case 'float': + case 'number': + case 'currency': + case 'percent': + case 'rating': + case 'slider': + case 'progress': + case 'summary': + case 'boolean': + case 'toggle': + case 'date': + case 'datetime': + case 'time': + return null; + default: + // `createColumn`'s catch-all, spelled the same way so the two cannot + // disagree about which types are JSON: everything else is + // `table.string(name)` at knex's default width. + // + // ⚠️ The FILE family first, and by the same deployment question the + // emitter asks — this mirror is what the drift detector consults + // INSTEAD of restating the emitter's cases, so a mirror that answered + // `null` for a moved media column would tell the detector no varchar + // exists where the emitter had just built one. + if (FILE_REFERENCE_TYPES.has(type)) { + return this.mediaColumnIsJson() ? null : MEDIA_ID_VARCHAR_CHARS; + } + return JSON_COLUMN_TYPES.has(type) ? null : SqlDriver.DEFAULT_STRING_VARCHAR_CHARS; + } + } + /** The body of `SqlDriver#rowWidthProfile`; the method's docblock there is its contract. */ static rowWidthProfile(this: Pick, fields: Record | undefined, keyedColumns: ReadonlyMap, bytesPerChar: number): { columns: RowWidthContribution[]; totalBytes: number } { const builtin = new Set(['id', ...AUDIT_TIMESTAMP_COLUMNS]); @@ -617,4 +708,482 @@ export class ColumnCreationFacet { static jsonColumn(this: Pick, table: Knex.CreateTableBuilder, name: string): any { return this.isSqlite ? table.text(name) : table.json(name); } + + /** The body of `SqlDriver#createColumn`; the method's docblock there is its contract. */ + static createColumn(this: Pick, table: Knex.CreateTableBuilder, name: string, field: any, keyed?: { unique: boolean }, tenantColumn: boolean = false) { + // [#11567] Stated BEFORE the `multiple` short-circuit and before the type + // switch, because the spec's refusal is gated on neither: `FieldSchema` + // answers `unrecognized_keys` for `reference_to` on ANY field, whatever its + // type, and a multi-value lookup (a JSON column, returning immediately + // below) used to carry the key straight past this seam. One key, one + // answer, wherever it appears. + // + // `!== undefined` rather than `'reference_to' in field`: it refuses every + // value the key can actually carry — including the `null` and `''` the old + // truthy gate ignored — while staying immune to a producer that spreads an + // explicit `{ reference_to: undefined }`. Measured: `FieldSchema`'s own + // canonical output does NOT carry `reference_to` as an own key, so key + // presence would have been correct too; this is the narrower of two + // correct predicates. + if (field.reference_to !== undefined) refuseRejectedReferenceAlias(name); + + // [#17469] Was `if (field.multiple)`. The DDL writer and {@link isJsonField} + // are one source by the {@link JSON_COLUMN_TYPES} header's own contract + // ("so the two can't drift"), so the writer asks the same predicate the + // reader now asks. A field whose `multiple` the spec does not recognise on + // its type no longer gets a JSON column here — and `FieldSchema` refuses + // that declaration at the authoring entrance in the same ruling. + // + // [#17231] The short-circuit decides the column TYPE; it does not decide + // the column's CONSTRAINTS, and returning here used to mean it silently + // did. ADR-0113 P0 names this site verbatim — 「the physical constraint now + // keys off the explicitly-authored `storage.notNull` at that same + // `#createColumn` site」 — and carves out no field type: `storage.notNull`'s + // only declared exclusivity is `requiredWhen` (`FieldSchema.superRefine`), + // so `multiple: true` + `storage: { notNull: true }` is an authorable + // declaration this site was dropping on the floor. The differ, ADR-0113's + // other named consumer in this package, never had the gap — `fieldHasColumn` + // answers multi-value first and the nullability comparison then runs — so + // the platform reported DESTRUCTIVE `tighten_not_null` drift against tables + // it had itself just created, with no rows in them. + // + // ⚠️ NOT the drift ceremony this ADR routes around: `createColumn` runs on + // `CREATE TABLE` and on `ALTER TABLE ADD COLUMN`, so the column constrained + // here is always EMPTY — the same reason the string family's #11431 note + // below gives for narrowing a varchar here. Imposing `NOT NULL` over + // possibly-null data stays `tighten_not_null`, destructive, behind + // `os migrate apply --allow-destructive`, untouched. + // + // The column DEFAULT is deliberately still NOT emitted on this path: the + // multi-value shape has no scalar DDL form, and `os generate migration` + // skips it for the same reason (`declaredColumnDefault`), so the two + // producers already agree. + if (isMultiValuedColumn(String(field.type ?? ''), field)) { + const jsonCol = this.jsonColumn(table, name); + if (declaresColumnNotNull(field) || tenantColumn) jsonCol.notNullable(); + return; + } + + // [#16319] ⛔ Was `const type = field.type || 'string'`. See + // {@link refuseUndeclaredFieldType} for what that default cost and why the + // answer here is a refusal rather than a different guess. Asked after the + // multi-value short-circuit, exactly where the default stood — and a field + // with NO type is now refused rather than silently made a JSON column, + // which is this ruling's own direction: a type the spec never saw cannot + // carry a `multiple` the spec recognises. + if (!isDeclaredFieldType(field)) refuseUndeclaredFieldType(name); + const type: string = field.type; + let col: any; + switch (type) { + case 'string': + case 'email': + case 'url': + case 'phone': + case 'password': { + // #11431: the string family takes its declared `maxLength`, instead of + // knex's invented default of 255. + // + // The bound is the field's OWN — nothing is guessed. `schema-drift.ts` + // has always treated `varchar(field.maxLength)` as the EXPECTED + // physical shape of a bounded field (`widen_varchar` / + // `narrow_varchar` say so in as many words), so before this branch the + // differ and the emitter disagreed about every column the driver + // itself had just created. Measured on live MySQL 8.0.46 and Postgres + // 16 on the pre-fix tree: a `maxLength: 400` email, a `maxLength: 1024` + // url and a `maxLength: 20` phone all landed as `varchar(255)`, and the + // same table reported two `widen_varchar` (warning/safe) plus one + // `narrow_varchar` (error/DESTRUCTIVE) findings against itself, on a + // table with no rows in it. + // + // The write half is the reported defect. A 300-character value — + // legal under the declaration, and accepted by the record validator's + // own `max_length` check — was refused by BOTH enforcing dialects: + // MySQL `ER_DATA_TOO_LONG` under `STRICT_TRANS_TABLES`, Postgres + // `22001 value too long for type character varying(255)`. + // + // ⚠️ Both directions, and the narrowing one is NOT a destructive + // migration — a point worth stating because it reads like one: + // `createColumn` runs on `CREATE TABLE` and on `ALTER TABLE ADD + // COLUMN`, so the column it sizes is always EMPTY. Narrowing an + // EXISTING `varchar(255)` that holds rows stays exactly where it was + // before this change: the `narrow_varchar` drift op, category + // `destructive`, behind `os migrate apply --allow-destructive`. + // Nothing here plans an ALTER on an existing column, and the differ is + // untouched. What this removes is the permanent, self-inflicted drift + // report a fresh table used to raise against itself. + const declared = this.declaredVarcharLength(field); + col = declared === null ? table.text(name) : table.string(name, declared); + break; + } + case 'text': + case 'textarea': + case 'html': + case 'markdown': + // #11794: `richtext` and `code` join the text family, and membership is + // decided by a MEASURED test rather than by "this type's values look + // long". + // + // ## What makes an unbounded TEXT column correct for a type + // + // That the WRITE SEAM enforces the type's declared `maxLength` — the + // invariant the rest of this driver already rests on, stated in + // `schema-drift.ts` in as many words: "A TEXT column refuses nothing a + // `maxLength` allows … the bound is enforced at the write seam." So the + // question is not whether a value can be long, it is whether the + // declaration still binds once the column stops binding. + // + // objectql's record-validator applies its `max_length` / `min_length` + // branch to exactly the spec's BOUNDED_STRING_FIELD_TYPES — measured, + // not read off it: a `maxLength: 64` field of each member refuses a + // 100-character value with a field-named ADR-0112 envelope, before any + // column is reached. So membership here RESTORES the declared contract + // (any string, as `valueSchemaFor` says) instead of widening past it. + // + // `richtext` is the headline member: the spec groups `markdown` / `html` + // / `richtext` together as "Rich Content" (`field.zod.ts`) and two of + // the three already landed here — the third fell through to the + // catch-all's `table.string(name)`, knex's varchar(255), so an ordinary + // rich-text body over 255 characters was refused by both enforcing + // dialects while the same body in a `markdown` field on the same table + // was accepted. Measured at 1000 characters on live MySQL 8.0.46 + // (`ER_DATA_TOO_LONG` under `STRICT_TRANS_TABLES`) and Postgres 16 + // (`22001`). `code` is the same defect on the same evidence — a code + // editor's contents, refused identically on both dialects. + case 'richtext': + case 'code': + // #11875 (maintainer ruling 2026-08-25, option 1): `signature` and + // `qrcode` join under exactly the invariant above. #11794 measured them + // (same live refusal: a 1000-char data-URI landed varchar(255) and was + // refused `22001` on PG, `ER_DATA_TOO_LONG` on MySQL) and deliberately + // left them in the catch-all, because at that point NOTHING enforced + // their declared `maxLength` — an unbounded TEXT would have accepted + // values the declaration forbids. That gap is now closed at both seams: + // `FieldSchema` admits `maxLength` on them (BOUNDED_STRING_FIELD_TYPES) + // and the record-validator's `max_length` branch reads the same set, so + // a TEXT column here refuses nothing the declaration allows — the bound + // is enforced at the write seam, and the data-URI refusal goes away. + case 'signature': + case 'qrcode': { + // Commit d0e3a885b: a text-family column that some declared index KEYS ON is + // emitted as `varchar(maxLength)` rather than TEXT, whenever the field + // declared a bound this dialect can key on. + // + // MySQL refuses a TEXT/BLOB column in a key without a prefix length + // (`ER_BLOB_KEY_WITHOUT_LENGTH`), so an unbounded TEXT is not merely a + // slower key there — it is not a key at all: `CREATE TABLE` succeeds and + // the following `ALTER TABLE … ADD [UNIQUE] INDEX` fails, leaving the + // table on disk WITHOUT the constraint it declared. Measured on MySQL + // 8.0.46 before this branch existed: 36 of the 44 platform objects + // failed schema-sync that way, so an auth stack could not stand up its + // own schema on a driver `createDefaultDatasourceDriverFactory` maps + // `mysql`/`mysql2` onto. + // + // The bound is the field's OWN `maxLength` — nothing is invented here. + // `schema-drift.ts` already treats `varchar(field.maxLength)` as the + // expected physical shape of a bounded field (its `widen_varchar` / + // `narrow_varchar` ops say so in as many words); this is the emitter + // finally agreeing with the differ. There is no `Field.string` + // builder: none of `Field`'s keys is named `string`, `FieldType.options` + // omits it too, and `FieldSchema.safeParse({ type: 'string', … })` + // fails at `[type]` (#12593). What IS true of knex: its bare + // `table.string(name)` call (no length argument) is `varchar(255)`. + // This branch never calls it bare — `table.string(name, keyable)`, + // where `keyable` is the field's own declared `maxLength`, up to + // `MAX_KEYABLE_VARCHAR_CHARS` (768 chars, wider than knex's + // 255-char default). + // + // Applied on every dialect rather than under `isMysql`, deliberately: + // the alternative is one declaration with two enforcement answers, so + // the same app would refuse an over-length write on MySQL and accept it + // on Postgres. Dialect-divergent enforcement of one declared bound is + // the defect class this repo's conformance matrices exist to close, and + // a `varchar(n)` is exactly what the SQLite and Postgres columns land + // as for a field declared `Field.text({ maxLength: n })` on a keyed + // column — the nearest authorable spelling to this shape; there is no + // `Field.string` (#12593). + // + // ⚠️ Scope, both halves load-bearing: + // - KEYED only. A non-indexed `Field.text({ maxLength: 65000 })` stays + // TEXT: `varchar(65000)` on utf8mb4 is 260000 bytes and blows MySQL's + // 65535-byte ROW limit, which would turn a working table into an + // un-creatable one. Off-page TEXT has no such cost, and a column no + // index keys on has nothing to gain from a bound. + // - BOUNDED only. `maxLength` absent, or wider than a key part can be + // ({@link keyableTextLength}), leaves the column TEXT — see + // {@link explainUnkeyableTextColumn} for what happens next, which is + // a named refusal and never a silently weaker constraint. + const keyable = keyed ? this.keyableTextLength(field) : null; + col = keyable === null ? table.text(name) : table.string(name, keyable); + break; + } + case 'integer': + case 'int': + col = table.integer(name); + break; + // `float` is a DRIVER-SIDE ALIAS, not a `FieldType`: there is no + // `Field.float` builder and `NUMERIC_VALUE_TYPES` does not carry it, so + // #16318's table has no opinion about it and it keeps the column it has + // always had. + case 'float': + col = table.float(name); + break; + // [#16318] The seven members of `NUMERIC_VALUE_TYPES` take the physical + // representation `packages/spec` states for them ({@link + // numericColumnFor}) — the same table both `os generate migration` + // formats read, so one declaration can no longer produce three different + // columns (measured on live PostgreSQL 16.13: this arm's `real`, the sql + // format's `numeric(18,2)`/`numeric(5,2)`, and the typescript format's + // `numeric(8,2)`). The spec module carries the measurements, the ruling + // and the residual bound; ⛔ do not restate its numbers here. + // + // What this arm still owes its reader is the SQLite half, because that is + // why three of these types were put in a float arm in the first place: + // + // `rating`/`slider`/`progress` are authored as numeric scalars (a star + // count, a slider position, a percent-of-completion). Without an explicit + // case they fell to `default → table.string`, giving the column TEXT + // affinity so SQLite coerced the written number to a string ('4' not 4) — + // a silent type-fidelity leak the value-loss tests didn't catch. REAL + // affinity round-trips them as JS numbers (#field-zoo). + // + // That leak stays defeated, and MEASURED rather than argued: knex + // compiles `table.decimal(name, p, s)` and `table.float(name)` to the + // IDENTICAL `float` column on SQLite + // (`ColumnCompiler_SQLite3.prototype.decimal` is the literal `'float'`), + // so the six exact-decimal members emit byte-identical SQLite DDL to what + // this arm emitted before and keep REAL affinity. `rating` moves to + // INTEGER affinity, where SQLite stores `4` as an integer and still + // accepts `4.5` as a REAL — it refuses no fractional value — so nothing + // this dialect accepts today stops being accepted. + // + // ⚠️ The read path is what makes the move safe on the server dialects: + // node-postgres parses `numeric` to a STRING and `real` to a number, and + // it is `NUMERIC_SCALAR_TYPES`' existing `numericFields` coercion — + // already registered for all seven of these types — that turns it back + // into a JS number on the way out. + case 'number': + case 'currency': + case 'percent': + case 'rating': + case 'slider': + case 'progress': + case 'summary': { + const numeric = numericColumnFor(type); + // ⛔ Not `?? table.float(name)`: an undefined answer for a type named + // in these very case labels would mean the labels and + // `NUMERIC_VALUE_TYPES` have parted, and a silent fallback is exactly + // the drift #16318 exists to close. The spec-side pin + // (`numeric-column-representation.test.ts`) holds the two equal, and + // `NUMERIC_VALUE_TYPES` is the single membership authority both read. + col = + numeric === undefined || numeric.kind === 'integer' + ? table.integer(name) + : table.decimal(name, numeric.precision, numeric.scale); + break; + } + // `toggle` is a boolean rendered as a switch. Same leak as above (TEXT + // affinity stored '1'); a boolean column gives NUMERIC affinity and the + // `booleanFields` read-coercion below converts the stored 1/0 back to a + // real JS boolean. + case 'boolean': + case 'toggle': + col = table.boolean(name); + break; + case 'date': + col = table.date(name); + break; + case 'datetime': + // MySQL's `TIMESTAMP` is a 32-bit epoch: it cannot represent an instant + // outside 1970-01-01..2038-01-19 (a contract end date, a subscription + // expiry, a retention horizon — all rejected outright), it carries no + // fractional seconds by default so the canonical form's milliseconds are + // silently truncated, and it CONVERTS on read/write using the session + // timezone, which makes the stored instant depend on server config. + // `DATETIME(3)` has none of those properties: 1000..9999, milliseconds + // kept, and stored verbatim — so the column holds the UTC wall clock the + // driver writes, exactly as ServiceNow stores its MySQL timestamps + // (#3942). Postgres deliberately keeps `table.timestamp` → `timestamptz`: + // asking for precision 3 there would REDUCE it from microseconds. + col = this.isMysql ? table.datetime(name, { precision: MYSQL_DATETIME_PRECISION }) : table.timestamp(name); + break; + case 'time': + // MySQL's bare `TIME` is zero-precision and ROUNDS a fractional literal + // (`'14:30:00.500'` → `14:30:01`), so the canonical form's milliseconds + // would change the stored wall clock. `TIME(3)` keeps them — the + // `DATETIME(3)` precedent (#3942) applied to time-of-day (#3994). Knex's + // `time()` takes no precision, hence the explicit type. + col = this.isMysql ? table.specificType(name, 'time(3)') : table.time(name); + break; + // `user` is a lookup specialized to sys_user (ADR: lookup → sys_user). Same + // physical storage as any lookup: a string column holding the related row id + // (multiple ⇒ JSON, handled at the top of createColumn). No bespoke storage + // primitive — it shares this exact DDL path so reads/$expand/FK stay uniform. + case 'lookup': + case 'user': + // ⛔ #11431 deliberately STOPS at the string family and does not reach + // here, even though these branches spell the same `table.string(name)`. + // A lookup column does not hold the declared value — it holds the + // REFERENCED ROW'S ID, whose width is the parent table's `id` column, + // not anything this field declared. Measured on MySQL 8.0.46: the FK + // itself is content with mismatched widths (`varchar(20)` child → + // `varchar(255)` parent `id` creates cleanly, and Postgres 16 accepts + // it too), so the type system gives no warning — the refusal arrives on + // the first write too wide for the child, as `ERROR 1406 Data too long`. + // + // [#15522] ⛔ An id's width is NOT a fixed number, and it is NOT this + // field's to declare. Both halves are this driver's own behaviour: when + // the caller supplies no id it mints `nanoid(DEFAULT_ID_LENGTH)` — 16 + // characters, from the constant above and the three mint sites it feeds + // — and when the caller DOES supply one it is stored verbatim at + // whatever width the caller chose (measured on this arm's own driver: + // 10-, 17- and 18-character ids all landed unaltered). Nothing on this + // path bounds an id's width at all, so `maxLength: 20` already cannot + // hold a 24-character supplied id, and any `maxLength` under 16 cannot + // hold even a minted one. Honouring it here would make the column + // structurally incapable of holding ids it will be asked to hold — a + // strictly worse defect than the one #11431 fixes. + // + // ⛔ Do not restore the number this sentence used to carry. It called a + // platform id 26 characters long and spelled out a ULID + // (`01JQ8XKZ9M4N7P2R5T6V8W0Y3B`). `DEFAULT_ID_LENGTH` has been 16 for the + // whole life of this file and no ULID is minted anywhere in this repo, so + // at the real numbers the worked example did not hold either — 16 + // characters FIT in `varchar(20)`. Four `packages/cli` sites still cite + // this arm BY NAME for that number; they are filed as #16114, not fixed + // here. + // + // [#11567] ⛔ This arm emits NO `FOREIGN KEY`, and that is the ruled + // contract rather than an omission. It used to carry + // `if (field.reference_to) table.foreign(name)...` — gated on a key + // the spec REFUSES (see {@link refuseRejectedReferenceAlias}), so it + // could not fire for any spec-conformant lookup and never had: zero FK + // constraints existed across all 44 exported platform objects on live + // Postgres 16.13 and MySQL 8.0.46. Retiring it is therefore a no-op for + // every authored deployment, and it is pinned in the retiring direction + // by `sql-driver-11567-lookup-no-foreign-key.test.ts`. + // + // ⛔ Do NOT "restore" this by re-reading the canonical `reference`. + // Measured on a PRISTINE EMPTY database: doing so takes Postgres from + // 44/44 objects synced to 18/44 and MySQL from 37/44 to 15/44, with + // `relation "sys_user" does not exist` — no data involved. Two + // structural causes: `syncSchema` has no topological ordering, so a + // child reaches DDL before its parent; and three of the ten FK targets + // (`sys_file`, `sys_environment`, `sys_package_version`) are not among + // the platform objects at all (ADR-0003 puts them in `service-tenant` / + // `service-storage`). Referential integrity is the ENGINE's, via + // `deleteBehavior` — which is what `content/docs/protocol/objectql/ + // types.mdx` has told authors since 2026-07-30. + col = table.string(name); + break; + case 'auto_number': + case 'autonumber': + // ⛔ Also out of #11431's scope, for a different reason than `lookup` + // above: the value is issued by the RUNTIME from its sequence, not + // supplied by a caller. `maxLength` has no write-time counterpart on + // this type — the record validator's `max_length` check covers the + // textual types only, and an autonumber never reaches it at all + // (runtime-owned types are excluded from its door) — so binding the + // DDL to it would create a refusal with nothing declaring it: the + // platform's own generated record number rejected by a column, with no + // author to hand the error to. Widening `maxLength` to a second + // meaning ("how wide is the generated number") is a spec decision, not + // a driver one. + col = table.string(name); + break; + case 'formula': + return; // Virtual — no column + default: + // Array/object-valued types are stored as a JSON column. Driven by the + // single `JSON_COLUMN_TYPES` source so this DDL switch and `isJsonField` + // (the read-side deserializer) can never drift — the drift between them + // is exactly what let array-valued fields reach the binder un-serialized + // (#field-zoo). Everything else is a plain string. + // + // ⛔ The third branch #11431 leaves alone. This is the CATCH-ALL, and + // MOST of what lands in it is the set of types whose stored value is + // NOT the declared value, or is short by construction: `secret` + // persists an opaque `sys_secret` ref rather than the credential it was + // given (ADR-0100), `select` / `radio` / `checkboxes` / `tree` store + // option machine names or ids, and `color` holds a color code. Sizing + // any of those from the author's `maxLength` would size the wrong + // string. (`code` used to be mis-listed here among the option-valued + // ones — measured in field-zoo it stores the editor's contents + // verbatim, which is why #11794 moved it to the text family above. + // `signature` / `qrcode` sat here as a measured OPEN DEFECT — their + // stored value IS the declared value, routinely a data-URI far past + // 255 chars, refused by both enforcing dialects — until #11875 gave + // the write seam their bound and moved them to the text family too.) + // + // A type that genuinely wants the bound belongs in the string-family + // case above, named — never acquired by falling through to here. + // + // ⚠️ The FILE family is asked separately, because its answer is a + // DEPLOYMENT fact rather than a property of the type (ADR-0104 + // addendum): a json column until this deployment's columns have moved, + // and then a `varchar` at the width the SQL generator already emits for + // it ({@link MEDIA_ID_VARCHAR_CHARS}) — not knex's default 255, which + // would be a new divergence between the two producers of this column. + // `multiple: true` media never reaches here: `createColumn` + // short-circuits on `multiple` above the type switch. + if (FILE_REFERENCE_TYPES.has(type)) { + col = this.mediaColumnIsJson() + ? this.jsonColumn(table, name) + : table.string(name, MEDIA_ID_VARCHAR_CHARS); + break; + } + col = JSON_COLUMN_TYPES.has(type) ? this.jsonColumn(table, name) : table.string(name); + } + + if (col) { + // NOTE: field-level `unique` is deliberately NOT emitted here (#3696). + // A column builder can only express a SINGLE-column constraint, but on a + // tenant-scoped object `unique: true` means unique *within the tenant* — + // a composite `(tenantField, field)` index. Emitting `col.unique()` here + // is what made every tenant share one global namespace, which collided + // head-on with the per-tenant autonumber sequence (each tenant counts + // from 1, so the second tenant's `PROD-00001` was rejected by an index it + // could not see). All unique materialization now goes through the single + // tenancy-aware path — {@link uniqueIndexesFromFields} + + // {@link syncDeclaredIndexes} — which runs after the table exists and can + // therefore build composites. `createColumn` owns type/nullability/ + // defaults only. + // + // ADR-0113: the physical NOT NULL comes from the EXPLICIT storage + // constraint, not from `required` — `required` is the write-time + // contract enforced by the record validator at the engine seam, and + // binding the DDL to it made every post-deploy tightening a + // destructive migration. + // + // ⚠️ NOTHING supplies `storage.notNull` on an author's behalf. The + // sentence that used to close this block said the opposite — that + // pre-17 sources "carry `storage.notNull` explicitly via the + // `field-required-notnull-explicit` conversion, so their columns come + // out exactly as they always did" — and it was measured false from both + // ends (#16693): a real `^17.0.0` app logged that conversion and got + // NULLABLE columns anyway, and the conversion itself has since been + // WITHDRAWN (maintainer ruling 2026-09-08), because stamping the + // constraint wherever `required: true` appears is the implication + // ADR-0113 abolished. So a column reaches `notNullable()` here because + // its author wrote `storage: { notNull: true }`, and for no other + // reason; a `required: true` field with no `storage` block gets a + // nullable column, at every protocol floor, on every dialect. + // + // [#17231] Asked through {@link declaresColumnNotNull} — the same + // question the multi-value short-circuit above now asks, stated once so + // this method's two exits cannot answer it differently again. + // + // ADR-0131 D1 (execution record 6285122667 on the v18 step 7 card): the + // table's tenant column is the one column NOT NULL WITHOUT a declaration + // — "there is no nullable tenant column anywhere". `tenantColumn` is + // passed only by the two CREATE TABLE sites ({@link initObjects}' create + // branch, {@link ensureShardTable}'s), never by an ADD COLUMN: a column + // added to a table that already holds rows is the organization-ownership + // ceremony's to tighten (ruling ② A, 6269955919), and no metadata says + // it — the injected tenant field stays `required: false` with no + // `storage`, and the differ's `ceremonyOwnedNullability` reads the + // difference as no drift. + if (declaresColumnNotNull(field) || tenantColumn) col.notNullable(); + this.applyDeclaredColumnDefault(col, field, type); + } + } } diff --git a/packages/drivers/driver-sql/src/sql-driver-database-helpers.ts b/packages/drivers/driver-sql/src/sql-driver-database-helpers.ts index 0a2bc671a1e..bebedf8be63 100644 --- a/packages/drivers/driver-sql/src/sql-driver-database-helpers.ts +++ b/packages/drivers/driver-sql/src/sql-driver-database-helpers.ts @@ -40,8 +40,8 @@ import { FILE_REFERENCE_TYPES } from '@objectstack/spec/data'; import { SqliteDatabaseFileError, makeDatabaseDirectory } from './sqlite-database-file.js'; import knex from 'knex'; -import { PRE_DDL_QUESTION_SCOPE, isMultiValuedColumn } from './sql-driver-top-level.js'; -import { JSON_COLUMN_TYPES, SqlDriverStatics as SqlDriver } from './sql-driver.js'; +import { JSON_COLUMN_TYPES, PRE_DDL_QUESTION_SCOPE, isMultiValuedColumn } from './sql-driver-top-level.js'; +import { SqlDriverStatics as SqlDriver } from './sql-driver.js'; import type { SqlDriver as SqlDriverClass } from './sql-driver.js'; /** diff --git a/packages/drivers/driver-sql/src/sql-driver-rotation.ts b/packages/drivers/driver-sql/src/sql-driver-rotation.ts index a4d89425d9e..27fcc5d28df 100644 --- a/packages/drivers/driver-sql/src/sql-driver-rotation.ts +++ b/packages/drivers/driver-sql/src/sql-driver-rotation.ts @@ -3,11 +3,11 @@ /** * [#29520 codemod] The "Data-lifecycle rotation (ADR-0057 P2)" section of * `SqlDriver`: the bodies of `rotationShardsOf`, `rotationWriteTarget`, - * `rotateShards`, `aliasShardBookkeeping`, `computeTenantField`, - * `computeAndRecordTenantField`, `registerManagedObjectMetadata`, - * `registerObjectMetadata`, `withPinnedSession`, `migrateMysqlDatetimeColumns`, - * `migrateMysqlTimeColumns` moved out of `sql-driver.ts` whole by - * `scripts/codemod-split-sql-driver.mjs`. + * `rotateShards`, `ensureShardTable`, `aliasShardBookkeeping`, + * `computeTenantField`, `computeAndRecordTenantField`, + * `registerManagedObjectMetadata`, `registerObjectMetadata`, `initObjects`, + * `withPinnedSession`, `migrateMysqlDatetimeColumns`, `migrateMysqlTimeColumns` + * moved out of `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`. * * A MOVE, not a redesign. {@link RotationFacet} is a stateless facet of `SqlDriver` * (ruling 6624345766): each static method of it is the body of the `SqlDriver` @@ -36,6 +36,7 @@ import { FILE_REFERENCE_TYPES, NUMERIC_VALUE_TYPES, isTenancyDisabled, parseAuto import type { AutonumberToken } from '@objectstack/spec/data'; import { aggregandColumnClass } from '@objectstack/core'; import { StorageNameMapping, SystemObjectName } from '@objectstack/spec/system'; +import { indexedKeyColumns, isUniqueScopeDeclared } from './schema-drift.js'; import { AUDIT_TIMESTAMP_COLUMNS, MYSQL_DATETIME_PRECISION, NUMERIC_SCALAR_TYPES, declaredValueShapes, isDeferredDdlLockWaitRefusal, isMultiValuedColumn, isNowDefaultValue, @@ -52,13 +53,22 @@ import type { SqlDriver } from './sql-driver.js'; export interface RotationHost { assertSchemaMutable: SqlDriver['assertSchemaMutable']; readonly autoNumberFields: SqlDriver['autoNumberFields']; + backfillCanonicalDatetimes: SqlDriver['backfillCanonicalDatetimes']; + backfillCanonicalJsonEncoding: SqlDriver['backfillCanonicalJsonEncoding']; + backfillCanonicalTimes: SqlDriver['backfillCanonicalTimes']; readonly booleanFields: SqlDriver['booleanFields']; computeAndRecordTenantField: SqlDriver['computeAndRecordTenantField']; computeTenantField: SqlDriver['computeTenantField']; + createAuditTimestampColumn: SqlDriver['createAuditTimestampColumn']; + createColumn: SqlDriver['createColumn']; readonly dateFields: SqlDriver['dateFields']; readonly datetimeFields: SqlDriver['datetimeFields']; + readonly deferredDdl: SqlDriver['deferredDdl']; + readonly deferredSchemaObjects: SqlDriver['deferredSchemaObjects']; readonly dialectName: SqlDriver['dialectName']; + ensureDatabaseExists: SqlDriver['ensureDatabaseExists']; ensureRotation: SqlDriver['ensureRotation']; + ensureSequencesTable: SqlDriver['ensureSequencesTable']; readonly fractionalNumericFields: SqlDriver['fractionalNumericFields']; isJsonField: SqlDriver['isJsonField']; readonly isMysql: SqlDriver['isMysql']; @@ -71,15 +81,26 @@ export interface RotationHost { readonly managedObjectFields: SqlDriver['managedObjectFields']; readonly managedObjectIndexes: SqlDriver['managedObjectIndexes']; readonly mediaFields: SqlDriver['mediaFields']; + migrateMysqlDatetimeColumns: SqlDriver['migrateMysqlDatetimeColumns']; + migrateMysqlTimeColumns: SqlDriver['migrateMysqlTimeColumns']; nowColumnDefault: SqlDriver['nowColumnDefault']; readonly numericFields: SqlDriver['numericFields']; readonly numericValueFields: SqlDriver['numericValueFields']; organizationObjectRegistered: SqlDriver['organizationObjectRegistered']; readonly physicalKeyIndexes: SqlDriver['physicalKeyIndexes']; + reconcileAndWarnDrift: SqlDriver['reconcileAndWarnDrift']; registerManagedObjectMetadata: SqlDriver['registerManagedObjectMetadata']; + registerObjectMetadata: SqlDriver['registerObjectMetadata']; + resolveFileColumnsMoved: SqlDriver['resolveFileColumnsMoved']; + resolveTenantField: SqlDriver['resolveTenantField']; + rethrowWithRowSizeExplanation: SqlDriver['rethrowWithRowSizeExplanation']; readonly rotationStateByTable: SqlDriver['rotationStateByTable']; runWideningAlters: SqlDriver['runWideningAlters']; readonly supportsRotation: SqlDriver['supportsRotation']; + syncDeclaredIndexes: SqlDriver['syncDeclaredIndexes']; + syncTableIndexes: SqlDriver['syncTableIndexes']; + readonly tablesCreatedHere: SqlDriver['tablesCreatedHere']; + readonly tablesFoundExisting: SqlDriver['tablesFoundExisting']; readonly tablesWithTimestamps: SqlDriver['tablesWithTimestamps']; readonly tenantFieldByTable: SqlDriver['tenantFieldByTable']; readonly tenantOptOutByTable: SqlDriver['tenantOptOutByTable']; @@ -87,6 +108,7 @@ export interface RotationHost { readonly undeclaredAuditTimestampFields: SqlDriver['undeclaredAuditTimestampFields']; readonly updatedAtColumnState: SqlDriver['updatedAtColumnState']; readonly valueShapeFields: SqlDriver['valueShapeFields']; + warnBuiltinColumnCollisions: SqlDriver['warnBuiltinColumnCollisions']; } /** The bodies of the "Data-lifecycle rotation (ADR-0057 P2)" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ @@ -115,6 +137,86 @@ export class RotationFacet { return this.ensureRotation(tableName, objectDef, policy, nowMs); } + /** The body of `SqlDriver#ensureShardTable`; the method's docblock there is its contract. */ + static async ensureShardTable(this: Pick, shardName: string, obj: { fields?: Record; tenancy?: any; indexes?: any[] }, baseTable: string = shardName): Promise { + const builtinColumns = new Set(['id', 'created_at', 'updated_at']); + // [#12015] Both branches below drop a declared field named after a builtin + // column — the create branch skips it explicitly, the column-sync branch + // finds the column already present — so the shard path warns once here, + // ahead of either. + this.warnBuiltinColumnCollisions(shardName, obj.fields, builtinColumns, 'shard'); + const exists = await this.knex.schema.hasTable(shardName); + // ADR-0131 D1: a shard is created from the BASE's declaration, so it takes + // the base's tenant column NOT NULL — asked of the base through the + // recording resolver, the same answer the declared-index sync below uses + // (the shard has no bookkeeping of its own until `aliasShardBookkeeping`). + // A shard that already exists keeps the nullability it has: the + // organization-ownership ceremony reports such a shard, it never rebuilds it. + const baseTenantField = this.computeAndRecordTenantField(baseTable, obj); + // Commit d0e3a885b: a shard carries the base table's declared indexes (below), so its + // columns need the same keyable-text decision the managed path makes. + const keyedColumns = indexedKeyColumns({ + table: shardName, + fields: obj.fields ?? {}, + tenantField: this.resolveTenantField(shardName), + declaredIndexes: obj.indexes, + }); + if (!exists) { + await this.knex.schema.createTable(shardName, (table) => { + table.string('id').primary(); + this.createAuditTimestampColumn(table, 'created_at'); + this.createAuditTimestampColumn(table, 'updated_at'); + for (const [name, field] of Object.entries(obj.fields ?? {})) { + if (builtinColumns.has(name)) continue; + this.createColumn(table, name, field, keyedColumns.get(name), name === baseTenantField); + } + }); + } else { + const columnInfo = await this.knex(shardName).columnInfo(); + const existingColumns = Object.keys(columnInfo); + await this.knex.schema.alterTable(shardName, (table) => { + for (const [name, field] of Object.entries(obj.fields ?? {})) { + if (!existingColumns.includes(name)) { + this.createColumn(table, name, field, keyedColumns.get(name)); + } + } + }); + } + + // Declared indexes per shard. Auto-derived names already embed the shard + // name; explicit names get a shard prefix so they can't collide across + // shards in the same database. + const declared = obj.indexes; + if (Array.isArray(declared) && declared.length > 0) { + const colInfo = await this.knex(shardName).columnInfo(); + const perShard = declared.map((idx: any) => ({ + ...idx, + name: typeof idx?.name === 'string' && idx.name.trim() ? `${shardName}__${idx.name.trim()}` : undefined, + })); + // Shard bookkeeping is aliased AFTER this method runs, so `shardName` has + // no entry of its own yet — a declared `unique: 'organization'` index + // (ADR-0120 D1) must scope identically on every shard of the base table, + // so the answer has to come from the base. + // + // [commit 0f38ab084] Keyed by `baseTable`, and through the RECORDING resolver, not + // the bare `computeTenantField`. The bare one reads this call's schema + // alone, so a shard synced from a partial re-registration — one carrying + // no `tenancy` block — fell through to the implicit `organization_id` + // heuristic and gave the shard an organization key part the base table's + // own index does not have. `tenantOptOutByTable` already holds the base's + // explicit opt-out (`initObjects` recorded it under exactly this key, and + // `aliasShardBookkeeping` propagates it to the shard afterwards), so + // consulting it here is what makes the two paths answer the same. + await this.syncDeclaredIndexes( + shardName, + perShard, + new Set(Object.keys(colInfo)), + baseTenantField, + obj.fields ?? {}, + ); + } + } + /** The body of `SqlDriver#aliasShardBookkeeping`; the method's docblock there is its contract. */ static aliasShardBookkeeping(this: Pick, base: string, shard: string): void { this.jsonFields[shard] = this.jsonFields[base] ?? []; @@ -282,6 +384,244 @@ export class RotationFacet { for (const obj of objects) this.registerManagedObjectMetadata(obj); } + /** The body of `SqlDriver#initObjects`; the method's docblock there is its contract. */ + static async initObjects(this: Pick, objects: Array<{ name: string; fields?: Record; tenancy?: any; indexes?: any[]; lifecycle?: any }>): Promise { + // The ADR-0104 media arm, resolved BEFORE registration because + // `registerObjectMetadata` below freezes `isJsonField`'s answer into + // `jsonFields` for every media column it sees. Memoized, and every failure + // path lands on the JSON arm — see {@link resolveFileColumnsMoved}. + await this.resolveFileColumnsMoved(); + + // In-memory registration FIRST, and deliberately ahead of the DDL gate + // below: being refused permission to alter a schema is not a reason to stay + // ignorant of the objects we were just told about. On a datasource we are a + // guest in (`schemaMode !== 'managed'`) the gate throws — and before this + // line ran here, it threw with every coercion registry still empty, so the + // very next write to a JSON field on that datasource was bound by the + // client's per-type defaults (see {@link registerObjectMetadata} for what + // node-postgres does with an array and with `[]`). Nothing in this call + // touches the database; the refusal it precedes is unchanged. + this.registerObjectMetadata(objects); + + // DDL gate (ADR-0015 §5.1): createTable/alterTable below mutate schema. + // Also covers `syncSchema`, which delegates here. + this.assertSchemaMutable('initObjects'); + + // [#13028] …and NOT while DDL is deferred. Under {@link deferredDdl} every + // branch below is skipped in favour of recording the work, so there is no + // DDL for a database to exist FOR — while `ensureDatabaseExists()` is the + // one line here that can WRITE: it `mkdir -p`s a sqlite parent directory + // and, on Postgres/MySQL, issues `SELECT 1` and CREATEs THE DATABASE when + // that comes back `3D000` / `ER_BAD_DB_ERROR`. + // + // That made a declared dry run able to create a database (#6743 closed the + // sqlite half by opening an absent file in memory; this is the same + // promise, kept at the source and for every dialect), and it charged one + // round-trip per object to do it — on a control plane at ~300ms RTT × ~80 + // objects, a `plan` that computes in seconds would have spent minutes + // asking a database it was never going to touch whether it existed. + // + // ⛔ NOT a relaxation of the guarantee for real DDL: `flushDeferredSchemaDdl` + // clears `deferredDdl` BEFORE re-entering this method with the deferred + // objects, so the confirmed `os migrate apply` still ensures the database + // ahead of the first `CREATE TABLE`. + if (!this.deferredDdl) await this.ensureDatabaseExists(); + + for (const obj of objects) { + // Re-read what the registration above recorded, rather than recomputing: + // `computeAndRecordTenantField` carries a sticky explicit-opt-out, so the + // recorded answer IS the answer. + const tableName = StorageNameMapping.resolveTableName(obj); + const tenantField = this.tenantFieldByTable[tableName] ?? null; + + // Deferred-DDL mode (#3917): the call above is in-memory metadata + // registration — coercion maps, tenancy, and the `managedObjectFields` + // entry `detectManagedDrift()` diffs against. Everything below issues + // DDL. `os migrate plan` / `apply` boot with the deferral armed so the + // plan is computed against the database as it actually is, and nothing + // is created until the operator has seen (and confirmed) the plan. + if (this.deferredDdl) { + this.deferredSchemaObjects.set(tableName, { ...obj, name: tableName }); + continue; + } + + // ADR-0057 P2: rotation-declared telemetry is physically time-sharded — + // the Rotator owns its DDL (shard tables + a read view under the base + // name); the plain create/alter path below would collide with the view. + const rotationPolicy = obj.lifecycle?.storage; + if (rotationPolicy?.strategy === 'rotation' && this.supportsRotation) { + this.tablesWithTimestamps.add(tableName); + await this.ensureRotation(tableName, obj, rotationPolicy); + continue; + } + + let exists = await this.knex.schema.hasTable(tableName); + // Recorded BEFORE the legacy-`_id` rebuild below can flip `exists`: a + // table that was here when we arrived stays "found" even if we then drop + // and recreate it. Re-syncing a table we made ourselves this boot — boot + // runs the sync more than once, and objects registered later sync on + // demand — is not evidence of anything preceding us, so it is ignored. + // See `getSchemaSyncStats`. + if (!exists) this.tablesCreatedHere.add(tableName); + else if (!this.tablesCreatedHere.has(tableName)) this.tablesFoundExisting.add(tableName); + + if (exists) { + const columnInfo = await this.knex(tableName).columnInfo(); + const existingColumns = Object.keys(columnInfo); + + if (existingColumns.includes('_id') && !existingColumns.includes('id')) { + await this.knex.schema.dropTable(tableName); + exists = false; + } + } + + // Columns created unconditionally by initObjects — skip them when + // iterating obj.fields to avoid duplicate-column errors (e.g. SQLite + // rejects CREATE TABLE with two columns of the same name). + const builtinColumns = new Set(['id', 'created_at', 'updated_at']); + + // Commit d0e3a885b: which columns this object's indexes will KEY ON, resolved before + // any DDL runs. `createColumn` needs it to decide whether a bounded text + // field takes `varchar(maxLength)` (keyable) or TEXT — a decision that is + // only makeable at CREATE time, since no dialect turns a TEXT column into + // a keyable one afterwards. + const keyedColumns = indexedKeyColumns({ + table: tableName, + fields: obj.fields ?? {}, + tenantField, + declaredIndexes: obj.indexes, + }); + + if (!exists) { + // [#12015] The `continue` below drops a declared field that collides + // with a builtin column. Said BEFORE the DDL runs, so the author hears + // it even when the CREATE goes on to fail for an unrelated reason. + this.warnBuiltinColumnCollisions(tableName, obj.fields, builtinColumns, 'create'); + try { + await this.knex.schema.createTable(tableName, (table) => { + table.string('id').primary(); + this.createAuditTimestampColumn(table, 'created_at'); + this.createAuditTimestampColumn(table, 'updated_at'); + if (obj.fields) { + for (const [name, field] of Object.entries(obj.fields)) { + if (builtinColumns.has(name)) continue; + // ADR-0131 D1: a fresh table's tenant column is NOT NULL. + this.createColumn(table, name, field, keyedColumns.get(name), name === tenantField); + } + } + }); + } catch (e: any) { + // #11565: MySQL charges every bounded column's DECLARED width against + // a per-ROW budget, and refuses the CREATE naming no column and no + // declaration — about a table its author described entirely in + // metadata. Re-throw the SAME failure carrying the field-level fix; + // anything that is not that refusal is re-thrown untouched. + await this.rethrowWithRowSizeExplanation(tableName, obj.fields, keyedColumns, null, e); + } + this.tablesWithTimestamps.add(tableName); + } else { + const columnInfo = await this.knex(tableName).columnInfo(); + const existingColumns = Object.keys(columnInfo); + + if (existingColumns.includes('updated_at')) { + this.tablesWithTimestamps.add(tableName); + } + + // [#12015] The ADD COLUMN diff is keyed on "column not already there", + // so a declared `id`/`created_at`/`updated_at` is dropped on this path + // too — the builtin is already in `existingColumns`, so the diff below + // never proposes it and `addedColumns` excludes it by name. Silent on + // every boot of an existing table until now. + this.warnBuiltinColumnCollisions(tableName, obj.fields, builtinColumns, 'alter'); + + // #11565: the row budget is a property of the WHOLE row, so adding one + // ordinary column to a wide table is refused by the width of columns + // nobody is touching — with the same column-less server error. Named + // here so the ADD COLUMN path is not the one that keeps it. + const addedColumns = Object.keys(obj.fields ?? {}).filter( + (name) => !builtinColumns.has(name) && !existingColumns.includes(name), + ); + try { + await this.knex.schema.alterTable(tableName, (table) => { + if (obj.fields) { + for (const [name, field] of Object.entries(obj.fields)) { + if (!existingColumns.includes(name)) { + this.createColumn(table, name, field, keyedColumns.get(name)); + } + } + } + }); + } catch (e: any) { + await this.rethrowWithRowSizeExplanation(tableName, obj.fields, keyedColumns, addedColumns, e); + } + } + + // Materialize the table's index set: field-level `unique` (tenancy-aware + // — #3696) plus object-level declared indexes (`indexes: [{ fields, + // unique }]`, carrying the multi-column UNIQUE guarantees dedup/ + // convergence paths rely on, ADR-0030). Both go through one path so the + // create and alter branches above cannot produce different constraints + // for the same metadata. Done after the table is created/altered so every + // referenced column physically exists — which is also why field-level + // `unique` can no longer be emitted inline by `createColumn`: a composite + // needs the tenant column to already be there. + const declaredIndexes = obj.indexes; + const uniqueFields = Object.values(obj.fields ?? {}).some((f) => + isUniqueScopeDeclared(f?.unique), + ); + if (uniqueFields || (Array.isArray(declaredIndexes) && declaredIndexes.length > 0)) { + const colInfo = await this.knex(tableName).columnInfo(); + const physicalColumns = new Set(Object.keys(colInfo)); + await this.syncTableIndexes( + tableName, + obj.fields ?? {}, + declaredIndexes, + tenantField, + physicalColumns, + ); + } + + // #2186: the additive sync above only ever ADDs tables/columns/indexes. + // For a table that already existed, detect (and in dev, auto-reconcile) + // any non-additive divergence between metadata and the physical schema — + // relaxed NOT NULL, widened varchar, orphaned column, and since #3728 the + // index dimension too (a legacy global unique that is now tenant-scoped, + // a redefined or orphaned index). + if (exists) { + await this.reconcileAndWarnDrift(tableName, obj.fields ?? {}, declaredIndexes); + } + + // #3912: converge this table's `Field.datetime` columns on the canonical + // UTC-text storage form. A table this call just CREATED has no rows, so it + // is canonical by construction — record that without touching the disk. + await this.backfillCanonicalDatetimes(tableName, exists); + // #3994: the `Field.time` twin of the line above. + await this.backfillCanonicalTimes(tableName, exists); + // Commit 4045b954d: converge this table's `Field.json` columns on the injective + // JSON-text storage form the rewritten `formatInput` now writes. + await this.backfillCanonicalJsonEncoding(tableName, exists); + // #3942: the MySQL twin — widen legacy `TIMESTAMP` columns to `DATETIME(3)`. + if (exists) await this.migrateMysqlDatetimeColumns(tableName, obj.fields ?? {}); + // #3994: widen legacy MySQL `TIME` columns to `TIME(3)`. + if (exists) await this.migrateMysqlTimeColumns(tableName, obj.fields ?? {}); + } + + // Pre-create the auto_number counter table now, while we hold a fresh pooled + // connection and are NOT inside any data transaction. Creating it lazily on + // the first autonumber INSERT dead-locks a `/api/v1/batch` write on SQLite + // (pool max=1: the open batch transaction owns the only connection, so the + // lazy `ensureSequencesTable` blocks forever acquiring a second one) and + // risks the same pool exhaustion under concurrent first-writes on + // Postgres/MySQL. Idempotent and skipped entirely when nothing uses + // auto_number, so it costs one `hasTable` at boot in the common case. + const usesAutoNumber = Object.values(this.autoNumberFields).some( + (cols) => Array.isArray(cols) && cols.length > 0, + ); + if (usesAutoNumber && !this.deferredDdl) { + await this.ensureSequencesTable(); + } + } + /** The body of `SqlDriver#withPinnedSession`; the method's docblock there is its contract. */ static async withPinnedSession(this: Pick, fn: (run: (sql: string, bindings?: unknown[]) => Promise) => Promise): Promise { return await this.knex.transaction(async (trx) => diff --git a/packages/drivers/driver-sql/src/sql-driver-top-level.ts b/packages/drivers/driver-sql/src/sql-driver-top-level.ts index 5d0b4f62e39..0b1e8363576 100644 --- a/packages/drivers/driver-sql/src/sql-driver-top-level.ts +++ b/packages/drivers/driver-sql/src/sql-driver-top-level.ts @@ -11,7 +11,7 @@ // re-exports, by name, every declaration here it exported before, so every // importer of `./sql-driver.js` reads what it read. -import { ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, AggregationFunction, NUMERIC_VALUE_TYPES, RETIRED_FILTER_OPERATORS, hasDanglingLikeEscape, hasNulInLikePattern, isAcceptedFilterComparand, isListComparandOperator, isMultiValueField, isNowDefaultToken, reduceFilterKeyVerdict, reduceFilterVerdict } from '@objectstack/spec/data'; +import { ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, AggregationFunction, MULTI_OPTION_TYPES, NUMERIC_VALUE_TYPES, RETIRED_FILTER_OPERATORS, STRUCTURED_JSON_TYPES, hasDanglingLikeEscape, hasNulInLikePattern, isAcceptedFilterComparand, isListComparandOperator, isMultiValueField, isNowDefaultToken, reduceFilterKeyVerdict, reduceFilterVerdict } from '@objectstack/spec/data'; import type { FilterVerdictHooks, SchemaMode, FilterVerdict as SharedFilterVerdict, ValueShapeFieldDef } from '@objectstack/spec/data'; import type { DriverQuery, IntrospectedColumn as SpecIntrospectedColumn, IntrospectedSchema as SpecIntrospectedSchema, IntrospectedTable as SpecIntrospectedTable } from '@objectstack/spec/contracts'; import { jsonColumnOperatorRefusalText, temporalStorageForm } from '@objectstack/core'; @@ -164,6 +164,67 @@ export interface AutoNumberReservation { value: string; } +// GLOBAL_TENANT ('__global__') — the sentinel for the NULL-organization +// ("platform") bucket — is defined ONCE in schema-drift.ts and imported here: +// since ADR-0120 D3 it names the same bucket in two subsystems (the autonumber +// sequence table's tenant key, and the COALESCE organization key part of every +// organization-scoped unique index), and #3696's root lesson was two +// subsystems naming one concept differently. Storage stays NULL — see the +// definition site. + +/** + * Field types whose value is an array or object and must be stored as a JSON + * column (and JSON-(de)serialized at the driver boundary). SINGLE SOURCE for + * both the DDL column-type switch and `isJsonField` so the two can't drift — + * the drift between them is exactly what let array-valued fields (multiselect/ + * checkboxes/tags/repeater/vector) reach the SQLite binder un-serialized and + * crash with "SQLite3 can only bind numbers, strings, bigints, buffers, and + * null" (#field-zoo). `composite`/`address`/`location`/`record` are objects; + * the rest are arrays. + * + * ⚠️ The FILE family (`image`/`file`/`avatar`/`video`/`audio`) was a member + * until the ADR-0104 addendum ruled that its physical column holds the BARE + * `sys_file` id in a STRING column rather than a JSON-quoted id in a JSON + * column. Its membership is no longer a constant: a SINGLE-VALUE media field + * is a JSON column on a deployment that has not moved its columns and a string + * column on one that has, so the question is asked per driver instance through + * {@link SqlDriver.mediaColumnIsJson} rather than of this set. A + * `multiple: true` media field is unaffected — its value is a LIST of ids, it + * is a JSON column on every deployment, and `isMultiValueField` already says so + * above every type check (`file` / `image` are `MULTI_CAPABLE_TYPES` members; + * #17469 refuses `multiple: true` on `avatar` / `video` / `audio` at the + * authoring entrance rather than arrayifying their column). + * + * ⛔ Do not re-add the family here. Every reader of this set treats membership + * as deployment-independent, which is exactly what the family stopped being. + */ +export const JSON_COLUMN_TYPES = new Set([ + // Spec value-shape classes (ADR-0104 D1): structured JSON payloads and the + // inherently-array option types. Membership is owned by @objectstack/spec — + // a type added there becomes a JSON column here without touching this file. + ...STRUCTURED_JSON_TYPES, ...MULTI_OPTION_TYPES, + // Driver-internal aliases (external/introspected columns) — not authorable + // FieldTypes, so they stay a local extra. + 'object', 'array', +]); + +/** + * The `varchar(n)` width a moved single-value media column gets. + * + * ⚠️ NOT a taste: it is the width `os generate migration --format sql` already + * emits for the family (`VARCHAR(2048)` in `packages/cli/src/commands/ + * generate.ts`), and the maintainer ruling in ADR-0104's 2026-09-05 addendum is that the GENERATOR + * states the ruled end-state and does not move — the driver is the side that + * moves to meet it. A driver that created knex's default `varchar(255)` here + * would open a fresh divergence between the two producers of the same column + * on the very change that closes the old one. + * + * Mirrored by {@link SqlDriver.varcharColumnChars}, which is what the drift + * detector asks rather than restating the width; the two are pinned equal by + * `sql-driver-11565-row-byte-budget.test.ts` for every declared `FieldType`. + */ +const MEDIA_ID_VARCHAR_CHARS = 2048; + /** * Field types whose value is a numeric scalar. SINGLE SOURCE for the DDL * column-type switch (these map to INTEGER/REAL columns) and the read-side @@ -3984,13 +4045,13 @@ function reclaimBetterSqlite3(db: BetterSqlite3Connection): void { // [#29520 codemod] Declared above without `export`, and read by `sql-driver.ts` or a section module. export { - AUTONUMBER_COLLISION_RETRIES, CROSS_FIELD_COMPARISON_OPERATORS, DEFAULT_ID_LENGTH, RowWidthContribution, - SQL_AGGREGATE_FUNCTIONS, SQL_WINDOW_FUNCTIONS, SQL_WINDOW_SORT_DIRECTIONS, SqlAggregateLowering, TEXT_OPERATORS, - WITHHELD_FILTER_AUTHOR_TEXT, WITHHELD_FILTER_LOGGED, WITHHELD_FILTER_SUBTREE, assertCompilableComparand, - assertDefinedComparands, bareFieldReferenceError, betweenArityError, canonicalTimeOfDay, canonicalUtcDatetime, - declaresColumnNotNull, emptyFieldConstraintError, filterArrayReachedDriverError, insertedRowsNotReadBackError, - invalidWindowFunctionQueryError, isDeclaredFieldType, isDeferredDdlLockWaitRefusal, isEmptyFieldConstraint, - isUpsertFenceRefusal, jsonColumnOperatorError, normalizeSqliteDatetimeOutput, nowAnchorOf, + AUTONUMBER_COLLISION_RETRIES, CROSS_FIELD_COMPARISON_OPERATORS, DEFAULT_ID_LENGTH, MEDIA_ID_VARCHAR_CHARS, + RowWidthContribution, SQL_AGGREGATE_FUNCTIONS, SQL_WINDOW_FUNCTIONS, SQL_WINDOW_SORT_DIRECTIONS, + SqlAggregateLowering, TEXT_OPERATORS, WITHHELD_FILTER_AUTHOR_TEXT, WITHHELD_FILTER_LOGGED, WITHHELD_FILTER_SUBTREE, + assertCompilableComparand, assertDefinedComparands, bareFieldReferenceError, betweenArityError, canonicalTimeOfDay, + canonicalUtcDatetime, declaresColumnNotNull, emptyFieldConstraintError, filterArrayReachedDriverError, + insertedRowsNotReadBackError, invalidWindowFunctionQueryError, isDeclaredFieldType, isDeferredDdlLockWaitRefusal, + isEmptyFieldConstraint, isUpsertFenceRefusal, jsonColumnOperatorError, normalizeSqliteDatetimeOutput, nowAnchorOf, presentAuditTimestampOutput, reclaimBetterSqlite3, reduceFilterKey, reduceFilterNode, refusalSubtree, refuseAggregateFunction, refuseCrossRowIdentityMerge, refuseDateBucketedGroupBy, refuseDistinctAggregateWithoutField, refuseRejectedReferenceAlias, refuseUndeclaredFieldType, diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index a70e5588747..76b0fc6658c 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -228,11 +228,10 @@ import { findMisplacedReadableIds, refuseReadableIdsOnce } from '@objectstack/co import { isComposedReadableIds, type ComposedReadableIds } from '@objectstack/spec/data'; // [#29520 codemod] The top-level declarations that moved whole to `sql-driver-top-level.ts`, as this file still reads them. import { - AUTONUMBER_COLLISION_RETRIES, CROSS_FIELD_COMPARISON_OPERATORS, MYSQL_DATETIME_PRECISION, SEQUENCES_TABLE, - TEXT_OPERATORS, assertCompilableComparand, bareFieldReferenceError, betweenArityError, declaresColumnNotNull, - fieldReferenceOf, isDeclaredFieldType, isFilterNode, isMultiValuedColumn, nowAnchorOf, nowAnchorPositionError, - reduceFilterKey, reduceFilterNode, refusalSubtree, refuseRejectedReferenceAlias, refuseUndeclaredFieldType, - retiredFilterOperatorError, unsupportedFilterOperatorError, + AUTONUMBER_COLLISION_RETRIES, CROSS_FIELD_COMPARISON_OPERATORS, SEQUENCES_TABLE, TEXT_OPERATORS, + assertCompilableComparand, bareFieldReferenceError, betweenArityError, fieldReferenceOf, isFilterNode, nowAnchorOf, + nowAnchorPositionError, reduceFilterKey, reduceFilterNode, refusalSubtree, retiredFilterOperatorError, + unsupportedFilterOperatorError, } from './sql-driver-top-level.js'; import type { AutoNumberReservation, ConflictTargetVerdict, IntrospectedColumn, IntrospectedForeignKey, IntrospectedSchema, @@ -243,13 +242,13 @@ import type { export { AUDIT_TIMESTAMP_COLUMNS, type AutoNumberReservation, CREATE_INDEX_DDL, type ConflictTargetVerdict, DROP_INDEX_DDL, INDEX_DDL_PREFIX, type IntrospectedColumn, type IntrospectedForeignKey, type IntrospectedSchema, - type IntrospectedTable, LIKE_ESCAPE_CHARACTER, LOCK_WAIT_REFUSAL, MYSQL_DATETIME_PRECISION, NUMERIC_SCALAR_TYPES, - type NowAnchor, OWN_STATEMENT_FAULT, PRE_DDL_QUESTION_SCOPE, type ReadPresentationKind, SEQUENCES_TABLE, - SQLITE_NON_TEMPORAL_TEXT_REFS, SQLITE_TIME_EXPR_REFS, type SqlDriverConfig, type SqlWindowFunctionQuery, - type SqlWindowFunctionSpec, type SqliteAbsentFileMode, type SqliteJournalMode, type TextMatchShape, - crossFieldComparisonError, declaredValueShapes, fieldReferenceOf, isFilterNode, isMultiValuedColumn, - isNowDefaultValue, isUnresolvableColumnError, lastIdentifierSegment, mysqlDatetimeLiteral, nowAnchorPositionError, - postgresTableNameCollisionError, rawStatementFaultError, refuseUnbackedConflictTarget, + type IntrospectedTable, JSON_COLUMN_TYPES, LIKE_ESCAPE_CHARACTER, LOCK_WAIT_REFUSAL, MYSQL_DATETIME_PRECISION, + NUMERIC_SCALAR_TYPES, type NowAnchor, OWN_STATEMENT_FAULT, PRE_DDL_QUESTION_SCOPE, type ReadPresentationKind, + SEQUENCES_TABLE, SQLITE_NON_TEMPORAL_TEXT_REFS, SQLITE_TIME_EXPR_REFS, type SqlDriverConfig, + type SqlWindowFunctionQuery, type SqlWindowFunctionSpec, type SqliteAbsentFileMode, type SqliteJournalMode, + type TextMatchShape, crossFieldComparisonError, declaredValueShapes, fieldReferenceOf, isFilterNode, + isMultiValuedColumn, isNowDefaultValue, isUnresolvableColumnError, lastIdentifierSegment, mysqlDatetimeLiteral, + nowAnchorPositionError, postgresTableNameCollisionError, rawStatementFaultError, refuseUnbackedConflictTarget, refuseUpsertConflictOutsideWrittenOrganization, resolveSqliteAbsentFileTarget, unquoteSqlIdentifier, unsupportedFilterError, withheldFilterDiagnosticOf, withheldFilterError, } from './sql-driver-top-level.js'; @@ -306,67 +305,6 @@ import { windowFunctionsHost, } from './sql-driver-facet-hosts.js'; -// GLOBAL_TENANT ('__global__') — the sentinel for the NULL-organization -// ("platform") bucket — is defined ONCE in schema-drift.ts and imported here: -// since ADR-0120 D3 it names the same bucket in two subsystems (the autonumber -// sequence table's tenant key, and the COALESCE organization key part of every -// organization-scoped unique index), and #3696's root lesson was two -// subsystems naming one concept differently. Storage stays NULL — see the -// definition site. - -/** - * Field types whose value is an array or object and must be stored as a JSON - * column (and JSON-(de)serialized at the driver boundary). SINGLE SOURCE for - * both the DDL column-type switch and `isJsonField` so the two can't drift — - * the drift between them is exactly what let array-valued fields (multiselect/ - * checkboxes/tags/repeater/vector) reach the SQLite binder un-serialized and - * crash with "SQLite3 can only bind numbers, strings, bigints, buffers, and - * null" (#field-zoo). `composite`/`address`/`location`/`record` are objects; - * the rest are arrays. - * - * ⚠️ The FILE family (`image`/`file`/`avatar`/`video`/`audio`) was a member - * until the ADR-0104 addendum ruled that its physical column holds the BARE - * `sys_file` id in a STRING column rather than a JSON-quoted id in a JSON - * column. Its membership is no longer a constant: a SINGLE-VALUE media field - * is a JSON column on a deployment that has not moved its columns and a string - * column on one that has, so the question is asked per driver instance through - * {@link SqlDriver.mediaColumnIsJson} rather than of this set. A - * `multiple: true` media field is unaffected — its value is a LIST of ids, it - * is a JSON column on every deployment, and `isMultiValueField` already says so - * above every type check (`file` / `image` are `MULTI_CAPABLE_TYPES` members; - * #17469 refuses `multiple: true` on `avatar` / `video` / `audio` at the - * authoring entrance rather than arrayifying their column). - * - * ⛔ Do not re-add the family here. Every reader of this set treats membership - * as deployment-independent, which is exactly what the family stopped being. - */ -export const JSON_COLUMN_TYPES = new Set([ - // Spec value-shape classes (ADR-0104 D1): structured JSON payloads and the - // inherently-array option types. Membership is owned by @objectstack/spec — - // a type added there becomes a JSON column here without touching this file. - ...STRUCTURED_JSON_TYPES, ...MULTI_OPTION_TYPES, - // Driver-internal aliases (external/introspected columns) — not authorable - // FieldTypes, so they stay a local extra. - 'object', 'array', -]); - -/** - * The `varchar(n)` width a moved single-value media column gets. - * - * ⚠️ NOT a taste: it is the width `os generate migration --format sql` already - * emits for the family (`VARCHAR(2048)` in `packages/cli/src/commands/ - * generate.ts`), and the maintainer ruling in ADR-0104's 2026-09-05 addendum is that the GENERATOR - * states the ruled end-state and does not move — the driver is the side that - * moves to meet it. A driver that created knex's default `varchar(255)` here - * would open a fresh divergence between the two producers of the same column - * on the very change that closes the old one. - * - * Mirrored by {@link SqlDriver.varcharColumnChars}, which is what the drift - * detector asks rather than restating the width; the two are pinned equal by - * `sql-driver-11565-row-byte-budget.test.ts` for every declared `FieldType`. - */ -const MEDIA_ID_VARCHAR_CHARS = 2048; - // [#29520 codemod] The statics of `SqlDriver` its facet modules read, private ones included, as // the class declares them (an indexed access on the class). The static block at the end of // the class fills `SqlDriverStatics` with forwarders: a method is called on the class at @@ -4171,87 +4109,13 @@ export class SqlDriver implements IDataDriver { * defaults to `shardName` so an existing override or external caller keeps * today's behaviour, and the one in-tree call site names the base. */ - protected async ensureShardTable( + protected ensureShardTable( shardName: string, obj: { fields?: Record; tenancy?: any; indexes?: any[] }, baseTable: string = shardName, ): Promise { - const builtinColumns = new Set(['id', 'created_at', 'updated_at']); - // [#12015] Both branches below drop a declared field named after a builtin - // column — the create branch skips it explicitly, the column-sync branch - // finds the column already present — so the shard path warns once here, - // ahead of either. - this.warnBuiltinColumnCollisions(shardName, obj.fields, builtinColumns, 'shard'); - const exists = await this.knex.schema.hasTable(shardName); - // ADR-0131 D1: a shard is created from the BASE's declaration, so it takes - // the base's tenant column NOT NULL — asked of the base through the - // recording resolver, the same answer the declared-index sync below uses - // (the shard has no bookkeeping of its own until `aliasShardBookkeeping`). - // A shard that already exists keeps the nullability it has: the - // organization-ownership ceremony reports such a shard, it never rebuilds it. - const baseTenantField = this.computeAndRecordTenantField(baseTable, obj); - // Commit d0e3a885b: a shard carries the base table's declared indexes (below), so its - // columns need the same keyable-text decision the managed path makes. - const keyedColumns = indexedKeyColumns({ - table: shardName, - fields: obj.fields ?? {}, - tenantField: this.resolveTenantField(shardName), - declaredIndexes: obj.indexes, - }); - if (!exists) { - await this.knex.schema.createTable(shardName, (table) => { - table.string('id').primary(); - this.createAuditTimestampColumn(table, 'created_at'); - this.createAuditTimestampColumn(table, 'updated_at'); - for (const [name, field] of Object.entries(obj.fields ?? {})) { - if (builtinColumns.has(name)) continue; - this.createColumn(table, name, field, keyedColumns.get(name), name === baseTenantField); - } - }); - } else { - const columnInfo = await this.knex(shardName).columnInfo(); - const existingColumns = Object.keys(columnInfo); - await this.knex.schema.alterTable(shardName, (table) => { - for (const [name, field] of Object.entries(obj.fields ?? {})) { - if (!existingColumns.includes(name)) { - this.createColumn(table, name, field, keyedColumns.get(name)); - } - } - }); - } - - // Declared indexes per shard. Auto-derived names already embed the shard - // name; explicit names get a shard prefix so they can't collide across - // shards in the same database. - const declared = obj.indexes; - if (Array.isArray(declared) && declared.length > 0) { - const colInfo = await this.knex(shardName).columnInfo(); - const perShard = declared.map((idx: any) => ({ - ...idx, - name: typeof idx?.name === 'string' && idx.name.trim() ? `${shardName}__${idx.name.trim()}` : undefined, - })); - // Shard bookkeeping is aliased AFTER this method runs, so `shardName` has - // no entry of its own yet — a declared `unique: 'organization'` index - // (ADR-0120 D1) must scope identically on every shard of the base table, - // so the answer has to come from the base. - // - // [commit 0f38ab084] Keyed by `baseTable`, and through the RECORDING resolver, not - // the bare `computeTenantField`. The bare one reads this call's schema - // alone, so a shard synced from a partial re-registration — one carrying - // no `tenancy` block — fell through to the implicit `organization_id` - // heuristic and gave the shard an organization key part the base table's - // own index does not have. `tenantOptOutByTable` already holds the base's - // explicit opt-out (`initObjects` recorded it under exactly this key, and - // `aliasShardBookkeeping` propagates it to the shard afterwards), so - // consulting it here is what makes the two paths answer the same. - await this.syncDeclaredIndexes( - shardName, - perShard, - new Set(Object.keys(colInfo)), - baseTenantField, - obj.fields ?? {}, - ); - } + // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). + return RotationFacet.ensureShardTable.call(rotationHost.of(this), shardName, obj, baseTable); } /** Quoted, deterministic column list for the rotation view. */ @@ -4488,243 +4352,11 @@ export class SqlDriver implements IDataDriver { // including the half no in-file gate could see: a SUBCLASS in another // published package overriding one of these methods with a narrower literal // (`TursoDriver.initObjects` shadowed #4311's `tenancy` fix for five weeks). - async initObjects( + initObjects( objects: Array<{ name: string; fields?: Record; tenancy?: any; indexes?: any[]; lifecycle?: any }>, ): Promise { - // The ADR-0104 media arm, resolved BEFORE registration because - // `registerObjectMetadata` below freezes `isJsonField`'s answer into - // `jsonFields` for every media column it sees. Memoized, and every failure - // path lands on the JSON arm — see {@link resolveFileColumnsMoved}. - await this.resolveFileColumnsMoved(); - - // In-memory registration FIRST, and deliberately ahead of the DDL gate - // below: being refused permission to alter a schema is not a reason to stay - // ignorant of the objects we were just told about. On a datasource we are a - // guest in (`schemaMode !== 'managed'`) the gate throws — and before this - // line ran here, it threw with every coercion registry still empty, so the - // very next write to a JSON field on that datasource was bound by the - // client's per-type defaults (see {@link registerObjectMetadata} for what - // node-postgres does with an array and with `[]`). Nothing in this call - // touches the database; the refusal it precedes is unchanged. - this.registerObjectMetadata(objects); - - // DDL gate (ADR-0015 §5.1): createTable/alterTable below mutate schema. - // Also covers `syncSchema`, which delegates here. - this.assertSchemaMutable('initObjects'); - - // [#13028] …and NOT while DDL is deferred. Under {@link deferredDdl} every - // branch below is skipped in favour of recording the work, so there is no - // DDL for a database to exist FOR — while `ensureDatabaseExists()` is the - // one line here that can WRITE: it `mkdir -p`s a sqlite parent directory - // and, on Postgres/MySQL, issues `SELECT 1` and CREATEs THE DATABASE when - // that comes back `3D000` / `ER_BAD_DB_ERROR`. - // - // That made a declared dry run able to create a database (#6743 closed the - // sqlite half by opening an absent file in memory; this is the same - // promise, kept at the source and for every dialect), and it charged one - // round-trip per object to do it — on a control plane at ~300ms RTT × ~80 - // objects, a `plan` that computes in seconds would have spent minutes - // asking a database it was never going to touch whether it existed. - // - // ⛔ NOT a relaxation of the guarantee for real DDL: `flushDeferredSchemaDdl` - // clears `deferredDdl` BEFORE re-entering this method with the deferred - // objects, so the confirmed `os migrate apply` still ensures the database - // ahead of the first `CREATE TABLE`. - if (!this.deferredDdl) await this.ensureDatabaseExists(); - - for (const obj of objects) { - // Re-read what the registration above recorded, rather than recomputing: - // `computeAndRecordTenantField` carries a sticky explicit-opt-out, so the - // recorded answer IS the answer. - const tableName = StorageNameMapping.resolveTableName(obj); - const tenantField = this.tenantFieldByTable[tableName] ?? null; - - // Deferred-DDL mode (#3917): the call above is in-memory metadata - // registration — coercion maps, tenancy, and the `managedObjectFields` - // entry `detectManagedDrift()` diffs against. Everything below issues - // DDL. `os migrate plan` / `apply` boot with the deferral armed so the - // plan is computed against the database as it actually is, and nothing - // is created until the operator has seen (and confirmed) the plan. - if (this.deferredDdl) { - this.deferredSchemaObjects.set(tableName, { ...obj, name: tableName }); - continue; - } - - // ADR-0057 P2: rotation-declared telemetry is physically time-sharded — - // the Rotator owns its DDL (shard tables + a read view under the base - // name); the plain create/alter path below would collide with the view. - const rotationPolicy = obj.lifecycle?.storage; - if (rotationPolicy?.strategy === 'rotation' && this.supportsRotation) { - this.tablesWithTimestamps.add(tableName); - await this.ensureRotation(tableName, obj, rotationPolicy); - continue; - } - - let exists = await this.knex.schema.hasTable(tableName); - // Recorded BEFORE the legacy-`_id` rebuild below can flip `exists`: a - // table that was here when we arrived stays "found" even if we then drop - // and recreate it. Re-syncing a table we made ourselves this boot — boot - // runs the sync more than once, and objects registered later sync on - // demand — is not evidence of anything preceding us, so it is ignored. - // See `getSchemaSyncStats`. - if (!exists) this.tablesCreatedHere.add(tableName); - else if (!this.tablesCreatedHere.has(tableName)) this.tablesFoundExisting.add(tableName); - - if (exists) { - const columnInfo = await this.knex(tableName).columnInfo(); - const existingColumns = Object.keys(columnInfo); - - if (existingColumns.includes('_id') && !existingColumns.includes('id')) { - await this.knex.schema.dropTable(tableName); - exists = false; - } - } - - // Columns created unconditionally by initObjects — skip them when - // iterating obj.fields to avoid duplicate-column errors (e.g. SQLite - // rejects CREATE TABLE with two columns of the same name). - const builtinColumns = new Set(['id', 'created_at', 'updated_at']); - - // Commit d0e3a885b: which columns this object's indexes will KEY ON, resolved before - // any DDL runs. `createColumn` needs it to decide whether a bounded text - // field takes `varchar(maxLength)` (keyable) or TEXT — a decision that is - // only makeable at CREATE time, since no dialect turns a TEXT column into - // a keyable one afterwards. - const keyedColumns = indexedKeyColumns({ - table: tableName, - fields: obj.fields ?? {}, - tenantField, - declaredIndexes: obj.indexes, - }); - - if (!exists) { - // [#12015] The `continue` below drops a declared field that collides - // with a builtin column. Said BEFORE the DDL runs, so the author hears - // it even when the CREATE goes on to fail for an unrelated reason. - this.warnBuiltinColumnCollisions(tableName, obj.fields, builtinColumns, 'create'); - try { - await this.knex.schema.createTable(tableName, (table) => { - table.string('id').primary(); - this.createAuditTimestampColumn(table, 'created_at'); - this.createAuditTimestampColumn(table, 'updated_at'); - if (obj.fields) { - for (const [name, field] of Object.entries(obj.fields)) { - if (builtinColumns.has(name)) continue; - // ADR-0131 D1: a fresh table's tenant column is NOT NULL. - this.createColumn(table, name, field, keyedColumns.get(name), name === tenantField); - } - } - }); - } catch (e: any) { - // #11565: MySQL charges every bounded column's DECLARED width against - // a per-ROW budget, and refuses the CREATE naming no column and no - // declaration — about a table its author described entirely in - // metadata. Re-throw the SAME failure carrying the field-level fix; - // anything that is not that refusal is re-thrown untouched. - await this.rethrowWithRowSizeExplanation(tableName, obj.fields, keyedColumns, null, e); - } - this.tablesWithTimestamps.add(tableName); - } else { - const columnInfo = await this.knex(tableName).columnInfo(); - const existingColumns = Object.keys(columnInfo); - - if (existingColumns.includes('updated_at')) { - this.tablesWithTimestamps.add(tableName); - } - - // [#12015] The ADD COLUMN diff is keyed on "column not already there", - // so a declared `id`/`created_at`/`updated_at` is dropped on this path - // too — the builtin is already in `existingColumns`, so the diff below - // never proposes it and `addedColumns` excludes it by name. Silent on - // every boot of an existing table until now. - this.warnBuiltinColumnCollisions(tableName, obj.fields, builtinColumns, 'alter'); - - // #11565: the row budget is a property of the WHOLE row, so adding one - // ordinary column to a wide table is refused by the width of columns - // nobody is touching — with the same column-less server error. Named - // here so the ADD COLUMN path is not the one that keeps it. - const addedColumns = Object.keys(obj.fields ?? {}).filter( - (name) => !builtinColumns.has(name) && !existingColumns.includes(name), - ); - try { - await this.knex.schema.alterTable(tableName, (table) => { - if (obj.fields) { - for (const [name, field] of Object.entries(obj.fields)) { - if (!existingColumns.includes(name)) { - this.createColumn(table, name, field, keyedColumns.get(name)); - } - } - } - }); - } catch (e: any) { - await this.rethrowWithRowSizeExplanation(tableName, obj.fields, keyedColumns, addedColumns, e); - } - } - - // Materialize the table's index set: field-level `unique` (tenancy-aware - // — #3696) plus object-level declared indexes (`indexes: [{ fields, - // unique }]`, carrying the multi-column UNIQUE guarantees dedup/ - // convergence paths rely on, ADR-0030). Both go through one path so the - // create and alter branches above cannot produce different constraints - // for the same metadata. Done after the table is created/altered so every - // referenced column physically exists — which is also why field-level - // `unique` can no longer be emitted inline by `createColumn`: a composite - // needs the tenant column to already be there. - const declaredIndexes = obj.indexes; - const uniqueFields = Object.values(obj.fields ?? {}).some((f) => - isUniqueScopeDeclared(f?.unique), - ); - if (uniqueFields || (Array.isArray(declaredIndexes) && declaredIndexes.length > 0)) { - const colInfo = await this.knex(tableName).columnInfo(); - const physicalColumns = new Set(Object.keys(colInfo)); - await this.syncTableIndexes( - tableName, - obj.fields ?? {}, - declaredIndexes, - tenantField, - physicalColumns, - ); - } - - // #2186: the additive sync above only ever ADDs tables/columns/indexes. - // For a table that already existed, detect (and in dev, auto-reconcile) - // any non-additive divergence between metadata and the physical schema — - // relaxed NOT NULL, widened varchar, orphaned column, and since #3728 the - // index dimension too (a legacy global unique that is now tenant-scoped, - // a redefined or orphaned index). - if (exists) { - await this.reconcileAndWarnDrift(tableName, obj.fields ?? {}, declaredIndexes); - } - - // #3912: converge this table's `Field.datetime` columns on the canonical - // UTC-text storage form. A table this call just CREATED has no rows, so it - // is canonical by construction — record that without touching the disk. - await this.backfillCanonicalDatetimes(tableName, exists); - // #3994: the `Field.time` twin of the line above. - await this.backfillCanonicalTimes(tableName, exists); - // Commit 4045b954d: converge this table's `Field.json` columns on the injective - // JSON-text storage form the rewritten `formatInput` now writes. - await this.backfillCanonicalJsonEncoding(tableName, exists); - // #3942: the MySQL twin — widen legacy `TIMESTAMP` columns to `DATETIME(3)`. - if (exists) await this.migrateMysqlDatetimeColumns(tableName, obj.fields ?? {}); - // #3994: widen legacy MySQL `TIME` columns to `TIME(3)`. - if (exists) await this.migrateMysqlTimeColumns(tableName, obj.fields ?? {}); - } - - // Pre-create the auto_number counter table now, while we hold a fresh pooled - // connection and are NOT inside any data transaction. Creating it lazily on - // the first autonumber INSERT dead-locks a `/api/v1/batch` write on SQLite - // (pool max=1: the open batch transaction owns the only connection, so the - // lazy `ensureSequencesTable` blocks forever acquiring a second one) and - // risks the same pool exhaustion under concurrent first-writes on - // Postgres/MySQL. Idempotent and skipped entirely when nothing uses - // auto_number, so it costs one `hasTable` at boot in the common case. - const usesAutoNumber = Object.values(this.autoNumberFields).some( - (cols) => Array.isArray(cols) && cols.length > 0, - ); - if (usesAutoNumber && !this.deferredDdl) { - await this.ensureSequencesTable(); - } + // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). + return RotationFacet.initObjects.call(rotationHost.of(this), objects); } /** @@ -8376,15 +8008,8 @@ export class SqlDriver implements IDataDriver { * `default_mismatch` producer is the #4560 runtime-token check. */ protected createAuditTimestampColumn(table: Knex.CreateTableBuilder, name: string): void { - if (this.isMysql) { - table.datetime(name, { precision: MYSQL_DATETIME_PRECISION }).defaultTo(this.nowColumnDefault('datetime')); - return; - } - if (this.isSqlite) { - table.timestamp(name).defaultTo(this.nowColumnDefault('datetime')); - return; - } - table.timestamp(name).defaultTo(this.knex.fn.now()); + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`). + return ColumnCreationFacet.createAuditTimestampColumn.call(columnCreationHost.of(this), table, name); } /** @@ -8910,70 +8535,8 @@ export class SqlDriver implements IDataDriver { * to the spec enters that pin automatically. */ protected varcharColumnChars(field: any, keyed?: { unique: boolean }): number | null { - // Multi-value is decided before the type switch in `createColumn` — a JSON - // column, whatever the element type would have been. [#17469] The question - // is {@link isMultiValuedColumn}'s, exactly as it is there, so this mirror - // cannot answer a different one. - const type = field?.type || 'string'; - if (isMultiValuedColumn(String(type), field)) return null; - switch (type) { - case 'string': - case 'email': - case 'url': - case 'phone': - case 'password': - return this.declaredVarcharLength(field); - case 'text': - case 'textarea': - case 'html': - case 'markdown': - case 'richtext': - case 'code': - // #11875 — moved out of the catch-all together with `createColumn`'s - // text-family case: the write seam now enforces their declared bound. - case 'signature': - case 'qrcode': - return keyed ? this.keyableTextLength(field) : null; - // Virtual — `createColumn` returns without emitting anything. - case 'formula': - return null; - // The non-string primitives: INTEGER / REAL / DECIMAL / BOOLEAN / DATE / - // DATETIME / TIME columns. None of them is a varchar, so none is sized - // from `maxLength` — which is the only question this mirror answers. - // ⚠️ Since #16318 the numeric members ARE sized, by `numericColumnFor`, - // but from the field's TYPE and not from any declaration; `null` here - // stays the correct answer to the question actually asked. - case 'integer': - case 'int': - case 'float': - case 'number': - case 'currency': - case 'percent': - case 'rating': - case 'slider': - case 'progress': - case 'summary': - case 'boolean': - case 'toggle': - case 'date': - case 'datetime': - case 'time': - return null; - default: - // `createColumn`'s catch-all, spelled the same way so the two cannot - // disagree about which types are JSON: everything else is - // `table.string(name)` at knex's default width. - // - // ⚠️ The FILE family first, and by the same deployment question the - // emitter asks — this mirror is what the drift detector consults - // INSTEAD of restating the emitter's cases, so a mirror that answered - // `null` for a moved media column would tell the detector no varchar - // exists where the emitter had just built one. - if (FILE_REFERENCE_TYPES.has(type)) { - return this.mediaColumnIsJson() ? null : MEDIA_ID_VARCHAR_CHARS; - } - return JSON_COLUMN_TYPES.has(type) ? null : SqlDriver.DEFAULT_STRING_VARCHAR_CHARS; - } + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`). + return ColumnCreationFacet.varcharColumnChars.call(columnCreationHost.of(this), field, keyed); } /** @@ -9175,481 +8738,9 @@ export class SqlDriver implements IDataDriver { field: any, keyed?: { unique: boolean }, tenantColumn: boolean = false, - ) { - // [#11567] Stated BEFORE the `multiple` short-circuit and before the type - // switch, because the spec's refusal is gated on neither: `FieldSchema` - // answers `unrecognized_keys` for `reference_to` on ANY field, whatever its - // type, and a multi-value lookup (a JSON column, returning immediately - // below) used to carry the key straight past this seam. One key, one - // answer, wherever it appears. - // - // `!== undefined` rather than `'reference_to' in field`: it refuses every - // value the key can actually carry — including the `null` and `''` the old - // truthy gate ignored — while staying immune to a producer that spreads an - // explicit `{ reference_to: undefined }`. Measured: `FieldSchema`'s own - // canonical output does NOT carry `reference_to` as an own key, so key - // presence would have been correct too; this is the narrower of two - // correct predicates. - if (field.reference_to !== undefined) refuseRejectedReferenceAlias(name); - - // [#17469] Was `if (field.multiple)`. The DDL writer and {@link isJsonField} - // are one source by the {@link JSON_COLUMN_TYPES} header's own contract - // ("so the two can't drift"), so the writer asks the same predicate the - // reader now asks. A field whose `multiple` the spec does not recognise on - // its type no longer gets a JSON column here — and `FieldSchema` refuses - // that declaration at the authoring entrance in the same ruling. - // - // [#17231] The short-circuit decides the column TYPE; it does not decide - // the column's CONSTRAINTS, and returning here used to mean it silently - // did. ADR-0113 P0 names this site verbatim — 「the physical constraint now - // keys off the explicitly-authored `storage.notNull` at that same - // `#createColumn` site」 — and carves out no field type: `storage.notNull`'s - // only declared exclusivity is `requiredWhen` (`FieldSchema.superRefine`), - // so `multiple: true` + `storage: { notNull: true }` is an authorable - // declaration this site was dropping on the floor. The differ, ADR-0113's - // other named consumer in this package, never had the gap — `fieldHasColumn` - // answers multi-value first and the nullability comparison then runs — so - // the platform reported DESTRUCTIVE `tighten_not_null` drift against tables - // it had itself just created, with no rows in them. - // - // ⚠️ NOT the drift ceremony this ADR routes around: `createColumn` runs on - // `CREATE TABLE` and on `ALTER TABLE ADD COLUMN`, so the column constrained - // here is always EMPTY — the same reason the string family's #11431 note - // below gives for narrowing a varchar here. Imposing `NOT NULL` over - // possibly-null data stays `tighten_not_null`, destructive, behind - // `os migrate apply --allow-destructive`, untouched. - // - // The column DEFAULT is deliberately still NOT emitted on this path: the - // multi-value shape has no scalar DDL form, and `os generate migration` - // skips it for the same reason (`declaredColumnDefault`), so the two - // producers already agree. - if (isMultiValuedColumn(String(field.type ?? ''), field)) { - const jsonCol = this.jsonColumn(table, name); - if (declaresColumnNotNull(field) || tenantColumn) jsonCol.notNullable(); - return; - } - - // [#16319] ⛔ Was `const type = field.type || 'string'`. See - // {@link refuseUndeclaredFieldType} for what that default cost and why the - // answer here is a refusal rather than a different guess. Asked after the - // multi-value short-circuit, exactly where the default stood — and a field - // with NO type is now refused rather than silently made a JSON column, - // which is this ruling's own direction: a type the spec never saw cannot - // carry a `multiple` the spec recognises. - if (!isDeclaredFieldType(field)) refuseUndeclaredFieldType(name); - const type: string = field.type; - let col: any; - switch (type) { - case 'string': - case 'email': - case 'url': - case 'phone': - case 'password': { - // #11431: the string family takes its declared `maxLength`, instead of - // knex's invented default of 255. - // - // The bound is the field's OWN — nothing is guessed. `schema-drift.ts` - // has always treated `varchar(field.maxLength)` as the EXPECTED - // physical shape of a bounded field (`widen_varchar` / - // `narrow_varchar` say so in as many words), so before this branch the - // differ and the emitter disagreed about every column the driver - // itself had just created. Measured on live MySQL 8.0.46 and Postgres - // 16 on the pre-fix tree: a `maxLength: 400` email, a `maxLength: 1024` - // url and a `maxLength: 20` phone all landed as `varchar(255)`, and the - // same table reported two `widen_varchar` (warning/safe) plus one - // `narrow_varchar` (error/DESTRUCTIVE) findings against itself, on a - // table with no rows in it. - // - // The write half is the reported defect. A 300-character value — - // legal under the declaration, and accepted by the record validator's - // own `max_length` check — was refused by BOTH enforcing dialects: - // MySQL `ER_DATA_TOO_LONG` under `STRICT_TRANS_TABLES`, Postgres - // `22001 value too long for type character varying(255)`. - // - // ⚠️ Both directions, and the narrowing one is NOT a destructive - // migration — a point worth stating because it reads like one: - // `createColumn` runs on `CREATE TABLE` and on `ALTER TABLE ADD - // COLUMN`, so the column it sizes is always EMPTY. Narrowing an - // EXISTING `varchar(255)` that holds rows stays exactly where it was - // before this change: the `narrow_varchar` drift op, category - // `destructive`, behind `os migrate apply --allow-destructive`. - // Nothing here plans an ALTER on an existing column, and the differ is - // untouched. What this removes is the permanent, self-inflicted drift - // report a fresh table used to raise against itself. - const declared = this.declaredVarcharLength(field); - col = declared === null ? table.text(name) : table.string(name, declared); - break; - } - case 'text': - case 'textarea': - case 'html': - case 'markdown': - // #11794: `richtext` and `code` join the text family, and membership is - // decided by a MEASURED test rather than by "this type's values look - // long". - // - // ## What makes an unbounded TEXT column correct for a type - // - // That the WRITE SEAM enforces the type's declared `maxLength` — the - // invariant the rest of this driver already rests on, stated in - // `schema-drift.ts` in as many words: "A TEXT column refuses nothing a - // `maxLength` allows … the bound is enforced at the write seam." So the - // question is not whether a value can be long, it is whether the - // declaration still binds once the column stops binding. - // - // objectql's record-validator applies its `max_length` / `min_length` - // branch to exactly the spec's BOUNDED_STRING_FIELD_TYPES — measured, - // not read off it: a `maxLength: 64` field of each member refuses a - // 100-character value with a field-named ADR-0112 envelope, before any - // column is reached. So membership here RESTORES the declared contract - // (any string, as `valueSchemaFor` says) instead of widening past it. - // - // `richtext` is the headline member: the spec groups `markdown` / `html` - // / `richtext` together as "Rich Content" (`field.zod.ts`) and two of - // the three already landed here — the third fell through to the - // catch-all's `table.string(name)`, knex's varchar(255), so an ordinary - // rich-text body over 255 characters was refused by both enforcing - // dialects while the same body in a `markdown` field on the same table - // was accepted. Measured at 1000 characters on live MySQL 8.0.46 - // (`ER_DATA_TOO_LONG` under `STRICT_TRANS_TABLES`) and Postgres 16 - // (`22001`). `code` is the same defect on the same evidence — a code - // editor's contents, refused identically on both dialects. - case 'richtext': - case 'code': - // #11875 (maintainer ruling 2026-08-25, option 1): `signature` and - // `qrcode` join under exactly the invariant above. #11794 measured them - // (same live refusal: a 1000-char data-URI landed varchar(255) and was - // refused `22001` on PG, `ER_DATA_TOO_LONG` on MySQL) and deliberately - // left them in the catch-all, because at that point NOTHING enforced - // their declared `maxLength` — an unbounded TEXT would have accepted - // values the declaration forbids. That gap is now closed at both seams: - // `FieldSchema` admits `maxLength` on them (BOUNDED_STRING_FIELD_TYPES) - // and the record-validator's `max_length` branch reads the same set, so - // a TEXT column here refuses nothing the declaration allows — the bound - // is enforced at the write seam, and the data-URI refusal goes away. - case 'signature': - case 'qrcode': { - // Commit d0e3a885b: a text-family column that some declared index KEYS ON is - // emitted as `varchar(maxLength)` rather than TEXT, whenever the field - // declared a bound this dialect can key on. - // - // MySQL refuses a TEXT/BLOB column in a key without a prefix length - // (`ER_BLOB_KEY_WITHOUT_LENGTH`), so an unbounded TEXT is not merely a - // slower key there — it is not a key at all: `CREATE TABLE` succeeds and - // the following `ALTER TABLE … ADD [UNIQUE] INDEX` fails, leaving the - // table on disk WITHOUT the constraint it declared. Measured on MySQL - // 8.0.46 before this branch existed: 36 of the 44 platform objects - // failed schema-sync that way, so an auth stack could not stand up its - // own schema on a driver `createDefaultDatasourceDriverFactory` maps - // `mysql`/`mysql2` onto. - // - // The bound is the field's OWN `maxLength` — nothing is invented here. - // `schema-drift.ts` already treats `varchar(field.maxLength)` as the - // expected physical shape of a bounded field (its `widen_varchar` / - // `narrow_varchar` ops say so in as many words); this is the emitter - // finally agreeing with the differ. There is no `Field.string` - // builder: none of `Field`'s keys is named `string`, `FieldType.options` - // omits it too, and `FieldSchema.safeParse({ type: 'string', … })` - // fails at `[type]` (#12593). What IS true of knex: its bare - // `table.string(name)` call (no length argument) is `varchar(255)`. - // This branch never calls it bare — `table.string(name, keyable)`, - // where `keyable` is the field's own declared `maxLength`, up to - // `MAX_KEYABLE_VARCHAR_CHARS` (768 chars, wider than knex's - // 255-char default). - // - // Applied on every dialect rather than under `isMysql`, deliberately: - // the alternative is one declaration with two enforcement answers, so - // the same app would refuse an over-length write on MySQL and accept it - // on Postgres. Dialect-divergent enforcement of one declared bound is - // the defect class this repo's conformance matrices exist to close, and - // a `varchar(n)` is exactly what the SQLite and Postgres columns land - // as for a field declared `Field.text({ maxLength: n })` on a keyed - // column — the nearest authorable spelling to this shape; there is no - // `Field.string` (#12593). - // - // ⚠️ Scope, both halves load-bearing: - // - KEYED only. A non-indexed `Field.text({ maxLength: 65000 })` stays - // TEXT: `varchar(65000)` on utf8mb4 is 260000 bytes and blows MySQL's - // 65535-byte ROW limit, which would turn a working table into an - // un-creatable one. Off-page TEXT has no such cost, and a column no - // index keys on has nothing to gain from a bound. - // - BOUNDED only. `maxLength` absent, or wider than a key part can be - // ({@link keyableTextLength}), leaves the column TEXT — see - // {@link explainUnkeyableTextColumn} for what happens next, which is - // a named refusal and never a silently weaker constraint. - const keyable = keyed ? this.keyableTextLength(field) : null; - col = keyable === null ? table.text(name) : table.string(name, keyable); - break; - } - case 'integer': - case 'int': - col = table.integer(name); - break; - // `float` is a DRIVER-SIDE ALIAS, not a `FieldType`: there is no - // `Field.float` builder and `NUMERIC_VALUE_TYPES` does not carry it, so - // #16318's table has no opinion about it and it keeps the column it has - // always had. - case 'float': - col = table.float(name); - break; - // [#16318] The seven members of `NUMERIC_VALUE_TYPES` take the physical - // representation `packages/spec` states for them ({@link - // numericColumnFor}) — the same table both `os generate migration` - // formats read, so one declaration can no longer produce three different - // columns (measured on live PostgreSQL 16.13: this arm's `real`, the sql - // format's `numeric(18,2)`/`numeric(5,2)`, and the typescript format's - // `numeric(8,2)`). The spec module carries the measurements, the ruling - // and the residual bound; ⛔ do not restate its numbers here. - // - // What this arm still owes its reader is the SQLite half, because that is - // why three of these types were put in a float arm in the first place: - // - // `rating`/`slider`/`progress` are authored as numeric scalars (a star - // count, a slider position, a percent-of-completion). Without an explicit - // case they fell to `default → table.string`, giving the column TEXT - // affinity so SQLite coerced the written number to a string ('4' not 4) — - // a silent type-fidelity leak the value-loss tests didn't catch. REAL - // affinity round-trips them as JS numbers (#field-zoo). - // - // That leak stays defeated, and MEASURED rather than argued: knex - // compiles `table.decimal(name, p, s)` and `table.float(name)` to the - // IDENTICAL `float` column on SQLite - // (`ColumnCompiler_SQLite3.prototype.decimal` is the literal `'float'`), - // so the six exact-decimal members emit byte-identical SQLite DDL to what - // this arm emitted before and keep REAL affinity. `rating` moves to - // INTEGER affinity, where SQLite stores `4` as an integer and still - // accepts `4.5` as a REAL — it refuses no fractional value — so nothing - // this dialect accepts today stops being accepted. - // - // ⚠️ The read path is what makes the move safe on the server dialects: - // node-postgres parses `numeric` to a STRING and `real` to a number, and - // it is `NUMERIC_SCALAR_TYPES`' existing `numericFields` coercion — - // already registered for all seven of these types — that turns it back - // into a JS number on the way out. - case 'number': - case 'currency': - case 'percent': - case 'rating': - case 'slider': - case 'progress': - case 'summary': { - const numeric = numericColumnFor(type); - // ⛔ Not `?? table.float(name)`: an undefined answer for a type named - // in these very case labels would mean the labels and - // `NUMERIC_VALUE_TYPES` have parted, and a silent fallback is exactly - // the drift #16318 exists to close. The spec-side pin - // (`numeric-column-representation.test.ts`) holds the two equal, and - // `NUMERIC_VALUE_TYPES` is the single membership authority both read. - col = - numeric === undefined || numeric.kind === 'integer' - ? table.integer(name) - : table.decimal(name, numeric.precision, numeric.scale); - break; - } - // `toggle` is a boolean rendered as a switch. Same leak as above (TEXT - // affinity stored '1'); a boolean column gives NUMERIC affinity and the - // `booleanFields` read-coercion below converts the stored 1/0 back to a - // real JS boolean. - case 'boolean': - case 'toggle': - col = table.boolean(name); - break; - case 'date': - col = table.date(name); - break; - case 'datetime': - // MySQL's `TIMESTAMP` is a 32-bit epoch: it cannot represent an instant - // outside 1970-01-01..2038-01-19 (a contract end date, a subscription - // expiry, a retention horizon — all rejected outright), it carries no - // fractional seconds by default so the canonical form's milliseconds are - // silently truncated, and it CONVERTS on read/write using the session - // timezone, which makes the stored instant depend on server config. - // `DATETIME(3)` has none of those properties: 1000..9999, milliseconds - // kept, and stored verbatim — so the column holds the UTC wall clock the - // driver writes, exactly as ServiceNow stores its MySQL timestamps - // (#3942). Postgres deliberately keeps `table.timestamp` → `timestamptz`: - // asking for precision 3 there would REDUCE it from microseconds. - col = this.isMysql ? table.datetime(name, { precision: MYSQL_DATETIME_PRECISION }) : table.timestamp(name); - break; - case 'time': - // MySQL's bare `TIME` is zero-precision and ROUNDS a fractional literal - // (`'14:30:00.500'` → `14:30:01`), so the canonical form's milliseconds - // would change the stored wall clock. `TIME(3)` keeps them — the - // `DATETIME(3)` precedent (#3942) applied to time-of-day (#3994). Knex's - // `time()` takes no precision, hence the explicit type. - col = this.isMysql ? table.specificType(name, 'time(3)') : table.time(name); - break; - // `user` is a lookup specialized to sys_user (ADR: lookup → sys_user). Same - // physical storage as any lookup: a string column holding the related row id - // (multiple ⇒ JSON, handled at the top of createColumn). No bespoke storage - // primitive — it shares this exact DDL path so reads/$expand/FK stay uniform. - case 'lookup': - case 'user': - // ⛔ #11431 deliberately STOPS at the string family and does not reach - // here, even though these branches spell the same `table.string(name)`. - // A lookup column does not hold the declared value — it holds the - // REFERENCED ROW'S ID, whose width is the parent table's `id` column, - // not anything this field declared. Measured on MySQL 8.0.46: the FK - // itself is content with mismatched widths (`varchar(20)` child → - // `varchar(255)` parent `id` creates cleanly, and Postgres 16 accepts - // it too), so the type system gives no warning — the refusal arrives on - // the first write too wide for the child, as `ERROR 1406 Data too long`. - // - // [#15522] ⛔ An id's width is NOT a fixed number, and it is NOT this - // field's to declare. Both halves are this driver's own behaviour: when - // the caller supplies no id it mints `nanoid(DEFAULT_ID_LENGTH)` — 16 - // characters, from the constant above and the three mint sites it feeds - // — and when the caller DOES supply one it is stored verbatim at - // whatever width the caller chose (measured on this arm's own driver: - // 10-, 17- and 18-character ids all landed unaltered). Nothing on this - // path bounds an id's width at all, so `maxLength: 20` already cannot - // hold a 24-character supplied id, and any `maxLength` under 16 cannot - // hold even a minted one. Honouring it here would make the column - // structurally incapable of holding ids it will be asked to hold — a - // strictly worse defect than the one #11431 fixes. - // - // ⛔ Do not restore the number this sentence used to carry. It called a - // platform id 26 characters long and spelled out a ULID - // (`01JQ8XKZ9M4N7P2R5T6V8W0Y3B`). `DEFAULT_ID_LENGTH` has been 16 for the - // whole life of this file and no ULID is minted anywhere in this repo, so - // at the real numbers the worked example did not hold either — 16 - // characters FIT in `varchar(20)`. Four `packages/cli` sites still cite - // this arm BY NAME for that number; they are filed as #16114, not fixed - // here. - // - // [#11567] ⛔ This arm emits NO `FOREIGN KEY`, and that is the ruled - // contract rather than an omission. It used to carry - // `if (field.reference_to) table.foreign(name)...` — gated on a key - // the spec REFUSES (see {@link refuseRejectedReferenceAlias}), so it - // could not fire for any spec-conformant lookup and never had: zero FK - // constraints existed across all 44 exported platform objects on live - // Postgres 16.13 and MySQL 8.0.46. Retiring it is therefore a no-op for - // every authored deployment, and it is pinned in the retiring direction - // by `sql-driver-11567-lookup-no-foreign-key.test.ts`. - // - // ⛔ Do NOT "restore" this by re-reading the canonical `reference`. - // Measured on a PRISTINE EMPTY database: doing so takes Postgres from - // 44/44 objects synced to 18/44 and MySQL from 37/44 to 15/44, with - // `relation "sys_user" does not exist` — no data involved. Two - // structural causes: `syncSchema` has no topological ordering, so a - // child reaches DDL before its parent; and three of the ten FK targets - // (`sys_file`, `sys_environment`, `sys_package_version`) are not among - // the platform objects at all (ADR-0003 puts them in `service-tenant` / - // `service-storage`). Referential integrity is the ENGINE's, via - // `deleteBehavior` — which is what `content/docs/protocol/objectql/ - // types.mdx` has told authors since 2026-07-30. - col = table.string(name); - break; - case 'auto_number': - case 'autonumber': - // ⛔ Also out of #11431's scope, for a different reason than `lookup` - // above: the value is issued by the RUNTIME from its sequence, not - // supplied by a caller. `maxLength` has no write-time counterpart on - // this type — the record validator's `max_length` check covers the - // textual types only, and an autonumber never reaches it at all - // (runtime-owned types are excluded from its door) — so binding the - // DDL to it would create a refusal with nothing declaring it: the - // platform's own generated record number rejected by a column, with no - // author to hand the error to. Widening `maxLength` to a second - // meaning ("how wide is the generated number") is a spec decision, not - // a driver one. - col = table.string(name); - break; - case 'formula': - return; // Virtual — no column - default: - // Array/object-valued types are stored as a JSON column. Driven by the - // single `JSON_COLUMN_TYPES` source so this DDL switch and `isJsonField` - // (the read-side deserializer) can never drift — the drift between them - // is exactly what let array-valued fields reach the binder un-serialized - // (#field-zoo). Everything else is a plain string. - // - // ⛔ The third branch #11431 leaves alone. This is the CATCH-ALL, and - // MOST of what lands in it is the set of types whose stored value is - // NOT the declared value, or is short by construction: `secret` - // persists an opaque `sys_secret` ref rather than the credential it was - // given (ADR-0100), `select` / `radio` / `checkboxes` / `tree` store - // option machine names or ids, and `color` holds a color code. Sizing - // any of those from the author's `maxLength` would size the wrong - // string. (`code` used to be mis-listed here among the option-valued - // ones — measured in field-zoo it stores the editor's contents - // verbatim, which is why #11794 moved it to the text family above. - // `signature` / `qrcode` sat here as a measured OPEN DEFECT — their - // stored value IS the declared value, routinely a data-URI far past - // 255 chars, refused by both enforcing dialects — until #11875 gave - // the write seam their bound and moved them to the text family too.) - // - // A type that genuinely wants the bound belongs in the string-family - // case above, named — never acquired by falling through to here. - // - // ⚠️ The FILE family is asked separately, because its answer is a - // DEPLOYMENT fact rather than a property of the type (ADR-0104 - // addendum): a json column until this deployment's columns have moved, - // and then a `varchar` at the width the SQL generator already emits for - // it ({@link MEDIA_ID_VARCHAR_CHARS}) — not knex's default 255, which - // would be a new divergence between the two producers of this column. - // `multiple: true` media never reaches here: `createColumn` - // short-circuits on `multiple` above the type switch. - if (FILE_REFERENCE_TYPES.has(type)) { - col = this.mediaColumnIsJson() - ? this.jsonColumn(table, name) - : table.string(name, MEDIA_ID_VARCHAR_CHARS); - break; - } - col = JSON_COLUMN_TYPES.has(type) ? this.jsonColumn(table, name) : table.string(name); - } - - if (col) { - // NOTE: field-level `unique` is deliberately NOT emitted here (#3696). - // A column builder can only express a SINGLE-column constraint, but on a - // tenant-scoped object `unique: true` means unique *within the tenant* — - // a composite `(tenantField, field)` index. Emitting `col.unique()` here - // is what made every tenant share one global namespace, which collided - // head-on with the per-tenant autonumber sequence (each tenant counts - // from 1, so the second tenant's `PROD-00001` was rejected by an index it - // could not see). All unique materialization now goes through the single - // tenancy-aware path — {@link uniqueIndexesFromFields} + - // {@link syncDeclaredIndexes} — which runs after the table exists and can - // therefore build composites. `createColumn` owns type/nullability/ - // defaults only. - // - // ADR-0113: the physical NOT NULL comes from the EXPLICIT storage - // constraint, not from `required` — `required` is the write-time - // contract enforced by the record validator at the engine seam, and - // binding the DDL to it made every post-deploy tightening a - // destructive migration. - // - // ⚠️ NOTHING supplies `storage.notNull` on an author's behalf. The - // sentence that used to close this block said the opposite — that - // pre-17 sources "carry `storage.notNull` explicitly via the - // `field-required-notnull-explicit` conversion, so their columns come - // out exactly as they always did" — and it was measured false from both - // ends (#16693): a real `^17.0.0` app logged that conversion and got - // NULLABLE columns anyway, and the conversion itself has since been - // WITHDRAWN (maintainer ruling 2026-09-08), because stamping the - // constraint wherever `required: true` appears is the implication - // ADR-0113 abolished. So a column reaches `notNullable()` here because - // its author wrote `storage: { notNull: true }`, and for no other - // reason; a `required: true` field with no `storage` block gets a - // nullable column, at every protocol floor, on every dialect. - // - // [#17231] Asked through {@link declaresColumnNotNull} — the same - // question the multi-value short-circuit above now asks, stated once so - // this method's two exits cannot answer it differently again. - // - // ADR-0131 D1 (execution record 6285122667 on the v18 step 7 card): the - // table's tenant column is the one column NOT NULL WITHOUT a declaration - // — "there is no nullable tenant column anywhere". `tenantColumn` is - // passed only by the two CREATE TABLE sites ({@link initObjects}' create - // branch, {@link ensureShardTable}'s), never by an ADD COLUMN: a column - // added to a table that already holds rows is the organization-ownership - // ceremony's to tighten (ruling ② A, 6269955919), and no metadata says - // it — the injected tenant field stays `required: false` with no - // `storage`, and the differ's `ceremonyOwnedNullability` reads the - // difference as no drift. - if (declaresColumnNotNull(field) || tenantColumn) col.notNullable(); - this.applyDeclaredColumnDefault(col, field, type); - } + ): void { + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`). + return ColumnCreationFacet.createColumn.call(columnCreationHost.of(this), table, name, field, keyed, tenantColumn); } /** @@ -10211,17 +9302,22 @@ export class SqlDriver implements IDataDriver { get schemaMode() { return driver.schemaMode; }, })); columnCreationHost.install((driver) => ({ + applyDeclaredColumnDefault: (...a) => driver.applyDeclaredColumnDefault(...a), declaredFieldsFor: (...a) => driver.declaredFieldsFor(...a), + declaredVarcharLength: (...a) => driver.declaredVarcharLength(...a), explainHashShadowDuplicate: (...a) => driver.explainHashShadowDuplicate(...a), explainRowSizeOverflow: (...a) => driver.explainRowSizeOverflow(...a), hashShadowColumnState: (...a) => driver.hashShadowColumnState(...a), hashShadowSourceColumns: (...a) => driver.hashShadowSourceColumns(...a), get isMysql() { return driver.isMysql; }, get isSqlite() { return driver.isSqlite; }, + jsonColumn: (...a) => driver.jsonColumn(...a), keyableTextLength: (...a) => driver.keyableTextLength(...a), get knex() { return driver.knex; }, logDurabilityFailure: (...a) => driver.logDurabilityFailure(...a), get logger() { return driver.logger; }, + mediaColumnIsJson: (...a) => driver.mediaColumnIsJson(...a), + nowColumnDefault: (...a) => driver.nowColumnDefault(...a), rowWidthProfile: (...a) => driver.rowWidthProfile(...a), schemaBytesPerChar: (...a) => driver.schemaBytesPerChar(...a), varcharColumnChars: (...a) => driver.varcharColumnChars(...a), @@ -10410,13 +9506,22 @@ export class SqlDriver implements IDataDriver { rotationHost.install((driver) => ({ assertSchemaMutable: (...a) => driver.assertSchemaMutable(...a), get autoNumberFields() { return driver.autoNumberFields; }, + backfillCanonicalDatetimes: (...a) => driver.backfillCanonicalDatetimes(...a), + backfillCanonicalJsonEncoding: (...a) => driver.backfillCanonicalJsonEncoding(...a), + backfillCanonicalTimes: (...a) => driver.backfillCanonicalTimes(...a), get booleanFields() { return driver.booleanFields; }, computeAndRecordTenantField: (...a) => driver.computeAndRecordTenantField(...a), computeTenantField: (...a) => driver.computeTenantField(...a), + createAuditTimestampColumn: (...a) => driver.createAuditTimestampColumn(...a), + createColumn: (...a) => driver.createColumn(...a), get dateFields() { return driver.dateFields; }, get datetimeFields() { return driver.datetimeFields; }, + get deferredDdl() { return driver.deferredDdl; }, + get deferredSchemaObjects() { return driver.deferredSchemaObjects; }, get dialectName() { return driver.dialectName; }, + ensureDatabaseExists: (...a) => driver.ensureDatabaseExists(...a), ensureRotation: (...a) => driver.ensureRotation(...a), + ensureSequencesTable: (...a) => driver.ensureSequencesTable(...a), get fractionalNumericFields() { return driver.fractionalNumericFields; }, isJsonField: (...a) => driver.isJsonField(...a), get isMysql() { return driver.isMysql; }, @@ -10429,16 +9534,27 @@ export class SqlDriver implements IDataDriver { get managedObjectFields() { return driver.managedObjectFields; }, get managedObjectIndexes() { return driver.managedObjectIndexes; }, get mediaFields() { return driver.mediaFields; }, + migrateMysqlDatetimeColumns: (...a) => driver.migrateMysqlDatetimeColumns(...a), + migrateMysqlTimeColumns: (...a) => driver.migrateMysqlTimeColumns(...a), nowColumnDefault: (...a) => driver.nowColumnDefault(...a), get numericFields() { return driver.numericFields; }, get numericValueFields() { return driver.numericValueFields; }, get organizationObjectRegistered() { return driver.organizationObjectRegistered; }, set organizationObjectRegistered(v) { driver.organizationObjectRegistered = v; }, get physicalKeyIndexes() { return driver.physicalKeyIndexes; }, + reconcileAndWarnDrift: (...a) => driver.reconcileAndWarnDrift(...a), registerManagedObjectMetadata: (...a) => driver.registerManagedObjectMetadata(...a), + registerObjectMetadata: (...a) => driver.registerObjectMetadata(...a), + resolveFileColumnsMoved: (...a) => driver.resolveFileColumnsMoved(...a), + resolveTenantField: (...a) => driver.resolveTenantField(...a), + rethrowWithRowSizeExplanation: (...a) => driver.rethrowWithRowSizeExplanation(...a), get rotationStateByTable() { return driver.rotationStateByTable; }, runWideningAlters: (...a) => driver.runWideningAlters(...a), get supportsRotation() { return driver.supportsRotation; }, + syncDeclaredIndexes: (...a) => driver.syncDeclaredIndexes(...a), + syncTableIndexes: (...a) => driver.syncTableIndexes(...a), + get tablesCreatedHere() { return driver.tablesCreatedHere; }, + get tablesFoundExisting() { return driver.tablesFoundExisting; }, get tablesWithTimestamps() { return driver.tablesWithTimestamps; }, get tenantFieldByTable() { return driver.tenantFieldByTable; }, get tenantOptOutByTable() { return driver.tenantOptOutByTable; }, @@ -10446,6 +9562,7 @@ export class SqlDriver implements IDataDriver { get undeclaredAuditTimestampFields() { return driver.undeclaredAuditTimestampFields; }, get updatedAtColumnState() { return driver.updatedAtColumnState; }, get valueShapeFields() { return driver.valueShapeFields; }, + warnBuiltinColumnCollisions: (...a) => driver.warnBuiltinColumnCollisions(...a), })); schemaIntrospectionHost.install((driver) => ({ get knex() { return driver.knex; }, diff --git a/scripts/check-keyed-text-bounds.mjs b/scripts/check-keyed-text-bounds.mjs index 862a9e2b540..c352fe3dfb9 100644 --- a/scripts/check-keyed-text-bounds.mjs +++ b/scripts/check-keyed-text-bounds.mjs @@ -219,13 +219,14 @@ * an unboundable column may only be keyed by a UNIQUE index. */ -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; import { blank, maskComments, maskCommentsAndLiterals, scanSource } from './js-comment-mask.mjs'; +import { readSqlDriverSource } from './sql-driver-split-source.mjs'; // ── The self-test's own battery roster and floor (#13489) ────────────────── // @@ -244,7 +245,7 @@ import { blank, maskComments, maskCommentsAndLiterals, scanSource } from './js-c // must not red. A battery BELOW its floor means cases stopped running; the // remedy is to find what stopped registering. const SELF_TEST_BATTERIES = Object.freeze({ - 'the emitter-derived family': 3, + 'the emitter-derived family': 6, 'the detector FIRES': 15, 'the detector STAYS SILENT': 8, 'refusals: the shapes it will not guess at': 11, @@ -330,7 +331,11 @@ const EXPECTED_TEXT_FAMILY = [ 'code', 'html', 'markdown', 'qrcode', 'richtext', 'signature', 'text', 'textarea', ]; -/** Where the emitter's answer is read from. */ +/** + * Where the emitter's answer is read from: `SqlDriver`, read as one text through + * `sql-driver-split-source.mjs` (each codemod stub's body from the facet module + * it moved to, #30147). + */ const EMITTER_FILE = join('packages', 'drivers', 'driver-sql', 'src', 'sql-driver.ts'); /** @@ -629,11 +634,17 @@ function lineAt(starts, offset) { * extraction that cannot find its anchor is a refusal, never an empty set. */ export function readTextFamilyFromEmitter(root) { + // `SqlDriver` as one text: the codemod moved `varcharColumnChars`' body to a + // facet module and left a one-call stub here, so each stub's body is read + // where it moved (#30147). A stub that reader cannot follow is a refusal. let source; try { - source = readFileSync(join(root, EMITTER_FILE), 'utf8'); - } catch { - return { error: `cannot read ${EMITTER_FILE} -- the emitter this gate reads its text family from.` }; + source = readSqlDriverSource(join(root, dirname(EMITTER_FILE))).text; + } catch (error) { + if (error?.code === 'ENOENT' && !existsSync(join(root, EMITTER_FILE))) { + return { error: `cannot read ${EMITTER_FILE} -- the emitter this gate reads its text family from.` }; + } + return { error: `cannot read the emitter's text out of ${EMITTER_FILE}: ${error?.message ?? error}` }; } const { struct, masked } = project(source); const anchor = struct.indexOf(EMITTER_ANCHOR); @@ -1468,6 +1479,30 @@ export function selfTest() { movedFamily.fatal === undefined && familyProblem(movedFamily.family) !== null, JSON.stringify(movedFamily.family)); + // The facade form (#30147): the codemod leaves `varcharColumnChars` on the + // class as a one-call stub and moves its body to a facet module. The family + // is read through the stub, and a stub that cannot be followed refuses. + const facetRel = join(dirname(EMITTER_FILE), 'sql-driver-column-creation.ts'); + const splitHost = (ret) => 'export class SqlDriver {\n' + + ' protected varcharColumnChars(field: any, keyed?: { unique: boolean }): number | null {\n' + + ' // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`).\n' + + ` ${ret}\n` + + ' }\n}\n'; + const stubCall = 'return ColumnCreationFacet.varcharColumnChars.call(columnCreationHost.of(this), field, keyed);'; + const facetModule = (cls) => `export class ${cls} {\n` + + EMITTER_STUB.replace('protected varcharColumnChars(', 'static varcharColumnChars(this: any, ') + '}\n'; + const object = { 'packages/p/src/a.object.ts': objectFile(`{ name: 'o', fields: {}, indexes: [] }`) }; + const split = run({ ...object, [facetRel]: facetModule('ColumnCreationFacet') }, { emitter: splitHost(stubCall) }); + t('the facade form: the family is read through the stub, from the facet module the body moved to', + split.fatal === undefined && familyProblem(split.family) === null, JSON.stringify(split.fatal ?? split.family)); + const lostFacet = run({ ...object, [facetRel]: facetModule('OtherFacet') }, { emitter: splitHost(stubCall) }); + t('a stub naming a facet the module does not declare REFUSES rather than reading the stub', + lostFacet.fatal !== undefined && /declares no class ColumnCreationFacet/.test(lostFacet.fatal), lostFacet.fatal); + const oddStub = run({ ...object, [facetRel]: facetModule('ColumnCreationFacet') }, + { emitter: splitHost('return varcharColumnChars(field, keyed);') }); + t('a marked stub of any other shape REFUSES rather than reading the stub', + oddStub.fatal !== undefined && /is not one `return Facet\.…` stub/.test(oddStub.fatal), oddStub.fatal); + // ── the detector FIRES ──────────────────────────────────────────────── battery('the detector FIRES'); for (const type of EXPECTED_TEXT_FAMILY) { diff --git a/scripts/codemod-split-sql-driver.mjs b/scripts/codemod-split-sql-driver.mjs index a608e4b947e..6906d3f38aa 100644 --- a/scripts/codemod-split-sql-driver.mjs +++ b/scripts/codemod-split-sql-driver.mjs @@ -150,12 +150,14 @@ import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { createRequire } from 'node:module'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; +import { readSqlDriverSource } from './sql-driver-split-source.mjs'; import { parseSourceFile } from './ts-parse.mjs'; const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); @@ -244,11 +246,15 @@ const HOSTS_CLASS = 'FacetHost'; * top-level declaration of the name stays. Each row names the reader. A row * naming nothing the file declares stops the script (exit 2), so the table * cannot rot into names nobody reads. + * + * Empty since #30147: its two rows were `createColumn` and + * `createAuditTimestampColumn`, sliced by four `packages/cli` generate-* pins + * and `scripts/check-keyed-text-bounds.mjs`. Those readers now read `SqlDriver` + * through `scripts/sql-driver-split-source.mjs`, which follows each stub this + * script writes to the body it moved, so the two members move. The table stays + * for the next reader that takes the file's own text. */ const PATH_READER_PINS = Object.freeze({ - createColumn: 'packages/cli generate-{field-type-vocabulary,string-family-width,multiple-json-column} pins and ' - + 'scripts/check-keyed-text-bounds.mjs slice `protected createColumn(` to its type switch', - createAuditTimestampColumn: 'packages/cli generate-builtin-id-column pin reads 600 characters after its signature', }); /** @@ -259,18 +265,14 @@ const PATH_READER_PINS = Object.freeze({ * A reader that also reads the leaf (`sql-driver-top-level.ts`) has its rows in * `CLASS_PATH_READER_NEEDLES` below instead: there a top-level declaration * carrying the text may move, and only a method carrying it stays. + * + * Empty since #30147: its ten rows were the literals the same readers + * required (the id and audit column builders, the media-id width, the keyed + * text arm, the JSON and file-reference arms). They read the joined source, + * leaf included, so `ensureShardTable`, `initObjects` and `varcharColumnChars` + * move, and so do the two top-level declarations that carried a row. */ const PATH_READER_NEEDLES = Object.freeze({ - "table.string('id').primary();": 'packages/cli generate-builtin-id-column and generate-field-type-vocabulary pins', - 'table.timestamp(name).defaultTo(this.knex.fn.now());': 'packages/cli generate-builtin-id-column pin', - '...STRUCTURED_JSON_TYPES, ...MULTI_OPTION_TYPES,': 'packages/cli generate-field-type-vocabulary and generate-string-family-width pins', - 'const MEDIA_ID_VARCHAR_CHARS = ': 'packages/cli generate-file-reference-width and generate-field-type-vocabulary pins', - 'table.string(name, MEDIA_ID_VARCHAR_CHARS)': 'packages/cli generate-file-reference-width and generate-field-type-vocabulary pins', - 'this.createColumn(table, name, field, keyedColumns.get(name));': 'packages/cli generate-string-family-width pin', - 'const keyedColumns = indexedKeyColumns({': 'packages/cli generate-string-family-width pin', - 'keyed ? this.keyableTextLength(field) : null': 'scripts/check-keyed-text-bounds.mjs EMITTER_ANCHOR and the generate-string-family-width pin', - 'JSON_COLUMN_TYPES.has(type) ? this.jsonColumn(table, name) : table.string(name)': 'packages/cli generate-field-type-vocabulary and generate-string-family-width pins', - 'if (FILE_REFERENCE_TYPES.has(type)) {': 'packages/cli generate-string-family-width pin', }); /** @@ -326,6 +328,7 @@ const RECEIVER_FREE_FIELDS = Object.freeze({ */ const EXPLICIT_RETURN_TYPES = Object.freeze({ applyFilters: 'void', + createColumn: 'void', createDatabase: 'Promise', ensureDatabaseExists: 'Promise', getBuilder: 'knex.Knex.QueryBuilder', @@ -2385,8 +2388,9 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'byte-identity proof': 10, 'refusals': 6, 'codemod-limit retirements (#30147)': 31, + 'split readers (#30147)': 8, }); -const SELF_TEST_BATTERY_FLOOR = 6; +const SELF_TEST_BATTERY_FLOOR = 7; const FIXTURE_SECTIONS = Object.freeze({ 'Lifecycle': 'sql-driver-lifecycle.ts', 'Kept': null }); const FIXTURE_HEAD = Object.freeze(['Head', 'sql-driver-head.ts']); @@ -2860,6 +2864,51 @@ ${pad} check('--help is answered, never refused, whatever sql-driver.ts holds', (await main(['--help']).catch(() => 2)) === 0); } + open('split readers (#30147)'); + { + // `sql-driver-split-source.mjs` hands the by-path readers `SqlDriver` as + // one text again. Driven over a split this script generates, written to a + // directory the way `--write` lays it out. + const input = fixtureSource(); + const out = generate(tm, input, fixturePlan(tm, input)); + const dir = mkdtempSync(join(tmpdir(), 'codemod-split-reader-')); + const lay = (host, modules) => { + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, HOST_BASENAME), host); + for (const [name, text] of modules) writeFileSync(join(dir, name), text); + }; + const read = () => { try { return readSqlDriverSource(dir); } catch (e) { return { error: e.message }; } }; + try { + lay(out.host, out.modules); + const r = read(); + const base = parse(tm, HOST_BASENAME, input).getClassOrThrow(CLASS_NAME); + const baseBody = (name) => (base.getMethod(name) ?? base.getGetAccessor(name))?.getBody()?.getText(); + const stubs = (out.host.match(new RegExp(`// ${MARK.replace(/[[\]#]/g, '\\$&')} The body moved to`, 'g')) ?? []).length; + check('every stub the split writes is followed', !r.error && r.spliced.length === stubs && stubs > 0, + r.error ?? `${r.spliced.length} followed of ${stubs}`); + check('each followed stub reads its base body, byte for byte, after the signature the class keeps', + !r.error && r.spliced.every(({ member }) => baseBody(member) && r.text.includes(baseBody(member))), + r.error ?? r.spliced.filter(({ member }) => !r.text.includes(baseBody(member) ?? '\0')).map((x) => x.member).join()); + check('no stub text survives in the joined source', !r.error && !r.text.includes(`${MARK} The body moved to`)); + check('the leaf is read and stands ahead of the class', + !r.error && r.files.includes(LEAF_MODULE) && r.text.indexOf('const LOCAL_VALUE = 1;') >= 0 + && r.text.indexOf('const LOCAL_VALUE = 1;') < r.text.indexOf(`export class ${CLASS_NAME}`)); + lay(input, new Map()); + const unsplit = read(); + check('an unsplit sql-driver.ts is read as it stands', !unsplit.error && unsplit.text === input && unsplit.spliced.length === 0); + lay(out.host, new Map([...out.modules].filter(([name]) => name !== 'sql-driver-lifecycle.ts'))); + check('a stub naming a module that is not there refuses', /does not exist/.test(read().error ?? ''), read().error); + const mod = out.modules.get('sql-driver-lifecycle.ts'); + lay(out.host, new Map([...out.modules, ['sql-driver-lifecycle.ts', mod.replace('static async movable(', 'static async renamed(')]])); + check('a stub whose facet lacks the static refuses', /has no static `movable`/.test(read().error ?? ''), read().error); + lay(out.host.replace(/return LifecycleFacet\.movable\.call\(lifecycleHost\.of\(this\)/, 'return LifecycleFacet.movable.call(this'), out.modules); + check('a marked stub of another shape refuses rather than reading the stub', /is not one `return Facet/.test(read().error ?? ''), read().error); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + const declared = Object.keys(SELF_TEST_BATTERIES); if (declared.length < SELF_TEST_BATTERY_FLOOR) { failed += 1; diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index 4c46eed637d..5c9a92ecfaf 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -688,6 +688,27 @@ export const CROSS_PACKAGE_TEST_INPUTS = { // cli's whole suite on every driver commit. 'packages/drivers/driver-sql/src/sql-driver.ts', 'packages/drivers/driver-sql/src/schema-drift.ts', + // The facade form of that first file (#30147). The codemod + // (`scripts/codemod-split-sql-driver.mjs`) left `createColumn` and the + // other members those pins slice on the class as one-call stubs and moved + // their bodies into `sql-driver-*.ts` facet modules, and the top-level + // declarations into the leaf. The five generate-* pins read `SqlDriver` + // through `sql-driver-split-source.mjs`, which follows each stub to the + // module it names, so every one of those modules is now read, by a path + // built out of the stub's own text -- the shape this scan cannot name, + // hence the witnesses below. A glob rather than the modules by name: the + // set is the codemod's section table, and a module it adds is read on the + // day it is written. It also covers the package's `sql-driver-*.test.ts` + // files, and that costs no re-run the suite does not already make: + // `@objectstack/cli#test` depends on `build`, which depends on `^build`, + // and driver-sql's `build` hashes `$TURBO_DEFAULT$` (every tracked file in + // the package, tests included). + 'packages/drivers/driver-sql/src/sql-driver-*.ts', + // The reader itself and its types, the import-shaped coupling the + // `cli-unbuilt-workspace-lead` trio above has: what it returns IS the text + // those pins judge, and the `.d.mts` is an input to the typecheck verdict. + 'scripts/sql-driver-split-source.mjs', + 'scripts/sql-driver-split-source.d.mts', // `field.zod.ts` is the third entry no test READS -- named in the header // of src/commands/generate-field-type-vocabulary.pin.test.ts, which cites // it as the file `git log -S` was run over to establish that the ghost @@ -746,6 +767,31 @@ export const CROSS_PACKAGE_TEST_INPUTS = { 'packages/create-objectstack/src/templates/blank/src/**': [ 'packages/cli/test/create-objectstack-wiring-parity.test.ts', ], + // `readSqlDriverSource` builds both from its argument and from the stubs' + // text (#30147), so no literal names them; these pins read through it. + 'packages/drivers/driver-sql/src/sql-driver.ts': [ + 'packages/cli/src/commands/generate-builtin-id-column.pin.test.ts', + 'packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts', + 'packages/cli/src/commands/generate-file-reference-width.pin.test.ts', + 'packages/cli/src/commands/generate-multiple-json-column.pin.test.ts', + 'packages/cli/src/commands/generate-string-family-width.pin.test.ts', + ], + // The `.d.mts` is resolved by the typecheck from the `.mjs` specifier, + // never named. + 'scripts/sql-driver-split-source.d.mts': [ + 'packages/cli/src/commands/generate-builtin-id-column.pin.test.ts', + 'packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts', + 'packages/cli/src/commands/generate-file-reference-width.pin.test.ts', + 'packages/cli/src/commands/generate-multiple-json-column.pin.test.ts', + 'packages/cli/src/commands/generate-string-family-width.pin.test.ts', + ], + 'packages/drivers/driver-sql/src/sql-driver-*.ts': [ + 'packages/cli/src/commands/generate-builtin-id-column.pin.test.ts', + 'packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts', + 'packages/cli/src/commands/generate-file-reference-width.pin.test.ts', + 'packages/cli/src/commands/generate-multiple-json-column.pin.test.ts', + 'packages/cli/src/commands/generate-string-family-width.pin.test.ts', + ], }, }, '@objectstack/client': { diff --git a/scripts/facade-delegation-registry.json b/scripts/facade-delegation-registry.json index 42bf8dcb624..0ac73758dd6 100644 --- a/scripts/facade-delegation-registry.json +++ b/scripts/facade-delegation-registry.json @@ -463,36 +463,6 @@ "card": "#30147", "reason": "codemod limit: inferred-return-type, a get accessor whose object type the base leaves inferred, with a docblock on each key (MOVED_ACCESSORS moves a getter whose type is written, #30147 phase 2a)" }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "ensureShardTable", - "card": "#30147", - "reason": "path-reader (#30105 report: packages/cli generate-* pins read its text in sql-driver.ts); its field-call: knex half is retired (#30147 phase 2a)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "initObjects", - "card": "#30147", - "reason": "path-reader (#30105 report: packages/cli generate-* pins read its text in sql-driver.ts); its field-call: knex half is retired (#30147 phase 2a)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "createAuditTimestampColumn", - "card": "#30147", - "reason": "path-reader (#30105 report: the packages/cli generate-builtin-id-column pin reads its text in sql-driver.ts)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "varcharColumnChars", - "card": "#30147", - "reason": "path-reader (#30105 report: check-keyed-text-bounds and the packages/cli generate-string-family-width pin read its text in sql-driver.ts)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "createColumn", - "card": "#30147", - "reason": "path-reader (#30105 report: check-keyed-text-bounds and the packages/cli generate-* pins slice its type switch in sql-driver.ts); inferred-return-type" - }, { "file": "packages/drivers/driver-sql/src/sql-driver.ts", "member": "applyFilterCondition", diff --git a/scripts/sql-driver-split-source.d.mts b/scripts/sql-driver-split-source.d.mts new file mode 100644 index 00000000000..7982f93bb1d --- /dev/null +++ b/scripts/sql-driver-split-source.d.mts @@ -0,0 +1,25 @@ +// Types for `sql-driver-split-source.mjs`, which the `@objectstack/cli` +// migration-generator pins import (#30147). Declared rather than inferred (no +// `allowJs`) for the reason `js-comment-mask.d.mts` gives: the module sits at +// the repo root, outside the consuming program's `rootDir`, and an untyped +// `.mjs` import there is TS7016. Keep this file in step with the module by hand. + +/** The file that holds `SqlDriver` and its stubs. */ +export declare const SQL_DRIVER_HOST: 'sql-driver.ts'; + +/** The module the top-level declarations ahead of the class moved to. */ +export declare const SQL_DRIVER_LEAF: 'sql-driver-top-level.ts'; + +/** + * `SqlDriver` as one text: `sql-driver.ts` with each codemod stub's body + * replaced by the body of the facet method it calls, and the leaf ahead of the + * class. Throws on a marked stub it cannot follow. + */ +export declare function readSqlDriverSource( + srcDir: string, + options?: { readFile?: (path: string) => string }, +): { + text: string; + spliced: { member: string; module: string }[]; + files: string[]; +}; diff --git a/scripts/sql-driver-split-source.mjs b/scripts/sql-driver-split-source.mjs new file mode 100644 index 00000000000..aa9be57747c --- /dev/null +++ b/scripts/sql-driver-split-source.mjs @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * sql-driver-split-source -- `SqlDriver`'s text read back as ONE source, for + * the readers that take it by path (#30147). + * + * `scripts/codemod-split-sql-driver.mjs` leaves each moved method on the class + * as a one-call stub and puts its body, byte for byte, on a static method of a + * facet class in a `sql-driver-*.ts` module; the top-level declarations ahead + * of the class go whole to the leaf, `sql-driver-top-level.ts`. A reader that + * slices a method out of `sql-driver.ts` (from its signature to a line of its + * body) or requires a literal to appear there then reads a stub, or nothing. + * So the codemod pinned every member and declaration such a reader named, and + * those pins are what kept the members on the class. + * + * {@link readSqlDriverSource} returns the text those readers were written + * against: `sql-driver.ts` with the body of every codemod stub replaced by the + * body of the facet method it calls, and the leaf put back ahead of the class, + * where its declarations stood. A method's signature (the one on the class, the + * contract) is followed by its own body, as it was before the split. It is a + * reading, not an undo: imports, a private method's docblock (moved with its + * body) and the stub-side spellings the codemod writes (an optional parameter + * for a computed default, a written return type) stay as the split has them. + * `--verify` in the codemod is the byte-for-byte undo. + * + * It follows exactly the stub the codemod writes and nothing else: a member + * body whose only statement carries the codemod's marker and returns + * `Facet.(…)`, `Facet..call(Host.of(this), …)` or the generic + * `(Facet.<…>)` spelling of either, with the module named in the marker. + * Any other body is left as it stands (a hand-written delegation keeps its own + * text). A marked body it cannot follow -- another shape, a missing module, no + * such facet class or static member -- is a REFUSAL (it throws), never the stub + * text: a reader handed the stub would be judging the one-line call instead of + * the method, which is the silent miss this module exists to prevent. + * + * ## Inert on import + * + * No CLI and no top-level statement that runs anything but the `typescript` + * load, per `check:entry-guard`. Its tests are the codemod's `--self-test` + * battery "split readers (#30147)", which drives it over a generated split. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { requireDefaultExport } from './import-prerequisite.mjs'; +import { parseSourceFile } from './ts-parse.mjs'; + +const ts = await requireDefaultExport('typescript', () => import('typescript'), import.meta.url); + +/** The class the stubs stand on, and the file that holds it. */ +export const SQL_DRIVER_HOST = 'sql-driver.ts'; +const CLASS_NAME = 'SqlDriver'; + +/** The module the top-level declarations moved to. */ +export const SQL_DRIVER_LEAF = 'sql-driver-top-level.ts'; + +/** The marker comment the codemod writes on every stub. */ +const MARK = '[#29520 codemod]'; +const MARKED = /\[#29520 codemod\] The body moved to `(sql-driver-[a-z0-9-]+\.ts)`/; + +/** `XFacet.m`, optionally parenthesised with type arguments. Returns `{ facet, member }` or null. */ +function facetMemberOf(expr) { + let e = expr; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (ts.isExpressionWithTypeArguments?.(e) || ts.isInstantiationExpression?.(e)) e = e.expression; + if (!ts.isPropertyAccessExpression(e) || !ts.isIdentifier(e.expression) || !ts.isIdentifier(e.name)) return null; + if (!/^[A-Z][A-Za-z0-9]*Facet$/.test(e.expression.text)) return null; + return { facet: e.expression.text, member: e.name.text }; +} + +/** The facet call a stub's `return` makes, as `{ facet, member }`, or null for any other shape. */ +function stubTarget(expr) { + if (!expr || !ts.isCallExpression(expr)) return null; + const callee = expr.expression; + // `XFacet.m.call(xHost.of(this), …)` / `(XFacet.m).call(xHost.of(this), …)` + if (ts.isPropertyAccessExpression(callee) && callee.name.text === 'call') { + const host = expr.arguments[0]; + const ok = host && ts.isCallExpression(host) + && ts.isPropertyAccessExpression(host.expression) + && ts.isIdentifier(host.expression.expression) + && /^[a-z][A-Za-z0-9]*Host$/.test(host.expression.expression.text) + && host.expression.name.text === 'of' + && host.arguments.length === 1 + && host.arguments[0].kind === ts.SyntaxKind.ThisKeyword; + return ok ? facetMemberOf(callee.expression) : null; + } + // `XFacet.m(…)` / `(XFacet.m)(…)` + return facetMemberOf(callee); +} + +/** The text between a block's braces. */ +function interior(text, block) { + return text.slice(block.getStart() + 1, block.end - 1); +} + +function memberName(m) { + return m.name && (ts.isIdentifier(m.name) || ts.isPrivateIdentifier(m.name)) ? m.name.text : null; +} + +/** + * `SqlDriver` as one text: `sql-driver.ts` with each codemod stub's body + * replaced by its facet body, and the leaf (when it exists) ahead of the class. + * + * @param {string} srcDir the directory holding `sql-driver.ts` (driver-sql's `src`) + * @param {{ readFile?: (path: string) => string }} [options] + * @returns {{ text: string, spliced: { member: string, module: string }[], files: string[] }} + * `spliced` names each stub followed and the module its body was read from; + * `files` every file read, the host first. + */ +export function readSqlDriverSource(srcDir, { readFile = (p) => readFileSync(p, 'utf8') } = {}) { + const hostText = readFile(join(srcDir, SQL_DRIVER_HOST)); + const files = [SQL_DRIVER_HOST]; + const spliced = []; + if (!hostText.includes(MARK)) return { text: hostText, spliced, files }; + + const host = parseSourceFile(SQL_DRIVER_HOST, hostText); + const cls = host.statements.find((s) => ts.isClassDeclaration(s) && s.name?.text === CLASS_NAME); + if (!cls) throw new Error(`${SQL_DRIVER_HOST} carries ${MARK} stubs but declares no class ${CLASS_NAME}`); + + const modules = new Map(); + const facetBody = (module, facet, member, at) => { + if (!modules.has(module)) { + const path = join(srcDir, module); + if (!existsSync(path)) throw new Error(`${at}: the stub names \`${module}\`, which does not exist`); + const text = readFile(path); + files.push(module); + modules.set(module, { text, sf: parseSourceFile(module, text) }); + } + const { text, sf } = modules.get(module); + const fc = sf.statements.find((s) => ts.isClassDeclaration(s) && s.name?.text === facet); + if (!fc) throw new Error(`${at}: \`${module}\` declares no class ${facet}`); + const m = fc.members.find((x) => memberName(x) === member && x.body + && ts.getCombinedModifierFlags(x) & ts.ModifierFlags.Static); + if (!m) throw new Error(`${at}: ${facet} in \`${module}\` has no static \`${member}\` with a body`); + return interior(text, m.body); + }; + + const edits = []; + for (const m of cls.members) { + const body = m.body; + if (!body || !ts.isBlock(body)) continue; + const name = memberName(m) ?? (ts.isConstructorDeclaration(m) ? 'constructor' : '?'); + const inner = interior(hostText, body); + const marked = inner.match(MARKED); + if (!inner.includes(MARK)) continue; + const at = `${SQL_DRIVER_HOST} ${CLASS_NAME}#${name}`; + const only = body.statements.length === 1 ? body.statements[0] : null; + const target = only && ts.isReturnStatement(only) ? stubTarget(only.expression) : null; + if (!marked || !target) { + throw new Error(`${at} carries ${MARK} but is not one \`return Facet.…\` stub this reader follows`); + } + edits.push({ start: body.getStart() + 1, end: body.end - 1, text: facetBody(marked[1], target.facet, target.member, at) }); + spliced.push({ member: name, module: marked[1] }); + } + + let text = hostText; + for (const e of edits.sort((a, b) => b.start - a.start)) text = text.slice(0, e.start) + e.text + text.slice(e.end); + // The leaf goes where its declarations stood: ahead of the class, which + // starts at the same offset in the spliced text (every edit is inside it). + const leafPath = join(srcDir, SQL_DRIVER_LEAF); + if (existsSync(leafPath)) { + const at = cls.getFullStart(); + text = `${text.slice(0, at)}\n${readFile(leafPath)}${text.slice(at)}`; + files.push(SQL_DRIVER_LEAF); + } + return { text, spliced, files }; +} diff --git a/turbo.json b/turbo.json index d97deb3f8cd..49eaeec9d91 100644 --- a/turbo.json +++ b/turbo.json @@ -308,6 +308,9 @@ "$TURBO_ROOT$/scripts/sync-scaffold-emission-policy.mjs", "$TURBO_ROOT$/packages/drivers/driver-sql/src/sql-driver.ts", "$TURBO_ROOT$/packages/drivers/driver-sql/src/schema-drift.ts", + "$TURBO_ROOT$/packages/drivers/driver-sql/src/sql-driver-*.ts", + "$TURBO_ROOT$/scripts/sql-driver-split-source.mjs", + "$TURBO_ROOT$/scripts/sql-driver-split-source.d.mts", "$TURBO_ROOT$/packages/spec/src/data/field.zod.ts", "$TURBO_ROOT$/packages/create-objectstack/src/templates/blank/objectstack.config.ts", "$TURBO_ROOT$/packages/create-objectstack/src/templates/blank/src/**",