Skip to content

driver-sql: SQLite introspectUniqueConstraints flags a single-column PRIMARY KEY as unique while PG/MySQL never do — and SQLite disagrees with itself by key type #11654

Closed

Description

@os-warren

Found while implementing #11202 (which unified the three arms on single-column-uniqueness semantics). This is the residual cross-dialect divergence in the same method, left deliberately untouched there because it is a semantic question rather than a mechanical one — the flag it produces is not false, only inconsistent.

What

SqlDriver.introspectUniqueConstraints reaches its answer from a different catalog per dialect, and the two catalogs disagree about whether a PRIMARY KEY is a unique constraint:

  • Postgres / MySQL filter on CONSTRAINT_TYPE = 'UNIQUE', which excludes primary keys outright. A primary key is never reported through this method on either dialect.
  • SQLite iterates PRAGMA index_list and keys only on idx.unique === 1, never on origin. SQLite materialises a non-INTEGER primary key as a unique auto-index, so the primary-key column is reported.

Measured on embedded better-sqlite3 (2026-08-24):

create table t_text (id varchar(64) primary key, email varchar(64) unique)
  PRAGMA index_list(t_text) ->
    { name: 'sqlite_autoindex_t_text_2', unique: 1, origin: 'u'  }   -- the UNIQUE(email)
    { name: 'sqlite_autoindex_t_text_1', unique: 1, origin: 'pk' }   -- the PRIMARY KEY(id)

create table t_int (id integer primary key, note varchar(64))
  PRAGMA index_list(t_int) -> []

So introspectSchema sets isUnique: true on t_text.id, and the same table introspected through Postgres or MySQL does not.

The second half: SQLite disagrees with itself

t_int shows the divergence is not even stable within SQLite. An INTEGER PRIMARY KEY is a rowid alias, so SQLite creates no auto-index and index_list is empty — the column is not flagged. A varchar primary key is flagged. The same logical schema therefore produces different isUnique flags depending only on the key's declared type.

Why it matters

The same consumer argument as #11202: the flag reaches introspectedSchemaToObjects in @objectstack/objectql, which turns it into a drafted field's unique: true, and from there into cross-dialect snapshot comparison and the ADR-0015 federated-object draft. A drafted object gains or loses a unique: true on its key column according to which dialect it was drafted from, and according to whether that key is an INTEGER.

Note the direction: unlike #11202 this flag is not lying — a primary-key column really is unique. What is wrong is that three dialects (four, counting SQLite's two cases) answer differently.

Decision needed

Should a PRIMARY KEY column carry isUnique?

  • A — never, matching the PG/MySQL arms: filter the SQLite arm on origin !== 'pk'. Primary-key membership is already reported separately and losslessly through primaryKeys / IntrospectedColumn.primaryKey, so nothing is lost, and isUnique becomes purely "unique constraint that is not the key". Mechanical: origin is already in the index_list rows the arm reads.
  • B — always, matching SQLite's varchar case: report a single-column primary key as unique on all three dialects. Truthful in the strict sense (a PK is unique), and arguably more useful to a consumer asking "can I address a row by this column alone" — but it means adding a primary-key read to the PG and MySQL arms, and it makes isUnique partly redundant with primaryKey.

A is the smaller change and keeps the two flags non-overlapping; B is defensible if any consumer wants "uniquely addressable" as one question. Either way the SQLite self-inconsistency between INTEGER and varchar keys should go.

Filed unassigned, per the finding rule. Not fixed in #11202's PR: the correct form is not pinned by existing evidence, so it fails the bounded in-place exemption's second condition.


Generated by Claude Code

