Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/hash-shadow-unique-key-utf8mb4.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
---
'@objectstack/driver-sql': minor
---

driver-sql (MySQL): carry an over-long UNIQUE index on a hash-shadow column

On utf8mb4 InnoDB a key part holds at most 3072 bytes (768 characters), so a
full-value UNIQUE index over a longer column is inexpressible — an OAuth access
token that is a multi-KB JWT cannot be made keyable by any declared bound.
Measured on live MySQL 8.0.46, 7 of 44 exported platform objects failed
`syncSchema` outright and landed registered with their declared uniqueness
absent (Postgres 16.13: 0 of 44).

Such a UNIQUE index is now carried by a driver-owned `<index>__hash` column — a
`STORED GENERATED` `VARBINARY(32)` holding the full, untruncated SHA-256 of the
key values — with the unique index on that column. Uniqueness is still enforced
over the whole value: distinct values sharing a long prefix are both accepted
(the property that ruled out prefix-unique indexes), NULLs stay distinct, and a
composite tuple containing NULL conflicts with nothing.

The shadow is created only *after* the server refuses the direct index, so the
dialect divergence is selected by the error code rather than by a dialect check:
Postgres and SQLite are byte-identical to before. Non-unique indexes are
deliberately left refused — an index over a digest accelerates no lookup the
planner can reach.
34 changes: 34 additions & 0 deletions packages/drivers/driver-sql/src/schema-drift.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -327,6 +327,36 @@ export interface ManagedDriftEntry extends SchemaDiffEntry {
/** Columns the driver creates unconditionally — never metadata fields. */
export const BUILTIN_COLUMNS = new Set(['id', 'created_at', 'updated_at']);

/**
* Suffix of a HASH-SHADOW column — the driver-owned column that carries a
* declared UNIQUE index MySQL cannot express over the values themselves
* (#11627). See `SqlDriver.createHashShadowUniqueIndex` for why it exists and
* what it stores.
*/
export const HASH_SHADOW_SUFFIX = '__hash';

/**
* Is this physical column a driver-owned hash shadow (#11627)?
*
* ⚠️ Load-bearing for the ORPHAN differ below, and the reason this predicate
* is exported rather than inlined. A hash-shadow column exists in the database
* and — by construction — in no metadata field, which is the exact shape the
* orphan pass reports as `unmapped_column` with a `drop_column` op. Dropping it
* would take the UNIQUE index it carries with it, silently returning the object
* to "registered but its declared uniqueness unenforced" — the very state
* #11374/#11627 exist to end, reached this time through the migration tool
* rather than through a refused DDL.
*
* Matched by SUFFIX rather than by a registry of known names, deliberately: the
* differ runs against a database whose metadata it is comparing to, and a
* shadow whose declared index has since been removed must still be recognised
* as driver-owned (it is then cleaned up by the index's own removal path, not
* by a blind column drop).
*/
export function isHashShadowColumn(name: string): boolean {
return name.endsWith(HASH_SHADOW_SUFFIX);
}

/** Minimal shape of an introspected physical column (see SqlDriver.introspectColumns). */
export interface PhysicalColumn {
name: string;
Expand DownExpand Up@@ -838,6 +868,10 @@ export function diffManagedTable(args: {
// ── orphaned columns (physical column, no metadata field) ──────────
for (const col of columns) {
if (BUILTIN_COLUMNS.has(col.name)) continue;
// Driver-owned hash shadow (#11627), never a metadata field — see
// {@link isHashShadowColumn} for why dropping it as an orphan would
// silently disable the UNIQUE constraint it carries.
if (isHashShadowColumn(col.name)) continue;
if (expectedColumns.has(col.name)) continue;
out.push({
kind: 'unmapped_column',
Expand Down
Loading
Loading