Skip to content

Commit 9407318

Browse files
refactor(driver-sql): move introspectIndexes's body out of sql-driver.ts into sql-driver-introspection.ts (#25521 cut 5) (#25931)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DRkza71oHtDYy29jXfxjhrY)_ Part of #25521 Clause-②: no Metadata-protocol: none Ruling-ref: 6270097840 Ruling-ref: 6348891160 Cut 5 of the `sql-driver.ts` split (#24586 ruling A, recorded in `6270097840`). The claim is `6408067063`. The body of the protected `SqlDriver#introspectIndexes` moves unedited into cut 1's module, `packages/drivers/driver-sql/src/sql-driver-introspection.ts`, and reaches the class through cut 1's host, `IntrospectionHost`, which gains one member. The method keeps its name, docblock, signature (less `async`) and position. `resolveHashShadowKeys`, the one class method the body calls, stays in the class with the rest of the hash-shadow family. It is move-only, and the card stays open for the later cuts. Branch `claude/project-thread-8aooxu` (platform-assigned to this card thread, as the claim records), cut from `origin/main` at `f00e8d5c8` (cut 4 merged as `b1790841b` before it). **Net line count (the seat's rule from ACCEPT `6385306916`).** `sql-driver.ts` goes from 21,595 lines at base to 21,512 at head: **-83** (cut 4 priced it at about -82). `sql-driver-introspection.ts` goes from 800 to 927 lines. **Serial with the file's in-flight PRs.** At claim the seat read no open PR touching `sql-driver.ts` (hot-file queue on seat post #20966: this is the one slice in flight). Re-measured just before this PR was opened: 8 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`. `origin/main` moved one commit past the base meanwhile (#25904, now `2a728ba2a`). It touches no driver package and no gate script this diff derives, and it merges clean, so `main` is not merged in. ## The area, re-measured at `f00e8d5c8` - `introspectIndexes` with its docblock is lines 15096-15250 of `sql-driver.ts` (155 lines; the method itself 15143-15250). `git blame` with `--since` 30 days before the base attributes **0** of those lines to the last 30 days; the file has 63 commits in that window (the clone's history was deepened to 2026-09-20, before the window opens, for the reading). - It is protected, and its return type is annotated (a promise of `PhysicalIndex[]`), so the inferred-return limit does not bite. - It does not build through `getBuilder` (`check:tenant-chokepoint` rule 1: 24 bindings across 3 files at both ends). - It reads no static: the fields and getters it reads are `knex`, `isMysql`, `isPostgres` and `isSqlite`, all already `IntrospectionHost` members, plus the method `resolveHashShadowKeys`. - It has no `this.FIELD.delete/insert/update(` site: its `.set(` / `.get(` / `.push(` calls are on a local `Map` and local arrays. - `insertOnlyUpsertColumns` is not touched (#8740). ## The shape (#23050 shape B, as cuts 1-4 apply it) - The class keeps `introspectIndexes`, docblock and parameter JSDoc included, byte-identical except that `async` is dropped. Its body is now one host literal plus `introspectIndexes.call(host, tableName, opts)`, marked `[#25521 cut 5]`. It returns the moved function's own promise, so a caller awaits the same settlement. - `IntrospectionHost` gains one member, `resolveHashShadowKeys`, typed as the class member (an indexed access on `SqlDriver`). Its ten existing member lines are unchanged. The moved function types its `this` as the `Pick` of the host over exactly the five members its body reads, and the stub annotates its literal as the `ThisParameterType` of the moved function. There is no cast, and no member becomes public. - `knex` and the three dialect getters go as GETTERS that read the driver at call time. `resolveHashShadowKeys` goes as a late-bound closure that calls it on the driver, so a subclass override or an instance spy is still the one the body reaches. - **Why `resolveHashShadowKeys` stays.** The claim's surface allows the helpers only `introspectIndexes` calls, and it is one of them (its only caller). But the series carries "protected statics keep the hash-shadow family in the class" (cut 4's release `6388161035`), and cut 4's proposal for this cut kept it too, so it is left for the seat to price as its own slice (see the acceptance notes). - **Module-level declarations.** The body reads `parseIndexDdl`, `applyIndexKeyParts` and the type `PhysicalIndex`. All three live in `schema-drift.ts`, which exports them, so the module imports them from there (not a copy). After the move `sql-driver.ts` no longer reads the two functions, so they leave its `./schema-drift.js` import list; `PhysicalIndex` stays there, other members still read it. No declaration gains an `export`. `index.ts` re-exports nothing from this module. - The move was produced by a script from the source text, not retyped: the parameter list, return type and body lines, re-indented two columns, except the 9 lines that continue one of the body's two multi-line SQL template literals. Those keep their bytes (cut 1's rule: there the indentation is statement text). - Cut 1's module header gains a `[#25521 cut 5]` paragraph, and the import comment in `sql-driver.ts` is re-worded to name `introspectIndexes`. Both are comment-only. ### Overrides and spies, measured - `TursoDriver` overrides `introspectIndexes` and calls `super.introspectIndexes(tableName, opts)` on its local arm. The class method is still there and hands the body its host. - `SqliteWasmDriver` overrides the getter `isSqlite`. The host reads it through a getter that reads the driver. - `sql-driver-12732-varchar-emitter-parity-wiring.test.ts` spies on `introspectIndexes` on the instance; that is still the class method. - A scratch probe against the built `dist` (not committed) gave 18 readings, **identical at base and at head** (diff empty). It covered: the SQLite answer for a table with a primary key, a composite UNIQUE constraint, a plain index, a partial unique index and an expression index (columns, `partial`, `expressions`, `nullSafeColumns`); `getExistingIndexNames`; a missing table; a failing `PRAGMA index_info` under the default (rejects) and under `onFailure: 'partial'` (the short list); a `knex.raw` that throws synchronously (the method still returns a promise that rejects, at both ends); a subclass `introspectIndexes` that calls `super`; the MySQL arm through a subclass overriding the three dialect getters, with the `EXPRESSION` fallback statement; a subclass override of `resolveHashShadowKeys` and then an instance assignment of it (both reached); a rejecting `resolveHashShadowKeys` (surfaces by default, swallowed under `partial`); and a subclass `isSqlite` getter flipped between two calls (read at call time). ## The published `.d.ts` does not change Under the series bar (ruling `6348891160`, letter A), measured on base `f00e8d5c8` and head `bd1670fe0`, each built with `pnpm --filter @objectstack/driver-sql build` against the same dependency tree (the base leg restores the two base files into this worktree after the cut commit, rebuilds, reads, then restores HEAD, proved by blob hash and an empty `git diff HEAD`). - This package emits `dist/index.d.ts` and `dist/index.d.mts` (no `.d.cts`); the two are identical at both ends. - Raw sha256 of both files, base and head, all four the same: `93da45168e82136b7613944525e47402d5dc6f9f8daa9efa4bdceeaa11b52a89` (cuts 1-4's hash, unchanged). `cmp` base vs head: identical, raw diff 0 lines, 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 5714, `protected introspectIndexes(tableName: string, opts?: {`, with `protected` removed, gives `cbaacf3c70282bd8dfc867c2f97d0f218b114c06b7fc11fbde5b03f5c05f9da0` and a 4-line diff. - Export tables: the ESM and CJS bundles each export the same 56 names at base and head. The sorted list hashes to `d61a9dd3a02661602b1bb2dbe18152ac43986148f36b2746f40c0884bd8ffdec` on all four (cut 4's value). - The head build carries the move: `dist/index.mjs` 29 to 30 host `.call(` sites, `dist/index.js` 30 to 31. No changeset: `skip-changeset`, per ruling `6348891160` item 4. ## Proof of move-only - **Body, by AST token** (recursive `getChildren` leaves, template pieces as their exact source text, comments and JSDoc compared line by line less leading whitespace). The moved function's parameters (less the added `this`), return type and body have the same token sequence as the base method, and `async` matches. 673 body tokens compared, 0 differ; 2 parameters identical; return type identical. - **Lines**: 98 body lines compared. 88 are the base line with two leading spaces removed, 9 are byte-identical template-literal continuation lines, 0 other. - **Negative control**: in a copy of the module, `type = 'index'` becomes `type = 'indx'` inside the SQLite arm's `sqlite_master` template literal. The check then reports 1 token and 1 line different and exits `PROOF FAILED`, exit 1. - **Class side**: 347 members at base and head. The 346 that did not move are byte-identical to base, leading trivia included. - **Top level of `sql-driver.ts`**: 202 non-class statements at base and head; 200 byte-identical. The two others are the `./schema-drift.js` import (loses exactly `applyIndexKeyParts` and `parseIndexDdl`) and the `./sql-driver-introspection.js` import (gains exactly `introspectIndexes`; its leading comment re-worded). - **The module**: 9 statements at base, 11 at head. The 8 base statements other than `IntrospectionHost` are byte-identical at head (the first carries the re-worded header as leading trivia). `IntrospectionHost` keeps its ten member lines and gains one. The two new statements are the `./schema-drift.js` import and the moved function. - **Size**: +143 / -99 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). Neither paragraph describes the moved body, so per card ask 3 no entry is owed. The moved body cites no ADR; the docblock's ADR-0048 mention stays on the class. `check:adr-anchors`: 85 anchored files at both ends; decision-letter citations on the anchored surface **1,847 at base and 1,847 at head**. - **Symbol anchors.** No ADR, checklist or doc anchors `sql-driver.ts#introspectIndexes` (`git grep` over `docs/`, `content/`, `scripts/`, `.github/`: 0 hits). `check:adr-symbol-anchors`: 2,474 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 body's five `knex.raw(` call sites (`sqlite_master`, `PRAGMA index_list`, `PRAGMA index_info`, `pg_index`, MySQL `STATISTICS`) are catalog reads, none says `IS NULL` against a tenant column (the `pg_index` read says `ix.indpred IS NOT NULL`, not a tenant column). Verdict unchanged; `.raw(` call sites in `sql-driver.ts` go 75 to 70 (see the acceptance notes). - **Logger receivers.** The moved text reads no logger. `check:logger-receiver-detach`: 0 detaches at both ends. - **Doc comments** that name `SqlDriver.introspectIndexes` (`schema-drift.ts`, `turso-driver.ts`, `packages/spec/src/contracts/schema-diff-service.ts`) and the `{@link introspectIndexes}` references in the class still point at the method, which is still there. - `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` (265 files, the module included): 0 errors, 0 warnings. Repo-wide `pnpm lint` (the seat asked for it; driver-sql declares no lint script): exit 0 at head `bd1670fe0`. - `packages/drivers/driver-sql` suite: base 3,999 tests (3,787 passed, 0 failed, 212 skipped; 247 files), head the same. Compared per test name and status from vitest's JSON reporter: 0 lost, 0 changed, 0 added. - `packages/drivers/driver-turso` suite, against the rebuilt driver-sql `dist` at each end: base 2,526 tests (2,492 passed, 0 failed, 34 skipped; 95 files), head the same; per name 0 / 0 / 0 (2,513 distinct names, 13 repeat). - `packages/drivers/driver-sqlite-wasm` suite: base 704 tests (704 passed; 39 files), head the same; per name 0 / 0 / 0. - Each suite ran under its own `os-verify-lock` call, `VERDICT command-exit 0` at both ends. - `dispatch-gates --commands` on this diff, at head `bd1670fe0`: 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`; `check-plugin-teardown-shape --self-test` first exited 3 on the shallow clone and exits 0 after `git fetch --unshallow`. - `check:commit-card-trailers --range origin/main..HEAD`: 1 commit, clean. - No reverse verification or ablation: the diff changes no tested behaviour. ## Acceptance notes - **`check:tenant-chokepoint` rule 2 reads 5 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 five catalog `knex.raw(` calls that moved; the module now holds 24 `.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. - **`resolveHashShadowKeys` could follow.** Its only caller is now the moved body, its body reads only `knex` and two `schema-drift.ts` functions (no static), and its body (about 29 lines) is longer than the stub it would need (about 7), so it would pass the net-line rule at roughly -22. It stays because the series keeps the hash-shadow family in the class; whether it moves is the seat's call. - **Proposed cut 6** (cut 4's next candidate, re-read here): the SQLite rebuild pair `applyDriftOpInPlace` (14860) and `rebuildSqliteTablePatched` (14954), Schema sync. Neither builds through `getBuilder` and neither has a `this.FIELD.delete/insert/update(` site. Between them they read `knex` (14 times), `logger` (twice, so `check:logger-receiver-detach` is the gate to watch on a host getter), `dialectName`, `isMysql`, `isPostgres`, and the methods `introspectColumns`, `syncTableIndexes`, `resolveTenantField`, `nowColumnDefault`, `buildRebuiltColumn`, `applyIndexDriftOp`, `applyDeclaredColumnDefault`, plus the fields `managedObjectIndexes` and `managedObjectFields`. That is a wide host; it should be priced against the net-line rule at its dispatch. - `TursoDriver`'s `introspectIndexes` override still calls `super.introspectIndexes(tableName, opts)`. That now returns the moved function's promise instead of an `async` method's promise. A caller awaits the same settlement, and a synchronous throw inside the body still arrives as a rejection (probe reading 6). - Lines that continue a SQL template literal keep their base indentation inside the module, so they sit two columns deeper than the code around them (cut 1's rule). --- _Generated by [Claude Code](https://claude.ai/code/session_013Wyp3j7s8XDQmCUaKPbmXH)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1d2a246 commit 9407318

2 files changed

Lines changed: 143 additions & 99 deletions

File tree

‎packages/drivers/driver-sql/src/sql-driver-introspection.ts‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@
1414
* shorter than the host literal a stub would need, so moving it would make
1515
* `sql-driver.ts` longer, not shorter.
1616
*
17+
* [#25521 cut 5] `introspectIndexes`, the per-table index read schema sync
18+
* and index drift detection are built on, moved here too: it is the fifth
19+
* catalog read of the same shape, with the same `onFailure` contract as the
20+
* foreign-key and primary-key reads above. `resolveHashShadowKeys`, the MySQL
21+
* hash-shadow enrichment it calls, stayed in the class with the rest of the
22+
* hash-shadow family; the body reaches it through a closure that calls it on
23+
* the driver at call time, so a subclass override or a test spy on the
24+
* instance is still the one it reaches. `TursoDriver` overrides
25+
* `introspectIndexes` and calls `super` on its local arm, which reaches the
26+
* class method, which hands this body its host. `parseIndexDdl`,
27+
* `applyIndexKeyParts` and the type `PhysicalIndex` are imported from
28+
* `schema-drift.ts`, where they live; `sql-driver.ts` no longer reads the two
29+
* functions and dropped them from its import.
30+
*
1731
* A MOVE, not a redesign. Each function below is the body of the `SqlDriver`
1832
* method of the same name as it stood, re-indented one level and otherwise
1933
* unedited: the same catalog statements, the same dialect arms, the same
@@ -67,6 +81,7 @@ import type {
6781
UniqueConstraintMember,
6882
} from './sql-driver.js';
6983
import { postgresTableNameCollisionError, singleColumnUniqueColumns } from './sql-driver.js';
84+
import { applyIndexKeyParts, parseIndexDdl, type PhysicalIndex } from './schema-drift.js';
7085

7186
/**
7287
* The `SqlDriver` members the functions below read — every one of them, and
@@ -87,6 +102,7 @@ export interface IntrospectionHost {
87102
readonly isPostgres: SqlDriver['isPostgres'];
88103
readonly isSqlite: SqlDriver['isSqlite'];
89104
readonly knex: SqlDriver['knex'];
105+
resolveHashShadowKeys: SqlDriver['resolveHashShadowKeys'];
90106
}
91107

92108
/** The body of `SqlDriver#introspectColumnOrder`; the method's docblock there is its contract. */
@@ -798,3 +814,114 @@ export async function introspectSchema(this: Pick<IntrospectionHost, 'dialectNam
798814
// this repairs, wearing a fix's clothes.
799815
return { tables, dialect: this.dialectName, introspectedAt };
800816
}
817+
818+
/** The body of `SqlDriver#introspectIndexes`; the method's docblock there is its contract. */
819+
export async function introspectIndexes(
820+
this: Pick<IntrospectionHost, 'isMysql' | 'isPostgres' | 'isSqlite' | 'knex' | 'resolveHashShadowKeys'>,
821+
tableName: string,
822+
opts: {
823+
/**
824+
* What a failed read means to THIS caller (#7332). `'throw'` (the
825+
* default) surfaces it; `'partial'` returns whatever was read before the
826+
* failure — correct only where a short read is self-correcting.
827+
*/
828+
onFailure?: 'throw' | 'partial';
829+
} = {},
830+
): Promise<PhysicalIndex[]> {
831+
const byName = new Map<string, PhysicalIndex>();
832+
const upsert = (name: string, unique: boolean, primary: boolean): PhysicalIndex => {
833+
let e = byName.get(name);
834+
if (!e) byName.set(name, (e = { name, columns: [], unique, primary }));
835+
return e;
836+
};
837+
try {
838+
if (this.isSqlite) {
839+
const safe = tableName.replace(/[^a-zA-Z0-9_]/g, '');
840+
// `sqlite_master.sql` is the only place an expression key or a WHERE
841+
// predicate survives; it is NULL for the indexes SQLite auto-creates
842+
// for a UNIQUE/PK constraint, which are plain by construction.
843+
const ddlByName = new Map<string, string>();
844+
const master: any = await this.knex.raw(
845+
`SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ?`,
846+
[tableName],
847+
);
848+
for (const r of Array.isArray(master) ? master : (master?.rows ?? [])) {
849+
if (typeof r?.sql === 'string' && r.sql) ddlByName.set(r.name, r.sql);
850+
}
851+
const list: any = await this.knex.raw(`PRAGMA index_list(${safe})`);
852+
for (const r of list) {
853+
const entry = upsert(r.name, r.unique === 1 || r.unique === true, r.origin === 'pk');
854+
const parsed = parseIndexDdl(ddlByName.get(r.name) ?? '');
855+
if (parsed) {
856+
applyIndexKeyParts(entry, parsed.keyParts);
857+
if (parsed.partial) entry.partial = true;
858+
} else {
859+
const info: any = await this.knex.raw(`PRAGMA index_info(${JSON.stringify(r.name)})`);
860+
for (const c of info) if (c.name != null) entry.columns.push(c.name);
861+
}
862+
// `PRAGMA index_list` reports partiality directly (SQLite ≥ 3.8.9);
863+
// belt-and-braces with the parsed predicate above.
864+
if (r.partial === 1 || r.partial === true) entry.partial = true;
865+
}
866+
} else if (this.isPostgres) {
867+
// `to_regclass` resolves the name the way every other statement in the
868+
// session does — first match along `search_path` — instead of pinning
869+
// one schema literally. The literal was `n.nspname = 'public'`, which is
870+
// the same answer for a default deployment (`current_schema()` IS
871+
// `public` there) and NO answer at all for a session pointed anywhere
872+
// else: measured on a live Postgres 16 under a non-public search_path,
873+
// this returned `[]` for a table carrying a primary key AND a declared
874+
// unique index. Empty here does not read as "I could not see"; it reads
875+
// as "there are no indexes", which is what `assertConflictTargetHonoured`
876+
// turns into a refusal — a silent fail-open on the identity check.
877+
// Resolving by OID also removes the ambiguity a schema list would add:
878+
// two schemas on the path can hold the same table name, and only one of
879+
// them is the one a query would hit.
880+
const res: any = await this.knex.raw(
881+
`SELECT i.relname AS index_name, ix.indisunique AS is_unique, ix.indisprimary AS is_primary,
882+
(ix.indpred IS NOT NULL) AS is_partial,
883+
pg_get_indexdef(ix.indexrelid) AS indexdef
884+
FROM pg_index ix
885+
JOIN pg_class i ON i.oid = ix.indexrelid
886+
WHERE ix.indrelid = to_regclass(?)
887+
ORDER BY i.relname`,
888+
[tableName],
889+
);
890+
for (const r of res.rows) {
891+
const entry = upsert(r.index_name, r.is_unique === true, r.is_primary === true);
892+
if (r.is_partial === true) entry.partial = true;
893+
const parsed = parseIndexDdl(r.indexdef);
894+
if (parsed) applyIndexKeyParts(entry, parsed.keyParts);
895+
}
896+
} else if (this.isMysql) {
897+
// `EXPRESSION` only exists from MySQL 8.0.13 (functional indexes); on an
898+
// older server or MariaDB the column is unknown and the query errors, so
899+
// fall back rather than lose index introspection wholesale.
900+
const columns = 'INDEX_NAME, NON_UNIQUE, COLUMN_NAME, EXPRESSION';
901+
const statisticsQuery = (select: string) =>
902+
this.knex.raw(
903+
`SELECT ${select}
904+
FROM information_schema.STATISTICS
905+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
906+
ORDER BY INDEX_NAME, SEQ_IN_INDEX`,
907+
[tableName],
908+
);
909+
let res: any;
910+
try {
911+
res = await statisticsQuery(columns);
912+
} catch {
913+
res = await statisticsQuery('INDEX_NAME, NON_UNIQUE, COLUMN_NAME');
914+
}
915+
for (const r of res[0]) {
916+
const entry = upsert(r.INDEX_NAME, Number(r.NON_UNIQUE) === 0, r.INDEX_NAME === 'PRIMARY');
917+
if (r.COLUMN_NAME != null) entry.columns.push(r.COLUMN_NAME);
918+
else if (r.EXPRESSION != null) applyIndexKeyParts(entry, [String(r.EXPRESSION)]);
919+
}
920+
await this.resolveHashShadowKeys(tableName, [...byName.values()]);
921+
}
922+
} catch (e) {
923+
// Only a caller that can CORRECT a short read may ask for one (#7332).
924+
if (opts.onFailure !== 'partial') throw e;
925+
}
926+
return [...byName.values()];
927+
}

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 16 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,6 @@ import {
109109
import { postureEnforcesWall } from '@objectstack/spec/security';
110110
import { temporalStorageForm } from '@objectstack/core';
111111
import {
112-
applyIndexKeyParts,
113112
buildIndexName,
114113
describeMissingIndexColumns,
115114
diffManagedIndexes,
@@ -132,7 +131,6 @@ import {
132131
legacyUniqueReplacements,
133132
normalizeDeclaredIndex,
134133
organizationKeyPartSql,
135-
parseIndexDdl,
136134
uniqueIndexesFromFields,
137135
type DeclaredIndexInput,
138136
type HashShadowKeyPart,
@@ -161,12 +159,14 @@ import {
161159
import { recoverUnencodedJsonText } from './unencoded-json-text.js';
162160
// [#25521 cut 1] The catalog-introspection helpers (`introspectColumnOrder`,
163161
// `introspectColumns` and the foreign-key, primary-key and unique-constraint reads),
164-
// and [cut 4] `introspectSchema`, the whole-schema read built on them; the methods
162+
// [cut 4] `introspectSchema`, the whole-schema read built on them, and [cut 5]
163+
// `introspectIndexes`, the per-table index read schema sync is built on; the methods
165164
// of the same names below hand each body the members it reads (`IntrospectionHost`).
166165
import {
167166
introspectColumnOrder,
168167
introspectColumns,
169168
introspectForeignKeys,
169+
introspectIndexes,
170170
introspectPrimaryKeys,
171171
introspectSchema,
172172
introspectUniqueConstraints,
@@ -15140,7 +15140,7 @@ export class SqlDriver implements IDataDriver {
1514015140
* what tells the differ this index is none of its business
1514115141
* (`isSyncReproducibleIndex`).
1514215142
*/
15143-
protected async introspectIndexes(
15143+
protected introspectIndexes(
1514415144
tableName: string,
1514515145
opts: {
1514615146
/**
@@ -15151,102 +15151,19 @@ export class SqlDriver implements IDataDriver {
1515115151
onFailure?: 'throw' | 'partial';
1515215152
} = {},
1515315153
): Promise<PhysicalIndex[]> {
15154-
const byName = new Map<string, PhysicalIndex>();
15155-
const upsert = (name: string, unique: boolean, primary: boolean): PhysicalIndex => {
15156-
let e = byName.get(name);
15157-
if (!e) byName.set(name, (e = { name, columns: [], unique, primary }));
15158-
return e;
15154+
// [#25521 cut 5] The body moved to `sql-driver-introspection.ts` (see `IntrospectionHost`).
15155+
const readIsMysql = () => this.isMysql;
15156+
const readIsPostgres = () => this.isPostgres;
15157+
const readIsSqlite = () => this.isSqlite;
15158+
const readKnex = () => this.knex;
15159+
const host: ThisParameterType<typeof introspectIndexes> = {
15160+
get isMysql() { return readIsMysql(); },
15161+
get isPostgres() { return readIsPostgres(); },
15162+
get isSqlite() { return readIsSqlite(); },
15163+
get knex() { return readKnex(); },
15164+
resolveHashShadowKeys: (...a) => this.resolveHashShadowKeys(...a),
1515915165
};
15160-
try {
15161-
if (this.isSqlite) {
15162-
const safe = tableName.replace(/[^a-zA-Z0-9_]/g, '');
15163-
// `sqlite_master.sql` is the only place an expression key or a WHERE
15164-
// predicate survives; it is NULL for the indexes SQLite auto-creates
15165-
// for a UNIQUE/PK constraint, which are plain by construction.
15166-
const ddlByName = new Map<string, string>();
15167-
const master: any = await this.knex.raw(
15168-
`SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ?`,
15169-
[tableName],
15170-
);
15171-
for (const r of Array.isArray(master) ? master : (master?.rows ?? [])) {
15172-
if (typeof r?.sql === 'string' && r.sql) ddlByName.set(r.name, r.sql);
15173-
}
15174-
const list: any = await this.knex.raw(`PRAGMA index_list(${safe})`);
15175-
for (const r of list) {
15176-
const entry = upsert(r.name, r.unique === 1 || r.unique === true, r.origin === 'pk');
15177-
const parsed = parseIndexDdl(ddlByName.get(r.name) ?? '');
15178-
if (parsed) {
15179-
applyIndexKeyParts(entry, parsed.keyParts);
15180-
if (parsed.partial) entry.partial = true;
15181-
} else {
15182-
const info: any = await this.knex.raw(`PRAGMA index_info(${JSON.stringify(r.name)})`);
15183-
for (const c of info) if (c.name != null) entry.columns.push(c.name);
15184-
}
15185-
// `PRAGMA index_list` reports partiality directly (SQLite ≥ 3.8.9);
15186-
// belt-and-braces with the parsed predicate above.
15187-
if (r.partial === 1 || r.partial === true) entry.partial = true;
15188-
}
15189-
} else if (this.isPostgres) {
15190-
// `to_regclass` resolves the name the way every other statement in the
15191-
// session does — first match along `search_path` — instead of pinning
15192-
// one schema literally. The literal was `n.nspname = 'public'`, which is
15193-
// the same answer for a default deployment (`current_schema()` IS
15194-
// `public` there) and NO answer at all for a session pointed anywhere
15195-
// else: measured on a live Postgres 16 under a non-public search_path,
15196-
// this returned `[]` for a table carrying a primary key AND a declared
15197-
// unique index. Empty here does not read as "I could not see"; it reads
15198-
// as "there are no indexes", which is what `assertConflictTargetHonoured`
15199-
// turns into a refusal — a silent fail-open on the identity check.
15200-
// Resolving by OID also removes the ambiguity a schema list would add:
15201-
// two schemas on the path can hold the same table name, and only one of
15202-
// them is the one a query would hit.
15203-
const res: any = await this.knex.raw(
15204-
`SELECT i.relname AS index_name, ix.indisunique AS is_unique, ix.indisprimary AS is_primary,
15205-
(ix.indpred IS NOT NULL) AS is_partial,
15206-
pg_get_indexdef(ix.indexrelid) AS indexdef
15207-
FROM pg_index ix
15208-
JOIN pg_class i ON i.oid = ix.indexrelid
15209-
WHERE ix.indrelid = to_regclass(?)
15210-
ORDER BY i.relname`,
15211-
[tableName],
15212-
);
15213-
for (const r of res.rows) {
15214-
const entry = upsert(r.index_name, r.is_unique === true, r.is_primary === true);
15215-
if (r.is_partial === true) entry.partial = true;
15216-
const parsed = parseIndexDdl(r.indexdef);
15217-
if (parsed) applyIndexKeyParts(entry, parsed.keyParts);
15218-
}
15219-
} else if (this.isMysql) {
15220-
// `EXPRESSION` only exists from MySQL 8.0.13 (functional indexes); on an
15221-
// older server or MariaDB the column is unknown and the query errors, so
15222-
// fall back rather than lose index introspection wholesale.
15223-
const columns = 'INDEX_NAME, NON_UNIQUE, COLUMN_NAME, EXPRESSION';
15224-
const statisticsQuery = (select: string) =>
15225-
this.knex.raw(
15226-
`SELECT ${select}
15227-
FROM information_schema.STATISTICS
15228-
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
15229-
ORDER BY INDEX_NAME, SEQ_IN_INDEX`,
15230-
[tableName],
15231-
);
15232-
let res: any;
15233-
try {
15234-
res = await statisticsQuery(columns);
15235-
} catch {
15236-
res = await statisticsQuery('INDEX_NAME, NON_UNIQUE, COLUMN_NAME');
15237-
}
15238-
for (const r of res[0]) {
15239-
const entry = upsert(r.INDEX_NAME, Number(r.NON_UNIQUE) === 0, r.INDEX_NAME === 'PRIMARY');
15240-
if (r.COLUMN_NAME != null) entry.columns.push(r.COLUMN_NAME);
15241-
else if (r.EXPRESSION != null) applyIndexKeyParts(entry, [String(r.EXPRESSION)]);
15242-
}
15243-
await this.resolveHashShadowKeys(tableName, [...byName.values()]);
15244-
}
15245-
} catch (e) {
15246-
// Only a caller that can CORRECT a short read may ask for one (#7332).
15247-
if (opts.onFailure !== 'partial') throw e;
15248-
}
15249-
return [...byName.values()];
15166+
return introspectIndexes.call(host, tableName, opts);
1525015167
}
1525115168

1525215169
/**

0 commit comments

Comments
 (0)