Skip to content

Commit ef678d0

Browse files
claude[bot]claude
andauthored
fix(driver-sql): a failed index read is an error, not an empty index list (#7332) (#7394)
`SqlDriver.introspectIndexes` wrapped its entire dialect dispatch — SQLite, Postgres and MySQL alike — in one bare `catch {}` and returned its accumulator half-built. Callers could not tell "this table genuinely has no such index" from "the read failed and I am guessing". `diffManagedIndexes` takes its declared-index-missing branch on exactly that input, so a transient SQLITE_BUSY or a WAL read landing mid-flush became a confident, specific and false `actual: '(absent)'` report about an index that was there the whole time. Split the swallow by call site rather than removing it. Its justification — "let creation handle conflicts" — holds at `getExistingIndexNames`, whose caller `syncDeclaredIndexes` corrects an optimistic wrong reading by attempting the create and absorbing "already exists"; a throw there would take a boot down on a transient read. Detection has no such backstop and inherited the swallow only because #3728 wired a second consumer onto the same function. `introspectIndexes` now throws by default and takes an explicit `{ onFailure: 'partial' }` opt-in that only the creation seam passes. The detection callers were already built for this: `reconcileAndWarnDrift` catches and warns "could not introspect '<table>' for drift detection", and `os migrate plan` / `apply` both catch, print and exit non-zero. The sibling read in the same detect path, `introspectColumns`, has never swallowed. Measured: no consumer ever acted destructively on the false reading. Dropping entries from the physical list is monotone — `replace_unique_index`, `drop_index` and `recreate_index` all require an index to be present — so a short read can only remove a destructive proposal, never arm one. Claude-Session: https://claude.ai/code/session_012Wv1i1AwBy8eETqaDCXV6B Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5f7669e commit ef678d0

3 files changed

Lines changed: 316 additions & 6 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
fix(driver-sql): a failed index read is an error, not an empty index list (#7332)
6+
7+
`SqlDriver.introspectIndexes` wrapped its **entire** dialect dispatch — the
8+
SQLite, Postgres and MySQL branches alike — in one bare `catch {}` and then
9+
returned its accumulator in whatever half-built state it had reached. The caller
10+
could not tell *"this table genuinely has no such index"* from *"the read failed
11+
and I am guessing"*.
12+
13+
Drift detection consumed that same function. `diffManagedIndexes` takes its
14+
declared-index-missing branch on exactly that input, so a transient failure —
15+
SQLITE_BUSY, a WAL read landing mid-flush, any I/O hiccup — was not surfaced as
16+
an error. It was laundered into a confident, specific and **false** report:
17+
18+
```
19+
product: metadata declares index 'idx_product_code' (code) but the database
20+
has no such index — run "os migrate apply" to create it.
21+
```
22+
23+
…about an index that was there the whole time.
24+
25+
**The swallow is kept where its justification holds, and only there.** That
26+
justification — *"let creation handle conflicts"* — is sound at
27+
`getExistingIndexNames`, whose caller `syncDeclaredIndexes` corrects an
28+
optimistic wrong reading by attempting the create and absorbing the
29+
"already exists" error; a throw there would take a whole boot down on a
30+
transient read. Detection has no such backstop, and inherited the swallow only
31+
because #3728 wired a second consumer onto the same function. `introspectIndexes`
32+
therefore now **throws by default** and takes an explicit
33+
`{ onFailure: 'partial' }` opt-in, which the creation seam passes and nothing
34+
else does.
35+
36+
**What changes for you.** Nothing on the creation path: boot still tolerates a
37+
failed index read and still converges the schema. On the detection path, a
38+
failure that was previously invisible is now reported as one — `os migrate plan`
39+
and `os migrate apply` print it and exit non-zero instead of rendering a plan
40+
built on a partial reading, and boot-time drift handling logs
41+
`could not introspect '<table>' for drift detection` (a handler
42+
`reconcileAndWarnDrift` already carried) instead of a false drift warning. This
43+
matches the sibling read in the same detect path, `introspectColumns`, which has
44+
never swallowed.
45+
46+
Measured, and worth stating plainly: no consumer ever acted **destructively** on
47+
the false reading. Dropping entries from the physical list is monotone — the
48+
`replace_unique_index`, `drop_index` and `recreate_index` remedies all require an
49+
index to be *present*, so a short read can only ever remove a destructive
50+
proposal, never arm one. The defect was a confidently wrong report, not a
51+
dangerous one.
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, afterEach, vi } from 'vitest';
4+
import { SqlDriver, diffManagedIndexes, type PhysicalIndex } from '../src/index.js';
5+
6+
/**
7+
* A failed index read is not an empty index list (#7332).
8+
*
9+
* `introspectIndexes` used to wrap its whole dialect dispatch — SQLite,
10+
* Postgres and MySQL alike — in one bare `catch {}` and return `byName` in
11+
* whatever half-built state it had reached. The caller could not tell "this
12+
* table genuinely has no such index" from "the read failed and I am guessing",
13+
* so a transient SQLITE_BUSY / WAL hiccup was laundered into a confident,
14+
* specific, FALSE report that the database is missing declared indexes.
15+
*
16+
* The swallow's own justification — *"let creation handle conflicts"* — is
17+
* sound where it was written, {@link SqlDriver.getExistingIndexNames}: a
18+
* wrong-but-optimistic reading there costs one redundant `CREATE INDEX`, which
19+
* `syncDeclaredIndexes` absorbs as "already exists". Drift DETECTION has no
20+
* such backstop, and it inherited the swallow only because it was wired onto
21+
* the same function later (#3728). So the split is by call site, not by
22+
* removal: the creation seam opts into `{ onFailure: 'partial' }`, everything
23+
* else — detection included — sees the error.
24+
*
25+
* Note the asymmetry this closes: the sibling read in the very same detect
26+
* path, `introspectColumns`, has never swallowed. `reconcileAndWarnDrift`
27+
* already carries the handler for a throwing `detectTableDrift`
28+
* ("could not introspect '<table>' for drift detection"), and both CLI
29+
* consumers already `catch → printError → exit(1)`. The index dimension was
30+
* the one that could not reach any of them.
31+
*/
32+
describe('index introspection failure is not "no indexes" (#7332)', () => {
33+
let knexInstance: any;
34+
35+
const makeDriver = (opts: any = {}) => {
36+
const d = new SqlDriver({
37+
client: 'better-sqlite3',
38+
connection: { filename: ':memory:' },
39+
useNullAsDefault: true,
40+
...opts,
41+
});
42+
knexInstance = (d as any).knex;
43+
(d as any).logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn() };
44+
return d;
45+
};
46+
47+
afterEach(async () => {
48+
await knexInstance?.destroy();
49+
knexInstance = undefined;
50+
});
51+
52+
const productMeta = [
53+
{
54+
name: 'product',
55+
fields: {
56+
id: { type: 'string' },
57+
organization_id: { type: 'string' },
58+
code: { type: 'string' },
59+
},
60+
indexes: [{ name: 'idx_product_code', fields: ['code'] }],
61+
},
62+
];
63+
64+
/**
65+
* Fail the index read the way a busy database does — mid-dispatch, on one
66+
* statement — rather than by stubbing the method under test. `PRAGMA
67+
* index_list` is the SQLite branch's second round-trip; `introspectColumns`
68+
* goes through `knex(table).columnInfo()` and is deliberately untouched, so
69+
* the column dimension still reads clean and only the index dimension breaks.
70+
*/
71+
const breakIndexRead = (driver: SqlDriver, message = 'SQLITE_BUSY: database is locked') => {
72+
const knex: any = (driver as any).knex;
73+
const real = knex.raw.bind(knex);
74+
// knex defines `raw` as non-writable (but configurable), so a plain
75+
// assignment throws — the swap has to go through `defineProperty`.
76+
const swap = (fn: any) => Object.defineProperty(knex, 'raw', { value: fn, configurable: true });
77+
swap((sql: any, ...rest: any[]) => {
78+
if (typeof sql === 'string' && /PRAGMA\s+index_list/i.test(sql)) throw new Error(message);
79+
return real(sql, ...rest);
80+
});
81+
return () => swap(real);
82+
};
83+
84+
// ── Detection sees the failure ────────────────────────────────────────────
85+
86+
it('detectManagedDrift propagates the read failure instead of reporting "(absent)"', async () => {
87+
const driver = makeDriver();
88+
await driver.initObjects(productMeta);
89+
const restore = breakIndexRead(driver);
90+
try {
91+
await expect(driver.detectManagedDrift(productMeta as any)).rejects.toThrow(/SQLITE_BUSY/);
92+
} finally {
93+
restore();
94+
}
95+
});
96+
97+
it('does not invent a create_index remedy for an index that is actually there', async () => {
98+
const driver = makeDriver();
99+
await driver.initObjects(productMeta);
100+
101+
// Ground truth first: the index the read is about to miss really exists.
102+
const physical: PhysicalIndex[] = await (driver as any).introspectIndexes('product');
103+
expect(physical.map((p) => p.name)).toContain('idx_product_code');
104+
105+
const restore = breakIndexRead(driver);
106+
try {
107+
const drift = await driver.detectManagedDrift(productMeta as any).catch((e) => e);
108+
expect(drift).toBeInstanceOf(Error);
109+
} finally {
110+
restore();
111+
}
112+
});
113+
114+
it('the boot path reports the failure as a failure, not as drift', async () => {
115+
const driver = makeDriver();
116+
await driver.initObjects(productMeta);
117+
(driver as any).logger.warn.mockClear();
118+
119+
const restore = breakIndexRead(driver);
120+
try {
121+
await (driver as any).reconcileAndWarnDrift('product', productMeta[0].fields, productMeta[0].indexes);
122+
} finally {
123+
restore();
124+
}
125+
126+
const warnings = (driver as any).logger.warn.mock.calls.map((c: any[]) => String(c[0]));
127+
expect(warnings.some((w: string) => /could not introspect 'product' for drift detection/.test(w))).toBe(true);
128+
// The false report the swallow used to produce, verbatim from the differ.
129+
expect(warnings.some((w: string) => /has no such index/.test(w))).toBe(false);
130+
});
131+
132+
it('introspectIndexes throws by default — the honest contract for a read', async () => {
133+
const driver = makeDriver();
134+
await driver.initObjects(productMeta);
135+
const restore = breakIndexRead(driver);
136+
try {
137+
await expect((driver as any).introspectIndexes('product')).rejects.toThrow(/SQLITE_BUSY/);
138+
} finally {
139+
restore();
140+
}
141+
});
142+
143+
// ── Creation keeps the swallow, because it keeps the backstop ─────────────
144+
145+
it('getExistingIndexNames still degrades to a partial read rather than throwing', async () => {
146+
const driver = makeDriver();
147+
await driver.initObjects(productMeta);
148+
const restore = breakIndexRead(driver);
149+
try {
150+
await expect((driver as any).getExistingIndexNames('product')).resolves.toBeInstanceOf(Set);
151+
} finally {
152+
restore();
153+
}
154+
});
155+
156+
it('a boot whose index read fails throughout still creates the declared index', async () => {
157+
// The whole justification for keeping the swallow on the creation path: an
158+
// optimistic wrong reading is corrected by the database rejecting the
159+
// duplicate, so the boot completes and the schema converges anyway.
160+
const driver = makeDriver();
161+
const restore = breakIndexRead(driver);
162+
try {
163+
await driver.initObjects(productMeta);
164+
} finally {
165+
restore();
166+
}
167+
168+
const physical: PhysicalIndex[] = await (driver as any).introspectIndexes('product');
169+
expect(physical.map((p) => p.name)).toContain('idx_product_code');
170+
});
171+
172+
it('an index read that fails on the SECOND boot does not take the boot down', async () => {
173+
const driver = makeDriver();
174+
await driver.initObjects(productMeta);
175+
const restore = breakIndexRead(driver);
176+
try {
177+
// Everything already exists; the read that would prove it is broken.
178+
await expect(driver.initObjects(productMeta)).resolves.not.toThrow();
179+
} finally {
180+
restore();
181+
}
182+
});
183+
184+
// ── A truncated read can never ARM a destructive remedy ───────────────────
185+
186+
it('losing physical indexes only ever removes drop remedies, never adds one', () => {
187+
// The differ's three sections all key off PRESENCE in the physical list:
188+
// `replace_unique_index` needs the legacy index present, `drop_index` needs
189+
// the orphan present, `recreate_index` needs the declared name present.
190+
// Dropping entries is therefore monotone — this pins that direction, which
191+
// is why a false read is loud-and-wrong rather than destructive.
192+
const full: PhysicalIndex[] = [
193+
{ name: 'product_code_unique', columns: ['code'], unique: true, primary: false },
194+
{ name: 'idx_product_stale', columns: ['legacy_col'], unique: false, primary: false },
195+
];
196+
const args = {
197+
table: 'product',
198+
expected: [{ name: 'idx_product_code', columns: ['code'], unique: false }],
199+
legacy: [
200+
{
201+
column: 'code',
202+
legacyNames: ['product_code_unique'],
203+
replacement: { name: 'uniq_product_organization_id_code', columns: ['organization_id', 'code'], unique: true as const },
204+
},
205+
],
206+
tenantField: 'organization_id',
207+
};
208+
209+
const complete = diffManagedIndexes({ ...args, physical: full } as any);
210+
expect(complete.some((d) => d.op.type === 'replace_unique_index')).toBe(true);
211+
expect(complete.some((d) => d.op.type === 'drop_index')).toBe(true);
212+
213+
const truncated = diffManagedIndexes({ ...args, physical: [] } as any);
214+
expect(truncated.some((d) => d.category === 'destructive')).toBe(false);
215+
expect(truncated.map((d) => d.op.type)).toEqual(['create_index']);
216+
});
217+
});

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

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6753,8 +6753,30 @@ export class SqlDriver implements IDataDriver {
67536753
*
67546754
* Used both for sync idempotency ({@link getExistingIndexNames}) and for index
67556755
* drift detection (#3728), which needs the full definition rather than just
6756-
* the name. Failures are swallowed: at worst we attempt a create and absorb
6757-
* the "already exists" error in {@link syncDeclaredIndexes}.
6756+
* the name.
6757+
*
6758+
* ⚠️ A failed read is an ERROR, not an empty table (#7332). This used to wrap
6759+
* the whole dialect dispatch in a bare `catch {}` and return `byName` in
6760+
* whatever half-built state it had reached, so the caller could not tell "this
6761+
* table genuinely has no such index" from "the read failed and I am guessing".
6762+
* Downstream, `diffManagedIndexes` takes its declared-index-missing branch on
6763+
* exactly that input: a transient SQLITE_BUSY, or a WAL read landing
6764+
* mid-flush, was laundered into a confident, specific and FALSE
6765+
* `actual: '(absent)'` — *"metadata declares index 'x' but the database has no
6766+
* such index"* — about an index that was there the whole time.
6767+
*
6768+
* The swallow's justification, *"let creation handle conflicts"*, is sound
6769+
* only where it was written: {@link getExistingIndexNames}, whose caller
6770+
* {@link syncDeclaredIndexes} corrects an optimistic wrong reading by
6771+
* attempting the create and absorbing the "already exists" error. Detection
6772+
* has no such backstop, and it inherited the swallow only because #3728 wired
6773+
* a second consumer onto the same function. So the split is by CALL SITE:
6774+
* `onFailure: 'partial'` is the creation seam's opt-in, and everything else —
6775+
* detection included — sees the throw. Its callers are already built for it:
6776+
* {@link reconcileAndWarnDrift} catches and warns "could not introspect
6777+
* '<table>' for drift detection", and `os migrate plan` / `apply` both catch,
6778+
* print the error and exit non-zero. The sibling read in the same detect
6779+
* path, {@link introspectColumns}, has never swallowed.
67586780
*
67596781
* Postgres reads `pg_index` rather than `pg_indexes` so indexes backing a
67606782
* UNIQUE CONSTRAINT (which is exactly what knex's old `col.unique()` produced)
@@ -6770,7 +6792,17 @@ export class SqlDriver implements IDataDriver {
67706792
* what tells the differ this index is none of its business
67716793
* (`isSyncReproducibleIndex`).
67726794
*/
6773-
protected async introspectIndexes(tableName: string): Promise<PhysicalIndex[]> {
6795+
protected async introspectIndexes(
6796+
tableName: string,
6797+
opts: {
6798+
/**
6799+
* What a failed read means to THIS caller (#7332). `'throw'` (the
6800+
* default) surfaces it; `'partial'` returns whatever was read before the
6801+
* failure — correct only where a short read is self-correcting.
6802+
*/
6803+
onFailure?: 'throw' | 'partial';
6804+
} = {},
6805+
): Promise<PhysicalIndex[]> {
67746806
const byName = new Map<string, PhysicalIndex>();
67756807
const upsert = (name: string, unique: boolean, primary: boolean): PhysicalIndex => {
67766808
let e = byName.get(name);
@@ -6850,18 +6882,28 @@ export class SqlDriver implements IDataDriver {
68506882
else if (r.EXPRESSION != null) applyIndexKeyParts(entry, [String(r.EXPRESSION)]);
68516883
}
68526884
}
6853-
} catch {
6854-
// Best-effort — fall through and let creation handle conflicts.
6885+
} catch (e) {
6886+
// Only a caller that can CORRECT a short read may ask for one (#7332).
6887+
if (opts.onFailure !== 'partial') throw e;
68556888
}
68566889
return [...byName.values()];
68576890
}
68586891

68596892
/**
68606893
* Names of the indexes that already exist on a table. Used to make
68616894
* declared-index sync idempotent across repeated runs.
6895+
*
6896+
* Best-effort by design (#7332), and the one caller entitled to be: every
6897+
* consumer of this set uses it to decide whether to SKIP a create, so a name
6898+
* missing from a short read costs at most one redundant `CREATE INDEX` —
6899+
* which {@link syncDeclaredIndexes} absorbs as "already exists" — and a
6900+
* throw here would instead take the whole boot down on a transient read.
6901+
* The presence probes in {@link applyIndexDriftOp} fail the same safe way:
6902+
* a false absence keeps the legacy index in place and under-reports what was
6903+
* applied, never the reverse.
68626904
*/
68636905
protected async getExistingIndexNames(tableName: string): Promise<Set<string>> {
6864-
return new Set((await this.introspectIndexes(tableName)).map((i) => i.name));
6906+
return new Set((await this.introspectIndexes(tableName, { onFailure: 'partial' })).map((i) => i.name));
68656907
}
68666908

68676909
/**

0 commit comments

Comments
 (0)