Activity

  1. added theissue type on Aug 24, 2026
  2. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    Contributor

    Triage: cross-driver introspection-conformance family → domain:spec, type Bug, pm:queue — same lane and precedent as #11202 ("introspectUniqueConstraints answers a different question per dialect"), which landed today narrowing the producer to single-column uniqueness. This card is the next cell of that family: SQLite counting a single-column PRIMARY KEY as unique where PG/MySQL never do, and disagreeing with itself by key type.

    ⚠️ Premise check before dispatch: re-measure on the post-#11202 merged ref — that landing touched exactly this producer and may have absorbed or reshaped this cell (the finding #11655, filed against #11202's own doc, shows the surface moved today). If the inconsistency survives, the ruled convention from #11202 (what "unique" means to the introspection contract) decides which face is wrong — inherit it with its reason rather than re-opening the question.


    Generated by Claude Code

  3. self-assigned this
    on Aug 24, 2026
  4. os-warren commented on Aug 24, 2026

    @os-warren
    CollaboratorAuthor

    Claim: PM seat dispatch (spec lane), session session_01Rxnd8cyFnoU8V5y21PaTsy.


    Generated by Claude Code

  5. os-warren commented on Aug 24, 2026

    @os-warren
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 11654,
      "status": "done",
      "branch": "claude/issue-11654-sqlite-pk-unique",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11827",
      "premise_still_valid": true,
      "summary": "The cell was NOT absorbed by #11202/PR #11657 — re-measured at merge base dce6a019f through the driver on embedded better-sqlite3, and every reading the card gives reproduces: PRAGMA index_list(t_text) returns the origin 'u' row for UNIQUE(email) plus an origin 'pk' row for the varchar PRIMARY KEY, introspectUniqueConstraints(t_text) answered ['email','id'] (the PK column flagged) and introspectSchema set t_text.id.isUnique=true, while t_int (INTEGER PRIMARY KEY, a rowid alias) had an empty index_list and answered [] — the same logical schema giving a different isUnique from the declared type of its key alone. Implemented option A as prescribed, inheriting #11202's convention with its reason (isUnique means a DECLARED single-column UNIQUE constraint; PK membership is already reported losslessly via primaryKeys / IntrospectedColumn.primaryKey): the SQLite arm now skips origin 'pk' index rows, killing the cross-dialect divergence and the INTEGER-vs-varchar self-inconsistency in one stroke. The filter is on the INDEX's origin, never on whether the COLUMN is in the key — a key column separately carrying its own unique index really does have a declared single-column UNIQUE and stays flagged; that distinction is pinned because it fails under the wrong sibling implementation. Two shapes the card does not name were measured and changed the test design: a WITHOUT ROWID key still materialises a pk-origin auto-index and carried the identical defect (covered, pinned), and a composite PK was already dropped but only incidentally by #11202's width filter (pinned so this change is not credited with it). Diff is 3 files, +293/-1, entirely inside the introspection arm plus the two TSDoc contract sentences in the same file and a patch changeset. H17 fence verified clean: neither sqliteCanonicalDatetimeSql / backfillCanonicalDatetimes (#6009) nor insertOnlyUpsertColumns / the upsert region (#8740) appears anywhere in the diff — those symbols sit at lines 4007-9630 and 6529/7023 while the three hunks are at 3701, 14775 and 14887. PR #11827 is DRAFT, not flipped ready, auto-merge not armed.",
      "tests": "All readings at head SHA 66d9634df (working tree clean at commit time, so the tested tree IS the committed tree). REVERSE VERIFICATION, pinned first against unfixed source as dispatched — RED leg, full @objectstack/driver-sql suite with the new pins and the unmodified arm: 'Test Files 1 failed | 126 passed | 8 skipped (135)', 'Tests 4 failed | 1972 passed | 112 skipped (2088)', the four failures quoting the gates' own assertions: 'expected [ email, id ] to not include id' (PK flagged), 'expected true to be falsy' (the isUnique fold), and 'expected [ email, id ] to deeply equal [ email ]' twice (the INTEGER-vs-varchar agreement pin and the WITHOUT ROWID pin). GREEN leg after the origin filter: 'Test Files 127 passed | 8 skipped (135)', 'Tests 1976 passed | 112 skipped (2088)'. Exactly 4 tests moved, 1972 to 1976 passed, nothing else — and the four pins that must hold in BOTH directions (non-vacuity, the primaryKeys face, the composite key, the key-column-with-its-own-index case) were already green in the RED leg, which is what makes the red meaningful rather than a broken fixture. Fixtures prove the absence is real before asserting it: a repeated key and a repeated email are both REJECTED by the server. PER-PACKAGE: @objectstack/driver-sql test 127 files passed / 8 skipped, 1976 passed / 112 skipped; @objectstack/driver-sql typecheck exit 0 with 'tsc --noEmit' echoed (not a zero-match silent pass). CONSUMER CHECK: @objectstack/objectql src/util.test.ts — 1 file, 22 tests passed; that is the suite over introspectedSchemaToObjects, the flag's one real consumer, and its own fixture already models a key column as primaryKey:true with NO isUnique, so the consumer's test data had always encoded option A and no fixture triage was needed. GATES: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack run from the worktree AFTER the final commit; derivation line quoted verbatim: 'dispatch-gates: gate list derived from the tree of objectstack-ai/objectstack at commit 66d9634df' and 'change set derived from git — 3 path(s) vs merge base dce6a019f of origin/main and HEAD'. It named 14 path-derived families plus 6 convention-triggered (adds a test file); ALL 20 RUN, ALL exit 0, each exit code captured before any pipe. Verdict lines quoted from the gates themselves: 'check-driver-conformance: OK — 45 covered cell(s), 0 in the DEBT ledger, 0 exempt.', 'check-engine-double-contract: OK — 401 pinned, 133 in the DEBT ledger, 2 exempt.', 'OK: 16 package(s) read outside themselves, all declared', 'check-nul-bytes: OK (scanned 6593 text file(s) ... no raw ASCII control bytes).', 'check-where-matcher: 0 silently-wrong and 0 unjudged matcher(s) ... none new.', 'check-changeset-no-major: This diff introduces no major bump.'. TWO DECLARED NARROWINGS (declared, not silently skipped; CI runs the full farm regardless): (1) check:type-check-debt --re-measure not run locally — it re-runs tsc per LEDGER ENTRY and needs the whole workspace closure built; its population was read from the gate's own model rather than guessed: driver-sql carries no test-typecheck-debt.json and appears in NONE of the gate's DEBT / TEST_DEBT / EXEMPT / UNCHECKED_SOURCE_DEBT / PHANTOM_PIN_DEBT literals (only in its header's history prose as debt already paid), the structural half check:type-check-coverage passed, and the package's own tsc is green. (2) Repo-wide pnpm lint narrowed to the changed files, with all three evidence pieces so it is a measurement not a skip: population read from eslint's OWN config via ESLint#isPathIgnored / calculateConfigForFile (of 3 changed paths exactly 2 are in the linted population at 6 and 5 rules; the changeset .md is not linted at all), count from --format json (2 files linted, 0 errors, 0 warnings, exit 0), and the invariance claim from eslint.config.mjs's own text that this repo 'never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file' — with no cross-file type information in play this diff cannot move the verdict on any untouched file. The container was saturated when measured (load 4.41 on 4 cores, three waiters queued behind an active build), where a repo-wide scan reliably meets the foreground cap and yields nothing. No ablation was performed: the reverse verification here is the pin-first RED/GREEN pair above, run against real source rather than a mutation, so there is no mutation-on-disk or rebuild leg to report. All heavy commands went through scripts/pm/os-verify-lock.sh; dependency closure built first with pnpm --filter '@objectstack/driver-sql^...' build (VERDICT command-exit 0).",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #11826: objectql's consumer-side copy of the IntrospectedColumn.isUnique TSDoc in packages/objectql/src/util.ts under-specifies the contract now that PRIMARY KEYs are excluded — that declaration states in its own text that it 'must not drift from' the producer's sentence, and #11655 was filed and fixed for exactly this drift one round ago. Documentation only, no behaviour depends on it. Filed unassigned with the 'finding' label (matching #11655), NOT fixed in this PR: it fails the bounded in-place exemption's fourth condition, since packages/objectql/src pulls in gate families this diff otherwise never matches (check:durability-log-level, check-engine-split-ratio — the two PR #11657 measured when it amended that same file under a PM authorization), which is a new verification surface rather than a free ride."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  6. os-warren commented on Aug 24, 2026

    @os-warren
    CollaboratorAuthor

    Review (PM seat, session session_01Rxnd8cyFnoU8V5y21PaTsy): ACCEPT on PR #11827 at head 66d9634df.

    Clause-②: no on both limbs (driver introspection behaviour; the diff never enters packages/spec) — no contract-review gate. Landing path: CI at 66d9634df is converging now (started 18:55Z); on full green this PR flips ready and enqueues, ref verified via ls-remote.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions