Skip to content

Commit c1ae4a4

Browse files
refactor(driver-sql): move applyDriftOpInPlace's and rebuildSqliteTablePatched's bodies out of sql-driver.ts into sql-driver-schema-sync.ts (#25521 cut 6) (#26041)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X6RcybLaAtijgtf3Zm7ot2z)_ Part of #25521 Clause-②: no Metadata-protocol: none Ruling-ref: 6270097840 Ruling-ref: 6348891160 Cut 6 of the `sql-driver.ts` split (#24586 ruling A, recorded in `6270097840`). The claim is `6411690694`. The bodies of the two in-place drift reconcilers of schema sync, the protected `SqlDriver#applyDriftOpInPlace` and `SqlDriver#rebuildSqliteTablePatched`, move unedited into a new seam module, `packages/drivers/driver-sql/src/sql-driver-schema-sync.ts`, and reach the class through one named host, `SchemaSyncHost`. Both methods keep their names, docblocks, signatures (less `async`) and positions. It is move-only, and the card stays open for the later cuts. Branch `claude/project-thread-hq3kec` (platform-assigned to this card thread, as the claim records). The cut commit `381a4bb77d` sits on `origin/main` `05f7758f05`; `origin/main` was then merged in (`ebb210bf1b`, merge base `c16c00f930`). The 17 commits it brought touch no driver package. **Net line count (the seat's rule from ACCEPT `6385306916`).** `sql-driver.ts` goes from 21,512 lines to 21,398: **-114**. The two bodies are 152 lines; the two stubs are 33 body lines, and the new import with its comment is 5. The new module is 227 lines. **Serial with the file's in-flight PRs.** Re-measured at my start: 20 open PRs, each file list read in full. None touches `packages/drivers/driver-sql/**`, `driver-turso/**` or `driver-sqlite-wasm/**`, except the Version Packages PR #25640, which touches only those packages' `CHANGELOG.md` and `package.json`. Seat 1's #8740 (`SqlDriver.upsert`) has no open PR yet, and it is a different region; `insertOnlyUpsertColumns` is not touched. ## The area, measured on `05f7758f05` - `applyDriftOpInPlace` is lines 14859-14919 of `sql-driver.ts` (one-line docblock, 58 body lines). `rebuildSqliteTablePatched` is 14921-15049 (32-line docblock, 94 body lines). Both sit in the Schema sync section of cut 1's seam table, beside the `applyMigrationEntries` that calls them (it stays). - Both are protected with annotated return types (a promise of a boolean, and a promise of the honoured `ManagedDriftEntry` list), so the inferred-return limit does not bite. - Neither builds through `getBuilder`, so `check:tenant-chokepoint` rule 1 is untouched (24 bindings across 3 files at both ends). - Neither reads a static: an AST screen of the two bodies finds no `SqlDriver.` read and no cast-wrapped `this` read. The members they read are exactly `knex`, `logger`, `dialectName`, `isMysql`, `isPostgres`, `managedObjectFields`, `managedObjectIndexes`, and the methods `applyIndexDriftOp`, `introspectColumns`, `buildRebuiltColumn`, `nowColumnDefault`, `applyDeclaredColumnDefault`, `syncTableIndexes` and `resolveTenantField`. - Neither has a `this.FIELD.delete/insert/update(` site: the one map read is `this.managedObjectFields.get(table)`. `driver-sql` is outside the tenant-audit census corpus anyway (cut 1's reading). - `TursoDriver` and `SqliteWasmDriver` override none of the two methods or the members they call (`git grep` over both packages' `src`, tests excluded: 0 overrides). ## The shape (#23050 shape B, as cuts 1-5 apply it) - The class keeps both methods, docblock included, byte-identical except that `async` is dropped. Each body is now one host literal plus a `.call(host, ...)` of the moved function, marked `[#25521 cut 6]`. It returns the moved function's own promise, so a caller awaits the same settlement. - `SchemaSyncHost` lists the 14 members above, each typed as the class member (an indexed access on `SqlDriver`). Each moved function types its `this` as the Pick of the host over exactly the members its body reads (6 and 10), and each stub annotates its literal as the `ThisParameterType` of the moved function. There is no cast, and no member becomes public. - The fields `knex`, `logger`, `managedObjectFields` and `managedObjectIndexes`, the getter `dialectName` and the two dialect getters go as GETTERS that read the driver at call time. The methods go as late-bound closures that call them on the driver, so a subclass override or an instance spy is still the one the body reaches. - `this.logger.warn(...)` keeps its receiver: the getter hands back the driver's logger object and `warn` is called on it (the cut 2 shape, `rawStatementFault`). - **Module-level declarations.** The bodies read `isIndexDriftOp` and the types `DriftOp` and `ManagedDriftEntry`. All three live in `schema-drift.ts`, which exports them, so the module imports them from there (not a copy). `sql-driver.ts` still reads all three, so its import list does not change. No declaration gains an `export`; `index.ts` re-exports nothing from the new module. The module imports only the type `SqlDriver` from `sql-driver.ts`, so there is no value cycle. - The move was produced by a script from the source text, not retyped: each body line re-indented two columns. Neither body holds a multi-line template literal, so no line keeps its base indentation. ### Why `resolveHashShadowKeys` stays (the open call this cut was handed) Measured: its body is 25 lines and reads only `knex` (plus `isHashShadowCarrier` and `parseHashShadowKeyParts` from `schema-drift.ts`; no static), so a stub of about 6 lines would pay about -19. It stays in the class, for two reasons: - **The claim's file surface names one new or existing seam module.** Its only caller is cut 5's `introspectIndexes` body, so its home is `sql-driver-introspection.ts`; moving it there would edit a second seam module in this PR, and moving it into the new module would put a catalog-read enrichment into the drift-apply module. - **It shares nothing with this pair** but `knex`, so carrying it would not shrink this cut's host; it is its own slice, priced on its own. It is proposed below as a small follow-up slice into `sql-driver-introspection.ts`. ### Overrides and spies, measured - `sql-driver-schema-drift.test.ts` calls `applyDriftOpInPlace` on the driver after replacing `knex` and `logger` on the instance; the getters read the replacements. `sql-driver-unique-tenancy.test.ts` calls `rebuildSqliteTablePatched` on the driver. - A scratch probe against the built `dist` (not committed) gave 25 readings, **identical at base and at head** (diff empty). It covered: the honoured entries of a SQLite rebuild (relax and drop honoured, widen ignored, the same entry objects); an instance spy on `introspectColumns` (reached once); a subclass overriding `buildRebuiltColumn`, `syncTableIndexes`, `nowColumnDefault` and `resolveTenantField` (each reached, the same calls); columns and rows after the rebuild; a `managedObjectFields` map replaced on the instance after construction (its default re-materialized); an index op routed to a subclass `applyIndexDriftOp`; the SQLite unsupported-op warning through a logger replaced after construction, with `this` bound to the logger; the Postgres arm through a subclass `isPostgres` getter (six op types, the five statements and the `false`); the MySQL arm through a subclass `isMysql` getter (the restated `varchar(40)` and the missing-column `false`); a `knex.raw` that throws synchronously (still a rejected promise at both ends); and a failing `syncTableIndexes` after a rebuild (the same `warn` line). ## The published `.d.ts` does not change Under the series bar (ruling `6348891160`, letter A), measured on base `05f7758f05` and head `381a4bb77d`, each built with `pnpm --filter @objectstack/driver-sql build` against the same dependency tree (the base leg restores the base `sql-driver.ts` and removes the module in this worktree after the cut commit, under an EXIT/INT/TERM trap that restores HEAD; restore proved by blob hash and an empty `git diff HEAD`). Re-read at the merged head `ebb210bf1b`: the same. - This package emits `dist/index.d.ts` and `dist/index.d.mts` (no `.d.cts`); the two are identical at every end. - Raw sha256 of both files at base, head and merged head: `93da45168e82136b7613944525e47402d5dc6f9f8daa9efa4bdceeaa11b52a89` (cuts 1-5's hash, unchanged). `cmp` base vs head: identical, so the proof rests on raw identity and no normaliser is relied on. - Positive control on a copy of the head `dist/index.d.ts`: line 5604, `protected applyDriftOpInPlace(op: DriftOp)`, with `protected` removed, gives `464af7349dba2db4f27756062df5297f44af8deeef3604276d10eee4e6059d9c` and a 4-line diff. - Export tables: the ESM and CJS bundles each export the same 56 names at base, head and merged head (sorted lists `diff`-identical). - The head build carries the move: `.call(host` sites in `dist/index.mjs` and `dist/index.js` go 16 to 18. No changeset: `skip-changeset`, per ruling `6348891160` item 4 (move-only, no product behaviour change). ## Proof of move-only - **Body, by AST token** (recursive `getChildren` leaves, string and template pieces as their exact source text; comments compared line by line less leading whitespace). For each moved function, the parameters (less the added `this`), return type and body have the same token sequence as the base method, and `async` matches. `applyDriftOpInPlace`: 409 body tokens, 0 differ, 7 comments identical. `rebuildSqliteTablePatched`: 592 body tokens, 0 differ, 33 comments identical. - **Lines**: 156 lines of the two bodies compared (closing braces included); every one is the base line with two leading spaces removed. - **Negative control**: in a copy of the module, `PRAGMA foreign_keys = OFF` becomes `PRAGMA foreign_keys = 0FF`. The check then reports 1 token and 1 line different and exits `PROOF FAILED`, exit 1. - **Class side**: 347 members at base and head, in the same order. The 345 that did not move are byte-identical to base, leading trivia included. Each stub's leading trivia and signature equal the base's, less `async`. - **Top level of `sql-driver.ts`**: 202 non-class statements at base, 203 at head; all 202 base statements byte-identical at head. The one new statement is the `./sql-driver-schema-sync.js` import with its four-line comment. - **Size**: +267 / -154 over 2 files, far under the 3,000-line manual-merge line. ## Readers of `sql-driver.ts` by path, checked against the moved text None needed an edit: - **ADR anchors.** `scripts/adr-anchors/packages__drivers__driver-sql__src__sql-driver.ts.json` carries ADR-0053 (`formatOutput`, `presentReadValue`) and ADR-0120 D4 (the `recreate_index` ceremony, which lives in `applyIndexDriftOp` and stays). Neither paragraph describes a moved body, so per card ask 3 no entry is owed. The moved bodies cite no ADR. `check:adr-anchors`: 87 anchored files at both ends; decision-letter citations on the anchored surface **1,864 at base (`c16c00f930`, clean detached worktree) and 1,864 at head (`ebb210bf1b`)**. - **Symbol anchors.** No ADR, checklist or doc anchors `sql-driver.ts#applyDriftOpInPlace` or `#rebuildSqliteTablePatched` (`git grep` over `docs/`, `content/`, `scripts/`, `.github/`: 0 hits). `check:adr-symbol-anchors`: 2,658 anchors resolve at both ends. - **`check:tenant-chokepoint`.** Rule 1 verdict and population unchanged (above). Rule 2 visits raw-family calls in `SCAN_FILES`; the moved bodies' 13 raw calls (the Postgres and MySQL `ALTER TABLE` statements, the two `PRAGMA foreign_keys` toggles and the rebuild's `INSERT ... SELECT`) are DDL and a copy; none says `IS NULL` against a tenant column. Verdict unchanged; `.raw(` call sites in `sql-driver.ts` go 70 to 57 (see the acceptance notes). - **Logger receivers.** `check:logger-receiver-detach`: 0 detaches at both ends (3,717 files walked at base, 3,719 at head: the new module and the gitignored generated `packages/spec/src/migrations/registry.ts`, which the unbuilt base worktree lacks). `logger-receiver-detach.test.ts` ③ scans `sql-driver.ts` only; the two moved `this.logger.warn(` calls are direct calls, and the repo-wide gate covers the new module (cut 2's reading). - **Test comments** that name the two methods (`sql-driver-11321-sqlite-audit-default-canonical.test.ts`, `sql-driver-11722-sqlite-rebuild-applied-honesty.test.ts`, `schema-drift.ts`) and the `{@link rebuildSqliteTablePatched}` references in the class still point at the methods, which are still there. No test reads either body as source text. - `check:driver-conformance` at base and head: **55 covered, 0 in the DEBT ledger, 0 exempt, both times.** ## Verification - `tsc --noEmit` (driver-sql) exit 0. `eslint --no-inline-config` over `packages/drivers/driver-sql/src`: 266 files (the new module included), 0 errors, 0 warnings, exit 0. - Each suite ran under its own `os-verify-lock` call, exit 0 at every end. Compared per test name and status from vitest's JSON reporter, base `05f7758f05` vs head `381a4bb77d`, and again vs the merged head `ebb210bf1b`: - `packages/drivers/driver-sql`: 3,999 tests (3,787 passed, 0 failed, 212 skipped; 247 files) at every end; 0 lost, 0 changed, 0 added. - `packages/drivers/driver-turso`, against that end's built driver-sql `dist`: 2,526 tests (2,492 passed, 0 failed, 34 skipped; 95 files); 0 / 0 / 0 (2,513 distinct names). - `packages/drivers/driver-sqlite-wasm`: 704 tests (704 passed; 39 files); 0 / 0 / 0. - `dispatch-gates --commands` on this diff at the merged head `ebb210bf1b` (merge base `c16c00f930`, no STALE TREE): 55 derived commands, each run with its exit code captured before any pipe. `--ran` reconciles 55 derived, 54 run, 1 NOT MEASURED, 0 unrun. The NOT MEASURED one is `check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET: it needs every package's `dist`), which CI runs. `check:lean-entry-closure` first exited 3 (no `objectql` dist) and exits 0 after `turbo run build --filter=@objectstack/objectql`. - Also run, outside the derivation: `check:adr-anchors`, `check:adr-symbol-anchors`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity` (rosters under `packages/`), `check:commit-card-trailers` and its `--range origin/main..HEAD` (2 commits, clean): all exit 0. - No reverse verification or ablation: the diff changes no tested behaviour. ## Acceptance notes - **`check:tenant-chokepoint` rule 2 reads 13 fewer raw call sites in `sql-driver.ts`.** `SCAN_FILES` names `sql-driver.ts` and not the seam modules, so rule 2 no longer visits the moved DDL calls; the new module holds 13 `.raw(` sites. None is an arm on a tenant column today, but a future edit that added one in the module would not be caught by rule 2. Widening `SCAN_FILES` is a gate edit and the open precondition the series carries for the `getBuilder` areas; it is not made here. - **Proposed next slices.** (a) `resolveHashShadowKeys` into `sql-driver-introspection.ts` beside its only caller (about -19; `IntrospectionHost` already lists it and `knex`). (b) The rest of the drift-apply family next to this pair, `applyIndexDriftOp` (~14719) and `restoreBareIndexAfterFailedTighten` (~14836), into this cut's module: neither builds through `getBuilder`; both should be screened for statics and priced against the net-line rule at dispatch, and `applyIndexDriftOp` carries the ADR-0120 D4 ceremony, so its anchor entry paragraph would follow it into a new entry for the module. - The class methods now return the moved functions' promises instead of being `async` methods. A caller awaits the same settlement, and a synchronous throw inside a body still arrives as a rejection (probe reading above). --- _Generated by [Claude Code](https://claude.ai/code/session_01P1g134P91q7EHTr6mTkQz7)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8a990d8 commit c1ae4a4

2 files changed

Lines changed: 267 additions & 154 deletions

File tree

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25521 cut 6] The two in-place drift reconcilers of `SqlDriver`'s schema
5+
* sync — `applyDriftOpInPlace`, which applies one column drift op as a dialect
6+
* `ALTER` on Postgres and MySQL, and `rebuildSqliteTablePatched`, the SQLite
7+
* table rebuild that applies a set of them — moved out of `sql-driver.ts`
8+
* whole. `applyMigrationEntries`, which calls both, stays in the class, as do
9+
* the members the two bodies call (`applyIndexDriftOp`, `buildRebuiltColumn`,
10+
* `syncTableIndexes` and the rest).
11+
*
12+
* A MOVE, not a redesign. Each function below is the body of the `SqlDriver`
13+
* method of the same name as it stood, re-indented one level and otherwise
14+
* unedited: the same dialect statements, the same rebuild procedure, the same
15+
* honoured-entries contract.
16+
*
17+
* ## How the moved bodies reach the class
18+
*
19+
* The shape `sql-driver-introspection.ts` (cut 1) applies, after #23050. The
20+
* class keeps both methods, because the published `dist/*.d.ts` lists each
21+
* one's name, docblock and position, and tests call both on the driver. Each
22+
* body is now one literal of exactly the {@link SchemaSyncHost} members that
23+
* method reads, plus a `.call`. The methods are no longer `async`: each
24+
* returns the moved function's own promise, so a caller awaits the same
25+
* promise the body settles, with no extra wrapper.
26+
*
27+
* The methods the bodies call go as closures that call them on the driver at
28+
* call time, so a subclass override or a test spy on the instance is still the
29+
* one the body reaches. The fields `knex`, `logger`, `managedObjectFields` and
30+
* `managedObjectIndexes`, the getter `dialectName` and the two dialect getters
31+
* go as GETTERS, so each read happens when the body makes it, as it did on the
32+
* class; `this.logger.warn(...)` still calls `warn` on the logger itself.
33+
*
34+
* The module-level declarations the bodies read, `isIndexDriftOp` and the
35+
* types `DriftOp` and `ManagedDriftEntry`, live in `schema-drift.ts`, which
36+
* exports them, and are imported from there; `sql-driver.ts` still reads all
37+
* three. `index.ts` re-exports nothing from this module, so the published
38+
* surface does not move.
39+
*/
40+
41+
import { isIndexDriftOp, type DriftOp, type ManagedDriftEntry } from './schema-drift.js';
42+
import type { SqlDriver } from './sql-driver.js';
43+
44+
/**
45+
* The `SqlDriver` members the functions below read — every one of them, and
46+
* no other; each function's `this` is the `Pick` of the members IT reads. Each
47+
* is typed AS the class member (an indexed access on the class), so a change
48+
* to a member's signature on the class is a change here too, and naming a
49+
* member the class does not have is a compile error. See the module header
50+
* for which members are getters.
51+
*/
52+
export interface SchemaSyncHost {
53+
applyDeclaredColumnDefault: SqlDriver['applyDeclaredColumnDefault'];
54+
applyIndexDriftOp: SqlDriver['applyIndexDriftOp'];
55+
buildRebuiltColumn: SqlDriver['buildRebuiltColumn'];
56+
readonly dialectName: SqlDriver['dialectName'];
57+
introspectColumns: SqlDriver['introspectColumns'];
58+
readonly isMysql: SqlDriver['isMysql'];
59+
readonly isPostgres: SqlDriver['isPostgres'];
60+
readonly knex: SqlDriver['knex'];
61+
readonly logger: SqlDriver['logger'];
62+
readonly managedObjectFields: SqlDriver['managedObjectFields'];
63+
readonly managedObjectIndexes: SqlDriver['managedObjectIndexes'];
64+
nowColumnDefault: SqlDriver['nowColumnDefault'];
65+
resolveTenantField: SqlDriver['resolveTenantField'];
66+
syncTableIndexes: SqlDriver['syncTableIndexes'];
67+
}
68+
69+
/** The body of `SqlDriver#applyDriftOpInPlace`; the method's docblock there is its contract. */
70+
export async function applyDriftOpInPlace(this: Pick<SchemaSyncHost, 'applyIndexDriftOp' | 'dialectName' | 'isMysql' | 'isPostgres' | 'knex' | 'logger'>, op: DriftOp): Promise<boolean> {
71+
// Index ops need no dialect-specific ALTER — route them to the portable path.
72+
if (isIndexDriftOp(op)) return this.applyIndexDriftOp(op);
73+
const { table, column } = op;
74+
if (this.isPostgres) {
75+
switch (op.type) {
76+
case 'relax_not_null':
77+
await this.knex.raw('ALTER TABLE ?? ALTER COLUMN ?? DROP NOT NULL', [table, column]);
78+
return true;
79+
case 'tighten_not_null':
80+
await this.knex.raw('ALTER TABLE ?? ALTER COLUMN ?? SET NOT NULL', [table, column]);
81+
return true;
82+
case 'widen_varchar':
83+
case 'narrow_varchar':
84+
await this.knex.raw(`ALTER TABLE ?? ALTER COLUMN ?? TYPE varchar(${op.to})`, [table, column]);
85+
return true;
86+
case 'drop_column':
87+
await this.knex.raw('ALTER TABLE ?? DROP COLUMN ??', [table, column]);
88+
return true;
89+
case 'drop_column_default':
90+
await this.knex.raw('ALTER TABLE ?? ALTER COLUMN ?? DROP DEFAULT', [table, column]);
91+
return true;
92+
}
93+
}
94+
if (this.isMysql) {
95+
// MySQL MODIFY restates the FULL column definition — reconstruct the
96+
// type (with length for char types, so a nullability change never
97+
// silently drops a varchar's declared length) from columnInfo.
98+
const info: any = await this.knex(table).columnInfo();
99+
const ci: any = info?.[column];
100+
const colType: string | undefined = ci?.type
101+
? (/char/i.test(ci.type) && ci.maxLength ? `${ci.type}(${ci.maxLength})` : ci.type)
102+
: undefined;
103+
switch (op.type) {
104+
case 'relax_not_null':
105+
if (!colType) return false;
106+
await this.knex.raw(`ALTER TABLE ?? MODIFY ?? ${colType} NULL`, [table, column]);
107+
return true;
108+
case 'tighten_not_null':
109+
if (!colType) return false;
110+
await this.knex.raw(`ALTER TABLE ?? MODIFY ?? ${colType} NOT NULL`, [table, column]);
111+
return true;
112+
case 'widen_varchar':
113+
case 'narrow_varchar':
114+
await this.knex.raw(`ALTER TABLE ?? MODIFY ?? varchar(${op.to})`, [table, column]);
115+
return true;
116+
case 'drop_column':
117+
await this.knex.raw('ALTER TABLE ?? DROP COLUMN ??', [table, column]);
118+
return true;
119+
case 'drop_column_default':
120+
// `ALTER … ALTER COLUMN … DROP DEFAULT` — the one ALTER COLUMN form
121+
// MySQL accepts without restating the type, so it cannot lose a
122+
// varchar length the way MODIFY can.
123+
await this.knex.raw('ALTER TABLE ?? ALTER COLUMN ?? DROP DEFAULT', [table, column]);
124+
return true;
125+
}
126+
}
127+
this.logger.warn(`[schema-drift] ${op.type} on ${table}.${column} is unsupported on dialect '${this.dialectName}' — skipped`);
128+
return false;
129+
}
130+
131+
/** The body of `SqlDriver#rebuildSqliteTablePatched`; the method's docblock there is its contract. */
132+
export async function rebuildSqliteTablePatched(this: Pick<SchemaSyncHost, 'applyDeclaredColumnDefault' | 'buildRebuiltColumn' | 'introspectColumns' | 'knex' | 'logger' | 'managedObjectFields' | 'managedObjectIndexes' | 'nowColumnDefault' | 'resolveTenantField' | 'syncTableIndexes'>, table: string, ents: ManagedDriftEntry[]): Promise<ManagedDriftEntry[]> {
133+
const relax = new Set<string>();
134+
const tighten = new Set<string>();
135+
const drop = new Set<string>();
136+
const dropDefault = new Set<string>();
137+
// The entries this rebuild ACTS on, filled by the same pass that fills the
138+
// four sets — deliberately not a second list of op types to keep in sync
139+
// with the branches below. An entry is honoured if and only if one of those
140+
// branches took it, so the returned set cannot drift from the work done.
141+
const honoured: ManagedDriftEntry[] = [];
142+
for (const e of ents) {
143+
if (e.op.type === 'relax_not_null') relax.add(e.op.column);
144+
else if (e.op.type === 'tighten_not_null') tighten.add(e.op.column);
145+
else if (e.op.type === 'drop_column') drop.add(e.op.column);
146+
else if (e.op.type === 'drop_column_default') dropDefault.add(e.op.column);
147+
// Everything else is IGNORED here and must be reported as such (#11722):
148+
// widen/narrow varchar (SQLite ignores declared length — nothing to do),
149+
// `manual_column_type_change` (no reconciler arm on ANY dialect, by
150+
// decision), and any column op added later, which lands in this branch by
151+
// default rather than being silently absorbed into `honoured`.
152+
else continue;
153+
honoured.push(e);
154+
}
155+
156+
const physical = await this.introspectColumns(table);
157+
const kept = physical.filter((c) => !drop.has(c.name));
158+
const keptNames = kept.map((c) => c.name);
159+
const fields = this.managedObjectFields.get(table) ?? {};
160+
const tmp = `__os_mig_${table}`;
161+
162+
// FK enforcement must be toggled OUTSIDE the transaction (SQLite ignores
163+
// the PRAGMA inside one). Off during the swap so the rename doesn't trip
164+
// any dangling references mid-flight.
165+
await this.knex.raw('PRAGMA foreign_keys = OFF');
166+
try {
167+
await this.knex.transaction(async (trx) => {
168+
await trx.schema.dropTableIfExists(tmp);
169+
await trx.schema.createTable(tmp, (t) => {
170+
for (const c of kept) {
171+
const col = this.buildRebuiltColumn(t, c);
172+
if (!col) continue;
173+
const nullable = relax.has(c.name) ? true : tighten.has(c.name) ? false : c.nullable;
174+
if (!nullable && c.name !== 'id') col.notNullable();
175+
if (c.name === 'created_at' || c.name === 'updated_at') {
176+
// #11321: the SAME canonical default {@link createAuditTimestampColumn}
177+
// emits, not `knex.fn.now()`. This method is SQLite-only (see the
178+
// `isSqlite` branch in `applyMigrationEntries`), where
179+
// `knex.fn.now()` is the zone-naive `CURRENT_TIMESTAMP` — so
180+
// re-emitting it here would silently REVERT a table that was
181+
// created with the canonical default the moment any unrelated
182+
// drift (a relaxed NOT NULL, a dropped column) triggered a
183+
// rebuild. A rebuild must hand back the column `initObjects`
184+
// would have built.
185+
col.defaultTo(this.nowColumnDefault('datetime'));
186+
} else if (!dropDefault.has(c.name)) {
187+
// Re-emit the METADATA-declared default. The rebuild dropped the
188+
// original table, so a column whose default is not restated here
189+
// comes back without one — which is precisely what a
190+
// `drop_column_default` op wants, and precisely what a
191+
// `defaultValue: 'NOW()'` sibling must not suffer.
192+
const f = (fields as Record<string, any>)[c.name];
193+
if (f) this.applyDeclaredColumnDefault(col, f, f.type || 'string');
194+
}
195+
}
196+
});
197+
const colList = keptNames.map((n) => `"${n}"`).join(', ');
198+
await trx.raw(`INSERT INTO "${tmp}" (${colList}) SELECT ${colList} FROM "${table}"`);
199+
await trx.schema.dropTable(table);
200+
await trx.schema.renameTable(tmp, table);
201+
});
202+
} finally {
203+
await this.knex.raw('PRAGMA foreign_keys = ON');
204+
}
205+
206+
// Recreate unique constraints + declared indexes from metadata. The rebuild
207+
// dropped the original table, so every index went with it — this is a fresh
208+
// materialization, and it must produce the SAME constraints as
209+
// `initObjects` would. It used to inline its own `uniq_<table>_<col>`
210+
// single-column DDL, which is how a tenant-scoped field could silently
211+
// regain a global unique index after any drift rebuild (#3696); both paths
212+
// now share {@link syncTableIndexes}.
213+
try {
214+
const keptSet = new Set(keptNames);
215+
await this.syncTableIndexes(
216+
table,
217+
fields,
218+
this.managedObjectIndexes.get(table),
219+
this.resolveTenantField(table),
220+
keptSet,
221+
);
222+
} catch (e: any) {
223+
this.logger.warn(`[schema-drift] could not fully recreate indexes for '${table}' after rebuild`, e?.message ?? e);
224+
}
225+
226+
return honoured;
227+
}

0 commit comments

Comments
 (0)