Skip to content

Commit 987eb8a

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-6810-organization-id-indexed-key
2 parents 915359e + 6de592c commit 987eb8a

3 files changed

Lines changed: 342 additions & 3 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
fix(driver-sql): judge unique violations with the shared predicate, so a Postgres index build over dirty data no longer takes the boot down (#6543)
6+
7+
`syncDeclaredIndexes` has a branch whose whole job is to keep a database
8+
BOOTING when existing rows violate a NULL-safe unique it was asked to create
9+
(the #5030 defect made data): the constraint is logged at `error` as not
10+
enforced, and the ADR-0120 D4 drift pre-flight reports the exact conflicting
11+
rows. Taking the process down instead would brick the deployment.
12+
13+
It decided whether it was looking at that case with a private inline regex over
14+
the stringified message — `unique constraint failed|duplicate entry|duplicate
15+
key value`, the fourth hand-written spelling of this question #6250
16+
inventoried. That read one of the two channels drivers use, and on the DDL path
17+
the missing channel is the whole answer for one shipped dialect:
18+
19+
| dialect | `CREATE UNIQUE INDEX` over duplicate rows says | old regex |
20+
|:---|:---|:---|
21+
| SQLite | `UNIQUE constraint failed: product.code` | matched |
22+
| MySQL | `ER_DUP_ENTRY: Duplicate entry 'DUP' for key 'uniq_…'` | matched |
23+
| Postgres | `could not create unique index "uniq_…"`, SQLSTATE 23505 | **missed** |
24+
25+
Postgres does not reuse its DML phrasing for an index build: `duplicate key
26+
value violates unique constraint` is what a conflicting INSERT says, while a
27+
conflicting index BUILD says `could not create unique index "…"` and puts the
28+
verdict on `error.code` (SQLSTATE `23505`) with the offending tuple on
29+
`error.detail`. None of the three message limbs appear in it — so on Postgres
30+
the branch never fired, and a database with legacy duplicates failed to start
31+
rather than booting with the constraint reported as unenforced.
32+
33+
Both discriminators in this file now call `isUniqueViolationError` from
34+
`@objectstack/types`, passing the **error object** rather than a pre-stringified
35+
message, so `code`, `errno` and the `cause` chain are read alongside `message`:
36+
37+
- the #5030 boot-survival branch above;
38+
- the negative limb of the MySQL functional-key-part fallback in
39+
`createNullSafeUniqueIndex`, which used a bare `/duplicate/i` to avoid
40+
degrading a conflict into a "this server rejects functional key parts"
41+
verdict — a message-only exclusion that did not fire on the `errno`-only
42+
shape mysql2 can hand back.
43+
44+
`patch` rather than `minor`: no API changes, and the message spellings that
45+
were recognised before are a strict subset of what the predicate recognises, so
46+
nothing that was absorbed before is absorbed differently now. The site's own
47+
business logic — the `nullSafe.size > 0` guard that keeps this absorption
48+
scoped to the NULL-safe case, and the "already exists" race arm that runs ahead
49+
of it — is unchanged.
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `syncDeclaredIndexes` judges "did existing rows violate the NULL-safe unique
5+
* I just tried to create?" — the #5030 branch that keeps a dirty database
6+
* BOOTING (the constraint is logged as not-enforced and reported by the
7+
* ADR-0120 D4 drift pre-flight) instead of taking the process down.
8+
*
9+
* It used to judge that with a private inline regex over the stringified
10+
* message — `unique constraint failed|duplicate entry|duplicate key value` —
11+
* the fourth hand-written vocabulary #6250 inventoried. #6543 migrates it onto
12+
* `@objectstack/types`' `isUniqueViolationError`, passing the ERROR OBJECT so
13+
* the `code` / `errno` channels are read at all.
14+
*
15+
* ## Why this is a live defect and not only a structural one
16+
*
17+
* The issue graded the migration `finding`, on the reasoning that "on the three
18+
* dialects the repo ships, the message channel happens to carry the words".
19+
* That holds for the DML path (a duplicate INSERT), which is what this
20+
* package's other tests exercise. It does not hold for the DDL path this
21+
* branch is in:
22+
*
23+
* | dialect | `CREATE UNIQUE INDEX` over duplicate rows says | old regex |
24+
* |:---|:---|:---|
25+
* | SQLite | `UNIQUE constraint failed: product.code` | ✅ matched |
26+
* | MySQL | `ER_DUP_ENTRY: Duplicate entry 'DUP' for key 'uniq_…'` | ✅ matched |
27+
* | Postgres | `could not create unique index "uniq_…"`, SQLSTATE 23505 | ❌ **missed** |
28+
*
29+
* Postgres does not reuse its DML phrasing here: `duplicate key value violates
30+
* unique constraint` is what a conflicting INSERT says, while a conflicting
31+
* index BUILD says `could not create unique index "…"` and carries the verdict
32+
* on `error.code` (SQLSTATE `23505`, `ERRCODE_UNIQUE_VIOLATION`) with the
33+
* offending tuple on `error.detail`. None of the three old message limbs
34+
* appear in it — so on Postgres, the one dialect where the boot-survival
35+
* branch was needed most, it never fired and `throw e` took the boot down.
36+
* Postgres is a first-class shipped dialect for this package
37+
* (`description: "… Supports PostgreSQL, MySQL, SQLite via Knex"`).
38+
*
39+
* The failures are injected rather than driven through a live Postgres because
40+
* this package's unit suite boots SQLite only; the shapes below are the wire
41+
* shapes `pg`/`mysql2` hand knex, message prefix included.
42+
*
43+
* ## Why this package's OTHER tests keep their own spelling
44+
*
45+
* `sql-driver-schema.test.ts`, `sql-driver-unique-tenancy.test.ts` and
46+
* `adr0120-three-posture-conformance.test.ts` assert on
47+
* `/UNIQUE constraint failed|duplicate key value/`. #6543 asked for a decision
48+
* on those rather than leaving them to the next reader. **They stay as they
49+
* are, deliberately.**
50+
*
51+
* They are not discriminators — they are assertions on what a real driver
52+
* actually emitted when a real duplicate INSERT was refused, and their job is
53+
* to prove the constraint EXISTS in the database. Routing them through
54+
* `isUniqueViolationError` would make them strictly weaker in two ways:
55+
*
56+
* 1. The predicate is deliberately broad (four message limbs, three codes, an
57+
* errno, and a `cause` walk). An assertion through it can no longer
58+
* distinguish "SQLite refused this row on a unique index" from "some error
59+
* the predicate happens to accept", which is the whole content of those
60+
* tests.
61+
* 2. A test that judges with the same predicate the production path judges
62+
* with shares that predicate's blind spots — the two stop being
63+
* independent, and a wrong predicate passes its own tests. That
64+
* independence is exactly what caught the Postgres hole above.
65+
*
66+
* The narrow spelling is therefore the right one THERE, and the shared
67+
* predicate the right one in `src/sql-driver.ts`. The rule that reconciles
68+
* them: **judge with the predicate, assert on the literal.**
69+
*/
70+
71+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
72+
import { SqlDriver } from '../src/index.js';
73+
import type { DeclaredIndexInput } from '../src/index.js';
74+
75+
/** The NULL-safe unique of the #5030 scenario: `COALESCE(organization_id), code`. */
76+
const NULL_SAFE_INDEX: DeclaredIndexInput = {
77+
name: 'uniq_product_organization_id_code',
78+
fields: ['organization_id', 'code'],
79+
unique: 'organization',
80+
nullSafeColumns: ['organization_id'],
81+
};
82+
83+
/** The same index with no NULL-safe key part — the `nullSafe.size > 0` guard's false arm. */
84+
const PLAIN_INDEX: DeclaredIndexInput = {
85+
name: 'uniq_product_code',
86+
fields: ['code'],
87+
unique: true,
88+
};
89+
90+
const PHYSICAL_COLUMNS = new Set(['id', 'organization_id', 'code']);
91+
92+
/**
93+
* What `pg` hands knex when `CREATE UNIQUE INDEX` finds duplicate rows.
94+
* knex prefixes the failing statement onto the message; the primary message is
95+
* `could not create unique index "…"` and the tuple lives on `detail`.
96+
*/
97+
function postgresIndexBuildConflict(): Error {
98+
const err = new Error(
99+
`create unique index "uniq_product_organization_id_code" on "product" ` +
100+
`(COALESCE("organization_id", '__global__'), "code") - ` +
101+
`could not create unique index "uniq_product_organization_id_code"`,
102+
);
103+
Object.assign(err, {
104+
code: '23505',
105+
detail: `Key (COALESCE(organization_id, '__global__'::text), code)=(__global__, DUP) is duplicated.`,
106+
severity: 'ERROR',
107+
routine: '_bt_check_unique',
108+
});
109+
return err;
110+
}
111+
112+
/** mysql2's numeric channel with prose the old regex could not read. */
113+
function mysqlErrnoOnlyConflict(): Error {
114+
const err = new Error('alter table `product` add unique `uniq_product_organization_id_code` - ER_DUP_ENTRY');
115+
Object.assign(err, { errno: 1062, sqlState: '23000' });
116+
return err;
117+
}
118+
119+
/** A failure that is NOT a unique violation and must keep taking the boot down. */
120+
function unrelatedDdlFailure(): Error {
121+
const err = new Error('create unique index "uniq_product_organization_id_code" - permission denied for table product');
122+
Object.assign(err, { code: '42501' });
123+
return err;
124+
}
125+
126+
describe('syncDeclaredIndexes unique-violation discriminator (#6543)', () => {
127+
let driver: SqlDriver;
128+
let realKnex: any;
129+
let errors: string[];
130+
let warns: string[];
131+
132+
/** Make the NULL-safe index creation fail with `err`, and capture the log. */
133+
function arm(err: Error): void {
134+
(driver as any).createNullSafeUniqueIndex = async () => {
135+
throw err;
136+
};
137+
errors = [];
138+
warns = [];
139+
(driver as any).logger = {
140+
warn: (msg: string) => warns.push(String(msg)),
141+
error: (msg: string) => errors.push(String(msg)),
142+
};
143+
}
144+
145+
/** Drive the branch under test directly — `initObjects` is not needed to reach it. */
146+
function sync(indexes: DeclaredIndexInput[]): Promise<void> {
147+
return (driver as any).syncDeclaredIndexes('product', indexes, PHYSICAL_COLUMNS, 'organization_id');
148+
}
149+
150+
beforeEach(async () => {
151+
driver = new SqlDriver({
152+
client: 'better-sqlite3',
153+
connection: { filename: ':memory:' },
154+
useNullAsDefault: true,
155+
});
156+
realKnex = (driver as any).knex;
157+
await realKnex.schema.createTable('product', (t: any) => {
158+
t.string('id').primary();
159+
t.string('organization_id');
160+
t.string('code');
161+
});
162+
});
163+
164+
afterEach(async () => {
165+
// One test stands in for `knex`; put the real one back so teardown closes it.
166+
(driver as any).knex = realKnex;
167+
await driver.disconnect();
168+
});
169+
170+
// ── The channels the old message-only read could not see ──────────────────
171+
172+
it('absorbs a Postgres index-build conflict that names the verdict only on `code` (SQLSTATE 23505)', async () => {
173+
arm(postgresIndexBuildConflict());
174+
175+
// Before #6543 this REJECTED: none of `unique constraint failed`,
176+
// `duplicate entry`, `duplicate key value` appears in Postgres' DDL
177+
// phrasing, so the branch fell through to `throw e` and the boot died on
178+
// exactly the dirty database it exists to survive.
179+
await expect(sync([NULL_SAFE_INDEX])).resolves.toBeUndefined();
180+
181+
// Absorbed the way the branch promises: the durability-degradation
182+
// channel, naming the constraint that is NOT enforced and the way out.
183+
expect(errors).toHaveLength(1);
184+
expect(errors[0]).toMatch(/cannot create NULL-safe unique index/);
185+
expect(errors[0]).toMatch(/uniq_product_organization_id_code/);
186+
expect(errors[0]).toMatch(/NOT enforced/);
187+
expect(errors[0]).toMatch(/#5030/);
188+
expect(errors[0]).toMatch(/ADR-0120 D4/);
189+
});
190+
191+
it('absorbs a MySQL conflict carried only on `errno` (1062)', async () => {
192+
arm(mysqlErrnoOnlyConflict());
193+
194+
await expect(sync([NULL_SAFE_INDEX])).resolves.toBeUndefined();
195+
expect(errors).toHaveLength(1);
196+
expect(errors[0]).toMatch(/#5030/);
197+
});
198+
199+
it('reads the violation through a driver `cause` wrapper', async () => {
200+
const wrapped = new Error('index sync failed');
201+
Object.assign(wrapped, { cause: postgresIndexBuildConflict() });
202+
arm(wrapped);
203+
204+
await expect(sync([NULL_SAFE_INDEX])).resolves.toBeUndefined();
205+
expect(errors).toHaveLength(1);
206+
expect(errors[0]).toMatch(/#5030/);
207+
});
208+
209+
// ── Nothing the old regex caught may be narrowed ──────────────────────────
210+
211+
it.each([
212+
['sqlite', 'UNIQUE constraint failed: product.organization_id, product.code'],
213+
['mysql', "ER_DUP_ENTRY: Duplicate entry 'DUP' for key 'uniq_product_organization_id_code'"],
214+
['postgres dml', 'duplicate key value violates unique constraint "uniq_product_organization_id_code"'],
215+
])('still absorbs the %s message spelling the inline regex used to match', async (_dialect, message) => {
216+
arm(new Error(message));
217+
218+
await expect(sync([NULL_SAFE_INDEX])).resolves.toBeUndefined();
219+
expect(errors).toHaveLength(1);
220+
expect(errors[0]).toMatch(/#5030/);
221+
});
222+
223+
// ── The site's own business logic, untouched by the migration ─────────────
224+
225+
it('leaves the `nullSafe.size > 0` guard intact — a plain unique still fails the sync', async () => {
226+
arm(postgresIndexBuildConflict());
227+
// The plain arm goes through knex's schema builder rather than the
228+
// overridden method, and `knex.schema` is a fresh builder on every access
229+
// — so the failure is injected by standing in for `knex` itself.
230+
(driver as any).getExistingIndexNames = async () => new Set<string>();
231+
(driver as any).knex = {
232+
schema: {
233+
alterTable: () => Promise.reject(postgresIndexBuildConflict()),
234+
},
235+
};
236+
237+
// A unique violation on a NON-NULL-safe index is not the #5030 case and
238+
// must still surface: absorbing it would silently ship an unenforced
239+
// constraint the drift pre-flight was never told about.
240+
const rejected: any = await sync([PLAIN_INDEX]).then(
241+
() => undefined,
242+
(e: unknown) => e,
243+
);
244+
expect(rejected).toBeInstanceOf(Error);
245+
expect(rejected.code).toBe('23505');
246+
expect(errors).toHaveLength(0);
247+
});
248+
249+
it('rethrows a failure that is not a unique violation, identity preserved', async () => {
250+
const original = unrelatedDdlFailure();
251+
arm(original);
252+
253+
const rejected: any = await sync([NULL_SAFE_INDEX]).then(
254+
() => undefined,
255+
(e: unknown) => e,
256+
);
257+
expect(rejected).toBe(original);
258+
expect(rejected.code).toBe('42501');
259+
expect(errors).toHaveLength(0);
260+
});
261+
262+
it('still treats an "already exists" race as benign, ahead of the conflict branch', async () => {
263+
const race = new Error('create unique index - index "uniq_product_organization_id_code" already exists');
264+
Object.assign(race, { code: '42P07' });
265+
arm(race);
266+
267+
await expect(sync([NULL_SAFE_INDEX])).resolves.toBeUndefined();
268+
// Benign: absorbed WITHOUT the durability-degradation log, because the
269+
// constraint IS enforced — a different outcome from the #5030 branch.
270+
expect(errors).toHaveLength(0);
271+
});
272+
});

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

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import type { DriverQuery, IDataDriver } from '@objectstack/spec/contracts';
4040
import { StandardErrorCode } from '@objectstack/spec/api';
4141
import { StorageNameMapping } from '@objectstack/spec/system';
4242
import { ExternalSchemaModeViolationError } from '@objectstack/spec/shared';
43-
import { resolveTenancyPosture } from '@objectstack/types';
43+
import { isUniqueViolationError, resolveTenancyPosture } from '@objectstack/types';
4444
import { postureEnforcesWall } from '@objectstack/spec/security';
4545
import { nextUtcCalendarDay } from '@objectstack/core';
4646
import {
@@ -6348,7 +6348,17 @@ export class SqlDriver implements IDataDriver {
63486348
// different name can race us here — both are benign for our intent
63496349
// (the index exists). Anything else is a real failure.
63506350
if (/already exists|duplicate key name|exists/i.test(msg)) continue;
6351-
if (nullSafe.size > 0 && /unique constraint failed|duplicate entry|duplicate key value/i.test(msg)) {
6351+
// The ERROR OBJECT, not `msg` (#6543). This used to be a private
6352+
// inline regex over the message alone, which is the only channel the
6353+
// SQLite family reliably fills — but Postgres answers this exact
6354+
// failure with `could not create unique index "…"` and puts the
6355+
// verdict on `code` (SQLSTATE 23505) instead, so a message-only read
6356+
// missed the dialect entirely and took the boot down on the very case
6357+
// the branch below exists to absorb. The shared predicate reads
6358+
// `code` / `errno` / `message` / `cause`; see
6359+
// `@objectstack/types`' `unique-violation.ts` for why it is the one
6360+
// name for this question.
6361+
if (nullSafe.size > 0 && isUniqueViolationError(e)) {
63526362
// Existing rows violate the NULL-safe unique — the #5030 defect made
63536363
// visible. Do not take the boot down: the declared constraint is not
63546364
// enforced yet, say so at `error` (from the outside everything looks
@@ -6398,8 +6408,16 @@ export class SqlDriver implements IDataDriver {
63986408
await this.knex.raw(sql);
63996409
} catch (e: any) {
64006410
const msg = String(e?.message ?? e);
6411+
// The positive limb is this site's own question — "does this server
6412+
// reject functional key parts?" — and stays a message test, because
6413+
// that is the only channel the answer is on. The NEGATIVE limb was a
6414+
// seventh spelling of the unique-violation vocabulary (`/duplicate/i`)
6415+
// and is now the shared predicate (#6543): a conflict must never be
6416+
// read as a syntax rejection and silently degraded to the bare
6417+
// composite, and on the `errno`-only shape mysql2 can hand back, a
6418+
// message-only exclusion did not fire.
64016419
const functionalUnsupported =
6402-
this.isMysql && /syntax|functional|not supported|near '\(/i.test(msg) && !/duplicate/i.test(msg);
6420+
this.isMysql && /syntax|functional|not supported|near '\(/i.test(msg) && !isUniqueViolationError(e);
64036421
if (!functionalUnsupported) throw e;
64046422
(this.logger.error ?? this.logger.warn)(
64056423
`[sql-driver] this MySQL/MariaDB server rejects functional key parts — created '${name}' on ` +

0 commit comments

Comments
 (0)