diff --git a/.changeset/driver-sql-single-column-unique-introspection.md b/.changeset/driver-sql-single-column-unique-introspection.md
new file mode 100644
index 0000000000..e7315e8ecf
--- /dev/null
+++ b/.changeset/driver-sql-single-column-unique-introspection.md
@@ -0,0 +1,58 @@
+---
+"@objectstack/driver-sql": patch
+---
+
+fix(driver-sql): `introspectUniqueConstraints` reports single-column uniqueness on all three dialects (#11202)
+
+`SqlDriver.introspectUniqueConstraints` returns a flat `string[]` that
+`introspectSchema` folds into a per-column `isUnique` flag, and the three dialect arms
+disagreed about what that list meant. SQLite pushed a column only when the unique index
+had exactly one column; the Postgres and MySQL arms returned **every member of every
+composite constraint**. So for `UNIQUE (a, b)` the same table read through Postgres
+claimed `a` alone is unique *and* `b` alone is unique — a claim the constraint does not
+make — while through SQLite it claimed neither.
+
+The divergence was latent rather than active until recently: the Postgres arm's query
+selected `c.column_name` with no alias `c` in scope, and the bare `catch {}` the method
+carried until #11161 turned every execution into `[]`. Live Postgres had therefore never
+once reported a unique constraint through this method. Repairing that query is what put
+three dialects into conflict on live systems for the first time.
+
+Per maintainer ruling 2026-08-23 (option A→B), the flag is now narrowed to
+**single-column uniqueness only**: a column is reported iff some unique constraint covers
+that column and nothing else. A composite constraint's members are deliberately absent —
+a per-column boolean is structurally unable to say "a and b are unique *together*", so
+setting it on both members asserts something different and false. Representing composite
+constraints is option B and waits for real demand; until it exists, an absent flag on a
+composite member means "not single-column unique", never "no constraint".
+
+All three arms now normalise their rows to a `UniqueConstraintMember` and decide through
+one predicate, so a fourth dialect cannot quietly acquire a fourth meaning. The Postgres
+arm additionally selects `constraint_schema` and keys constraint identity on
+`(schema, name)`: its answer spans `current_schemas(false)` and Postgres auto-names a
+unique constraint after the table and column, so two same-named tables in two schemas
+produce two different constraints under one name — keyed on the name alone they would
+fuse into an apparent two-member constraint and drop a genuinely single-column unique
+(the #11201 defect class, one method over).
+
+Two smaller corrections ride the same rewrite, both in the SQLite arm's handling of
+`PRAGMA index_info` rows: an expression-index term (`… ON t (lower(a))`) reports
+`name: null`, which the arm used to push into a `string[]` as a literal `null` — it is
+now discarded, while still counting toward the index's width so `(d, lower(e))` cannot
+read as single-column; and the returned columns are de-duplicated, so a column carrying
+both a `UNIQUE` clause and a hand-made unique index is named once.
+
+No interface shape and no accepted input changes, and `isUnique` is only ever *set* to
+`true`, so a column that stops being flagged carries `undefined` exactly as an
+unconstrained column always has. The one in-tree consumer is
+`introspectedSchemaToObjects` in `@objectstack/objectql`, which turns the flag into a
+drafted field's `unique: true` — it is the direct beneficiary: composite members no
+longer draft fields declaring a single-column uniqueness the database never enforced.
+
+Verified on embedded SQLite, including the consumer-visible `introspectSchema` fold; the
+live Postgres and MySQL cells are declared through the shared dialect matrix and run in
+the `Temporal Conformance (live PG + MySQL)` job. The narrowing predicate is pinned
+directly against each dialect's real row shape, so the Postgres and MySQL decision is
+measurable without a provisioned server. Reverse-verified by ablation: with the width
+filter removed, 9 of the new pins fail — the Postgres and MySQL row-shape cases, the
+end-to-end SQLite cell, and the `isUnique` fold.
diff --git a/packages/drivers/driver-sql/src/sql-driver-11202-single-column-unique.test.ts b/packages/drivers/driver-sql/src/sql-driver-11202-single-column-unique.test.ts
new file mode 100644
index 0000000000..cac43288f5
--- /dev/null
+++ b/packages/drivers/driver-sql/src/sql-driver-11202-single-column-unique.test.ts
@@ -0,0 +1,323 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [#11202] `introspectUniqueConstraints` reports SINGLE-COLUMN uniqueness
+ * only, and reports it identically on all three dialects.
+ *
+ * The three arms used to answer three different questions. SQLite kept unique
+ * indexes of exactly one column (`info.length === 1`); Postgres and MySQL
+ * returned every member of every composite constraint. `introspectSchema`
+ * folds the flat `string[]` into a per-column `isUnique`, so for `UNIQUE
+ * (a, b)` the same table read through Postgres claimed `a` alone is unique and
+ * `b` alone is unique — a claim the constraint does not make — while through
+ * SQLite it claimed neither.
+ *
+ * The divergence was LATENT until #11161: the Postgres arm's query named an
+ * alias that was not in scope, and the bare `catch {}` it carried turned every
+ * execution into `[]`. Repairing the query is what put three live answers into
+ * conflict for the first time.
+ *
+ * Maintainer ruling 2026-08-23 (option A→B), verbatim and untranslated:
+ * 「10950 不考虑存量,其他接受你的建议」 — narrow the flag to single-column
+ * uniqueness now; a composite representation waits for real demand.
+ *
+ * ## Why this file has two halves
+ *
+ * The **predicate half** feeds `singleColumnUniqueColumns` the exact row
+ * shapes each dialect's query returns. It runs everywhere, which matters
+ * because the Postgres and MySQL narrowing is otherwise only measurable on a
+ * provisioned live server — the arms now group in JS precisely so the decision
+ * is testable without one. What it cannot prove is that the queries really
+ * return those rows.
+ *
+ * The **live half** proves that, end to end, on every provisioned cell, and is
+ * declared through `declareDialectCell` so an unprovisioned dialect is a NAMED
+ * skip (a red under `OS_EXPECT_LIVE_DIALECT_MATRIX=1`), never a silent pass.
+ *
+ * ## The interesting assertion is an ABSENCE, so the fixture is proven first
+ *
+ * "`a` is not flagged" goes green for free on a table whose composite
+ * constraint never got created. Every live cell therefore first makes the
+ * DATABASE state its own witness: two rows sharing `a` are ACCEPTED (so `a`
+ * alone is genuinely not unique — exactly what the flag must not claim), a
+ * repeat of the `(a, b)` pair is REJECTED (so the composite constraint exists
+ * and is enforced), and a repeat of `email` is REJECTED (so the single-column
+ * constraint that must be flagged exists too). If the fixture is not real,
+ * that case fails before any absence is asserted.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { SqlDriver, singleColumnUniqueColumns } from './sql-driver.js';
+import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';
+
+const MATRIX = 'single-column unique introspection';
+
+/** Composite `UNIQUE (a, b)` plus a single-column `UNIQUE (email)`. */
+const TABLE = 'os11202_uniq';
+
+/** `introspectUniqueConstraints` is `protected`; this is the narrowest reach. */
+class UniqueProbeDriver extends SqlDriver {
+ uniqueConstraints(table: string) {
+ return this.introspectUniqueConstraints(table);
+ }
+}
+
+// ── Half 1: the predicate, on every dialect's real row shape ────────────────
+
+describe('singleColumnUniqueColumns — the one definition of what the flag means (#11202)', () => {
+ it('keeps a one-member constraint and drops every member of a composite one', () => {
+ expect(
+ singleColumnUniqueColumns([
+ { constraint: ['pair'], column: 'a' },
+ { constraint: ['pair'], column: 'b' },
+ { constraint: ['solo'], column: 'email' },
+ ]),
+ ).toEqual(['email']);
+ });
+
+ it('a three-column constraint contributes nothing — width is counted, not assumed to be two', () => {
+ expect(
+ singleColumnUniqueColumns([
+ { constraint: ['triple'], column: 'a' },
+ { constraint: ['triple'], column: 'b' },
+ { constraint: ['triple'], column: 'c' },
+ ]),
+ ).toEqual([]);
+ });
+
+ it('a column carrying TWO separate single-column constraints is named once', () => {
+ expect(
+ singleColumnUniqueColumns([
+ { constraint: ['by_clause'], column: 'email' },
+ { constraint: ['by_index'], column: 'email' },
+ ]),
+ ).toEqual(['email']);
+ });
+
+ it('a column that is BOTH a composite member and single-column unique is still flagged', () => {
+ // The composite membership must not veto the standalone constraint: `a`
+ // really is unique on its own here, by a constraint of its own.
+ expect(
+ singleColumnUniqueColumns([
+ { constraint: ['pair'], column: 'a' },
+ { constraint: ['pair'], column: 'b' },
+ { constraint: ['solo_a'], column: 'a' },
+ ]),
+ ).toEqual(['a']);
+ });
+
+ it('an empty answer stays empty — no constraint, nothing flagged', () => {
+ expect(singleColumnUniqueColumns([])).toEqual([]);
+ });
+
+ describe('the Postgres row shape — identity is (schema, name), not name alone', () => {
+ /**
+ * The arm's answer spans `current_schemas(false)`, and Postgres auto-names
+ * a unique constraint `
__key` — so two same-named tables in
+ * two schemas hand back two DIFFERENT constraints under one name. Keyed on
+ * the name alone they fuse into an apparent two-member constraint and the
+ * genuine single-column unique disappears from the answer. This is the
+ * #11201 defect class, one method over.
+ */
+ it('same constraint name in two schemas stays two constraints', () => {
+ const rows = [
+ { constraint_schema: 'app', constraint_name: 'orders_email_key', column_name: 'email' },
+ { constraint_schema: 'other', constraint_name: 'orders_email_key', column_name: 'email' },
+ ];
+ const members = rows.map((row) => ({
+ constraint: [row.constraint_schema, row.constraint_name],
+ column: row.column_name,
+ }));
+ expect(singleColumnUniqueColumns(members)).toEqual(['email']);
+
+ // The counterfactual: keyed on the name alone, the same rows lose it.
+ const nameOnly = rows.map((row) => ({
+ constraint: [row.constraint_name],
+ column: row.column_name,
+ }));
+ expect(singleColumnUniqueColumns(nameOnly)).toEqual([]);
+ });
+
+ it('a composite constraint in one schema is dropped, its single-column sibling kept', () => {
+ const rows = [
+ { constraint_schema: 'app', constraint_name: 'os11202_ab', column_name: 'a' },
+ { constraint_schema: 'app', constraint_name: 'os11202_ab', column_name: 'b' },
+ { constraint_schema: 'app', constraint_name: 'os11202_email', column_name: 'email' },
+ ];
+ expect(
+ singleColumnUniqueColumns(
+ rows.map((row) => ({
+ constraint: [row.constraint_schema, row.constraint_name],
+ column: row.column_name,
+ })),
+ ),
+ ).toEqual(['email']);
+ });
+ });
+
+ describe('the MySQL row shape — SCREAMING keys, one identity part', () => {
+ it('composite members are dropped, the single-column constraint kept', () => {
+ const rows = [
+ { CONSTRAINT_NAME: 'os11202_ab', COLUMN_NAME: 'a' },
+ { CONSTRAINT_NAME: 'os11202_ab', COLUMN_NAME: 'b' },
+ { CONSTRAINT_NAME: 'os11202_email', COLUMN_NAME: 'email' },
+ ];
+ expect(
+ singleColumnUniqueColumns(
+ rows.map((row) => ({ constraint: [row.CONSTRAINT_NAME], column: row.COLUMN_NAME })),
+ ),
+ ).toEqual(['email']);
+ });
+ });
+
+ describe('the SQLite row shape — an index member is not always a column', () => {
+ it('a one-term EXPRESSION index contributes nothing, and never a null', () => {
+ // `PRAGMA index_info` reports `name: null` for an expression term. The
+ // arm used to push that row's `name` straight into a `string[]`.
+ const flagged = singleColumnUniqueColumns([
+ { constraint: ['os11202_lower_c'], column: null },
+ ]);
+ expect(flagged).toEqual([]);
+ expect(flagged).not.toContain(null);
+ });
+
+ it('a column PAIRED with an expression term is not single-column unique', () => {
+ // The unnamed member still occupies a slot: `(d, lower(e))` is a
+ // two-member index, so `d` alone is not unique and must not be flagged.
+ expect(
+ singleColumnUniqueColumns([
+ { constraint: ['os11202_d_lower_e'], column: 'd' },
+ { constraint: ['os11202_d_lower_e'], column: null },
+ ]),
+ ).toEqual([]);
+ });
+ });
+});
+
+// ── Half 2: end to end, on every provisioned dialect ────────────────────────
+
+function declareSingleColumnUniqueSuite(cell: DialectCell): void {
+ describe(`introspectUniqueConstraints — single-column only — ${cell.label} (#11202)`, () => {
+ let driver: UniqueProbeDriver;
+
+ beforeAll(async () => {
+ driver = new UniqueProbeDriver(cell.config());
+ await driver.execute(`drop table if exists ${TABLE}`).catch(() => {});
+ // No primary key on purpose: SQLite materialises a non-INTEGER primary
+ // key as a unique auto-index that `PRAGMA index_list` reports, while the
+ // Postgres and MySQL arms filter on `CONSTRAINT_TYPE = 'UNIQUE'` and so
+ // never see primary keys at all. Keeping keys out of the fixture makes
+ // this suite measure the composite-vs-single question and nothing else.
+ await driver.execute(
+ `create table ${TABLE} (
+ a varchar(64) not null,
+ b varchar(64) not null,
+ email varchar(64) not null,
+ note varchar(64),
+ constraint os11202_ab unique (a, b),
+ constraint os11202_email unique (email)
+ )`,
+ );
+ });
+
+ afterAll(async () => {
+ await driver.execute(`drop table if exists ${TABLE}`).catch(() => {});
+ await driver.disconnect().catch(() => {});
+ });
+
+ it('the fixture is real: `a` alone is NOT unique, the pair IS, and `email` IS', async () => {
+ // Non-vacuity, asserted against the server rather than the catalog: an
+ // absence assertion below is worthless if the constraints never landed.
+ await driver.execute(`insert into ${TABLE} (a, b, email) values ('x', '1', 'e1@example.com')`);
+
+ // Two rows sharing `a` — ACCEPTED. This is the fact the flag must not
+ // contradict: `a` is not unique on its own.
+ await driver.execute(`insert into ${TABLE} (a, b, email) values ('x', '2', 'e2@example.com')`);
+
+ // The PAIR repeated — REJECTED, so the composite constraint is enforced.
+ await expect(
+ driver.execute(`insert into ${TABLE} (a, b, email) values ('x', '1', 'e3@example.com')`),
+ ).rejects.toThrow();
+
+ // `email` repeated — REJECTED, so the single-column constraint exists.
+ await expect(
+ driver.execute(`insert into ${TABLE} (a, b, email) values ('y', '9', 'e1@example.com')`),
+ ).rejects.toThrow();
+ });
+
+ it('reports the single-column unique column and NEITHER member of the composite one', async () => {
+ const columns = await driver.uniqueConstraints(TABLE);
+
+ expect(columns).toContain('email');
+ expect(columns).not.toContain('a');
+ expect(columns).not.toContain('b');
+ expect(columns).not.toContain('note');
+ // Exact, so a dialect that starts reporting something extra is caught
+ // rather than absorbed by the three `not.toContain`s above.
+ expect(columns).toEqual(['email']);
+ });
+
+ it("`introspectSchema` folds that into `isUnique` — the consumer-visible half", async () => {
+ const schema = await driver.introspectSchema();
+ const table = schema.tables[TABLE];
+ expect(table, `${TABLE} missing from the introspected schema`).toBeDefined();
+
+ const byName = Object.fromEntries(table.columns.map((col) => [col.name, col]));
+ expect(byName.email?.isUnique).toBe(true);
+ // Falsy, not `false`: the flag is only ever SET to `true`, so a
+ // non-unique column carries `undefined` and asserting `false` would pin
+ // a shape the producer does not emit.
+ expect(byName.a?.isUnique).toBeFalsy();
+ expect(byName.b?.isUnique).toBeFalsy();
+ expect(byName.note?.isUnique).toBeFalsy();
+ });
+ });
+}
+
+for (const cell of DIALECT_CELLS) {
+ declareDialectCell(cell, MATRIX, declareSingleColumnUniqueSuite);
+}
+
+// ── SQLite-only: the member shapes no other dialect can produce ─────────────
+
+describe('SQLite expression indexes — a unique index member that is not a column (#11202)', () => {
+ const EXPR_TABLE = 'os11202_expr';
+ let driver: UniqueProbeDriver;
+
+ beforeAll(async () => {
+ driver = new UniqueProbeDriver({
+ client: 'better-sqlite3',
+ connection: { filename: ':memory:' },
+ useNullAsDefault: true,
+ });
+ await driver.execute(
+ `create table ${EXPR_TABLE} (c varchar(64), d varchar(64), e varchar(64), f varchar(64))`,
+ );
+ // One-term expression index: a single member carrying no column name.
+ await driver.execute(`create unique index os11202_lower_c on ${EXPR_TABLE} (lower(c))`);
+ // Column + expression: two members, so `d` is not unique on its own.
+ await driver.execute(`create unique index os11202_d_lower_e on ${EXPR_TABLE} (d, lower(e))`);
+ // A plain single-column unique index, so this suite has a positive too.
+ await driver.execute(`create unique index os11202_f on ${EXPR_TABLE} (f)`);
+ });
+
+ afterAll(async () => {
+ await driver.disconnect().catch(() => {});
+ });
+
+ it('PRAGMA index_info really reports a null name for an expression term', async () => {
+ // Pins the premise the arm's null-handling rests on. If SQLite ever named
+ // these members, the handling would be dead code and should be re-read.
+ const info: any = await driver.execute(`PRAGMA index_info(os11202_lower_c)`);
+ const rows = Array.isArray(info) ? info : [];
+ expect(rows).toHaveLength(1);
+ expect(rows[0].name).toBeNull();
+ });
+
+ it('flags only the plain single-column index — no nulls, no expression terms', async () => {
+ const columns = await driver.uniqueConstraints(EXPR_TABLE);
+ expect(columns).toEqual(['f']);
+ expect(columns).not.toContain(null);
+ expect(columns).not.toContain('d');
+ });
+});
diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts
index 865d3e5bee..c799779952 100644
--- a/packages/drivers/driver-sql/src/sql-driver.ts
+++ b/packages/drivers/driver-sql/src/sql-driver.ts
@@ -3683,7 +3683,24 @@ function nullSafeNegationOperand(node: Record): Record {
- const uniqueColumns: string[] = [];
+ const members: UniqueConstraintMember[] = [];
try {
if (this.isPostgres) {
@@ -14092,9 +14136,18 @@ export class SqlDriver implements IDataDriver {
// user can read, so without it the newly-working query would report a
// same-named table's constraints from a schema the session never
// reaches.
+ // `constraint_schema` is SELECTed, not just joined on, because it is
+ // half the constraint's IDENTITY here (#11202). `table_schema = ANY
+ // (current_schemas(false))` deliberately spans every schema on the
+ // search path, and Postgres auto-names a unique constraint after the
+ // table and column (`orders_email_key`) — so two same-named tables in
+ // two schemas produce two DIFFERENT constraints carrying the SAME
+ // name. Grouping on the name alone would fuse them into one apparent
+ // two-member constraint and drop a genuinely single-column unique from
+ // the answer.
const result = await this.knex.raw(
`
- SELECT ccu.column_name
+ SELECT tc.constraint_schema, tc.constraint_name, ccu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage AS ccu
ON tc.constraint_schema = ccu.constraint_schema
@@ -14107,12 +14160,19 @@ export class SqlDriver implements IDataDriver {
);
for (const row of result.rows) {
- uniqueColumns.push(row.column_name);
+ members.push({
+ constraint: [String(row.constraint_schema), String(row.constraint_name)],
+ column: row.column_name ?? null,
+ });
}
} else if (this.isMysql) {
+ // `TABLE_SCHEMA = DATABASE()` and `TABLE_NAME = ?` are both pinned in
+ // the WHERE and merged by the `USING` join, so a constraint name is
+ // already unique within this answer — one identity part suffices,
+ // unlike the Postgres arm above.
const result = await this.knex.raw(
`
- SELECT COLUMN_NAME
+ SELECT CONSTRAINT_NAME, COLUMN_NAME
FROM information_schema.TABLE_CONSTRAINTS tc
JOIN information_schema.KEY_COLUMN_USAGE kcu
USING (CONSTRAINT_NAME, TABLE_SCHEMA, TABLE_NAME)
@@ -14124,7 +14184,10 @@ export class SqlDriver implements IDataDriver {
);
for (const row of result[0]) {
- uniqueColumns.push(row.COLUMN_NAME);
+ members.push({
+ constraint: [String(row.CONSTRAINT_NAME)],
+ column: row.COLUMN_NAME ?? null,
+ });
}
} else if (this.isSqlite) {
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, '');
@@ -14133,16 +14196,26 @@ export class SqlDriver implements IDataDriver {
const tableNames = Array.isArray(tablesResult) ? tablesResult.map((row: any) => row.name) : [];
if (!tableNames.includes(safeTableName)) {
- return uniqueColumns;
+ return [];
}
const indexes = await this.knex.raw(`PRAGMA index_list(${safeTableName})`);
for (const idx of indexes) {
if (idx.unique === 1) {
+ // `PRAGMA index_info` reports one row per index MEMBER, and a
+ // member is not always a column: an expression term
+ // (`CREATE UNIQUE INDEX … ON t (lower(a))`) arrives with
+ // `name: null`. Every member is carried through so it still
+ // COUNTS toward the index's width — dropping the unnamed ones
+ // here would make `(a, lower(b))` look single-column and flag `a`.
+ // The `null` is discarded later, where it can only cost a flag
+ // rather than manufacture one. Previously the row's `name` was
+ // pushed unconditionally, so a one-term expression index put a
+ // literal `null` into a `string[]`.
const info = await this.knex.raw(`PRAGMA index_info(${idx.name})`);
- if (info.length === 1) {
- uniqueColumns.push(info[0].name);
+ for (const entry of info) {
+ members.push({ constraint: [String(idx.name)], column: entry.name ?? null });
}
}
}
@@ -14152,6 +14225,81 @@ export class SqlDriver implements IDataDriver {
if (opts.onFailure !== 'partial') throw e;
}
- return uniqueColumns;
+ return singleColumnUniqueColumns(members);
}
}
+
+/**
+ * One (constraint, column) membership row, normalised across dialects —
+ * the input to {@link singleColumnUniqueColumns} (#11202).
+ */
+export interface UniqueConstraintMember {
+ /**
+ * What makes this constraint a DISTINCT constraint within one answer, as
+ * its identity parts.
+ *
+ * Spelled as parts rather than a pre-joined string so each arm has to state
+ * what it is relying on: Postgres needs `[schema, name]` because its answer
+ * spans the search path and auto-generated constraint names repeat across
+ * schemas; MySQL and SQLite pin one schema/database in the query itself and
+ * so need only the name. Two constraints whose parts are equal are treated
+ * as one constraint.
+ */
+ constraint: readonly string[];
+ /**
+ * The column this member covers, or `null` for a member that is not a plain
+ * column — a SQLite expression-index term. A `null` still occupies a member
+ * slot; it simply cannot be flagged.
+ */
+ column: string | null;
+}
+
+/**
+ * The one definition of what `introspectUniqueConstraints` reports: the
+ * columns that ALONE carry a unique constraint (#11202).
+ *
+ * A constraint contributes its column iff it has exactly ONE member and that
+ * member is a real column. Members of a composite constraint contribute
+ * nothing — `UNIQUE (a, b)` says the PAIR is unique, and a per-column flag
+ * cannot say that, so claiming it per column is a claim the constraint never
+ * made.
+ *
+ * ## Both defensive choices fail toward NOT flagging, on purpose
+ *
+ * Members are counted as they arrive, without de-duplicating identical rows.
+ * No in-tree arm can produce a duplicate (each query joins one
+ * constraint-listing row to its own column rows), but if one ever did, the
+ * inflated count would merely hide a genuine single-column unique. The
+ * opposite policy — collapsing rows before counting — could merge two real
+ * members and flag a composite one. An under-claimed flag is a missed
+ * optimisation downstream; an over-claimed one is the defect being fixed
+ * here, and it reaches `introspectSchema`'s per-column `isUnique`, the
+ * federated-object draft (ADR-0015) and schema-drift comparison as fact.
+ *
+ * The returned columns are de-duplicated: a column carrying two separate
+ * single-column unique constraints (or, on SQLite, both a `UNIQUE` clause and
+ * a hand-made unique index) is one unique column, named once.
+ */
+export function singleColumnUniqueColumns(members: readonly UniqueConstraintMember[]): string[] {
+ const byConstraint = new Map();
+
+ for (const member of members) {
+ // JSON is used as the composite-key encoding because it is injective:
+ // joining the parts with a separator would fuse `['a.b', 'c']` and
+ // `['a', 'b.c']`, and a schema or constraint name really can contain the
+ // separator (Postgres quotes such identifiers rather than rejecting them).
+ const key = JSON.stringify(member.constraint);
+ const existing = byConstraint.get(key);
+ if (existing) existing.push(member.column);
+ else byConstraint.set(key, [member.column]);
+ }
+
+ const uniqueColumns: string[] = [];
+ for (const columns of byConstraint.values()) {
+ if (columns.length !== 1) continue;
+ const only = columns[0];
+ if (only == null) continue;
+ if (!uniqueColumns.includes(only)) uniqueColumns.push(only);
+ }
+ return uniqueColumns;
+}
diff --git a/packages/objectql/src/util.ts b/packages/objectql/src/util.ts
index 0f2ea015ad..1d6af0a026 100644
--- a/packages/objectql/src/util.ts
+++ b/packages/objectql/src/util.ts
@@ -31,7 +31,18 @@ import type {
* diff-facing contract does not declare.
*/
export interface IntrospectedColumn extends SpecIntrospectedColumn {
- /** Whether this column has a unique constraint */
+ /**
+ * Whether this column ALONE carries a single-column unique constraint —
+ * true iff some unique constraint covers this column and nothing else.
+ *
+ * Membership of a COMPOSITE constraint is deliberately not represented
+ * (#11202): `UNIQUE (a, b)` constrains the pair, and a per-column boolean
+ * cannot say that. The producer's declaration —
+ * `SqlDriver`'s `IntrospectedColumn.isUnique` in `@objectstack/driver-sql`
+ * — is the contract sentence; this is the consumer-side copy of the same
+ * key and must not drift from it. An absent flag on a composite member
+ * means "not single-column unique", never "no constraint".
+ */
isUnique?: boolean;
/**
* Maximum length for string types — raw as knex `columnInfo()` reports it: