Skip to content

Fix TS index uniqueness treating a prefix of a composite unique constraint as unique - #5969

Open
captain-mirage wants to merge 1 commit into
clockworklabs:masterfrom
captain-mirage:fix/ts-index-unique-exact-columns
Open

captain-mirage wants to merge 1 commit into
clockworklabs:masterfrom
captain-mirage:fix/ts-index-unique-exact-columns

Conversation

@captain-mirage

Copy link
Copy Markdown

Description of Changes

makeTableView decides whether an index is unique by testing whether the index's columns
are a subset of some unique constraint's columns:

crates/bindings-typescript/src/server/runtime.ts:1197-1200 (on 1906706)

const columnSet = new Set(column_ids);
const isUnique = table.constraints
  .filter(x => x.data.tag === 'Unique')
  .some(x => columnSet.isSubsetOf(new Set(x.data.value.columns)));

A proper subset is not unique. Given a unique constraint on (tenant, email) and a btree
index on tenant alone, {tenant} ⊆ {tenant, email} holds, so the index is built as a
UniqueIndex. On a table with two rows sharing a tenant:

const membership = table(
  {
    name: 'membership',
    indexes: [
      { accessor: 'byTenant',      algorithm: 'btree', columns: ['tenant'] },
      { accessor: 'byTenantEmail', algorithm: 'btree', columns: ['tenant', 'email'] },
    ],
    constraints: [
      { name: 'membership_tenant_email_key', constraint: 'unique', columns: ['tenant', 'email'] },
    ],
  },
  { id: t.u64().primaryKey().autoInc(), tenant: t.string(), email: t.string() }
);

// master:
Object.keys(view.byTenant)      // [ 'find', 'delete' ]
Object.keys(view.byTenantEmail) // [ 'find', 'delete' ]

// with this change:
Object.keys(view.byTenant)      // [ 'filter', 'delete' ]
Object.keys(view.byTenantEmail) // [ 'find', 'delete' ]

Three consequences for byTenant:

  • filter does not exist, so ctx.db.membership.byTenant.filter('acme') — the documented
    way to read a non-unique index, and the only thing the index's type offers (see below)
    — is a TypeError at runtime.
  • find does exist and returns one of the matching rows: the unique branch calls
    datastore_index_scan_point_bsatn and then tableIterateOne, which reads the first row
    and drops the rest. Silently, with no indication that more matched.
  • delete takes the unique branch's point form, so a Range never reaches
    datastore_delete_by_index_scan_range_bsatn, and since Fix TS composite btree range scans #5479 the index is also routed
    around the composite range-scan fix.

Nothing else in the tree agrees with the subset rule:

  • The client cache uses exact equality for the same concept —
    src/sdk/table_cache.ts:188-194, comment and all: "An index is unique if it shares all
    columns with a unique constraint"
    , implemented as
    deepEqual(constraint.columns, idx.columns). So the server and client halves of this
    package classify the same schema differently.
  • The schema validator pairs a unique constraint with its backing index by ColSet
    equality — crates/schema/src/def/validate/v9.rs:281-289,
    .any(|i| ColSet::from(i.algorithm.columns()) == **unique_cols). The comment right above
    it spells out that the prefix relationship is a different, currently unsupported thing,
    and that marking such an index unique "would not be a sound representation of what the
    user wanted".
  • The server's own index typing already says "not unique". Index<TableDef, I> in
    src/lib/indexes.ts branches on I['unique'], which table.ts computes as
    AllUnique<TableDef, Cols> — true only when every column of the index carries its own
    unique()/primaryKey() metadata. For byTenant that is false, so the static type is
    RangedIndex with filter/delete and no find. The runtime was handing back an
    object that contradicted its own declared type: the type-checked call fails and the
    call that works cannot be type-checked.

The change requires the index's column set to equal the constraint's, spelled as a size
comparison plus an every(has) membership test:

.some(x => {
  const constraintColumns = new Set(x.data.value.columns);
  return (
    columnSet.size === constraintColumns.size &&
    [...columnSet].every(column => constraintColumns.has(column))
  );
});

#5558 also rewrites this predicate (to [...columnSet].every(column => constraintColumns.has(column)), which keeps the subset semantics), so the two PRs touch
the same lines. I spelled the check in the same style so that whichever lands second
rebases trivially: the only difference is the size comparison.

Scope check for a second instance: this was the only subset-based uniqueness test in
crates/bindings-typescript/src. isPrimaryKey derives from isUnique but
adds its own exact column-list comparison against table.primaryKey, so it was already
false in the affected configuration and is unchanged. The Rust and C# bindings decide index
uniqueness at macro/codegen time from per-column #[unique] metadata rather than by
comparing column sets at runtime, so there is no analogous defect to fix there.

One related thing I did not change, since it is a different fix: ConstraintOpts in
src/lib/constraints.ts types columns as a one-element tuple
({ constraint: 'unique'; columns: [AllowedCol] }), so a composite unique constraint
currently cannot be declared through the typed table() API without a cast, even though
table() maps over constraintOpts.columns, RawConstraintDefV10's Unique payload is a
column list, UniqueConstraintData is a ColSet, and crates/codegen/src/typescript.rs
emits columns: [...] from whatever the constraint holds. That means today the bug is
reachable through raw/generated table definitions rather than hand-written typed ones — and
it means the generated client bindings for any module that does grow a composite unique
constraint will not type-check. Happy to widen ConstraintOpts in this PR if you'd prefer
them together; I kept them separate so this one stays a pure runtime-correctness fix.

API and ABI breaking changes

No wire-format, ABI or protocol change. There is a behavioural change worth being
explicit about: an index whose columns are a proper subset of a composite unique
constraint's columns changes shape, from { find, delete } to { filter, delete }. Code
calling find on such an index will now get a TypeError.

I think that is the right trade and not a break in practice:

  • find on such an index was never correct — it returns an arbitrary one of the matching
    rows.
  • The static type of such an index has always been RangedIndex, so calling find on it
    never type-checked; reaching it required an any or a cast.
  • Getting a composite unique constraint into a table definition at all needs a cast today
    (the ConstraintOpts note above), so the affected population is small.

I have not applied a breaking-change label for that reason, but I will if you read it the
other way.

Rollback safety impact

n/a — no ControlDB table or reducer, system table, or on-disk data format is written,
changed, or rendered unsupported by this PR. Index and constraint definitions sent to the
host are unchanged; only the accessor object the module sees is affected.

Expected complexity level and risk

  1. The diff is one predicate in one function. What makes it a 2 rather than a 1 is that
    isUnique selects between four index-construction branches in makeTableView, so it is
    worth confirming the unaffected cases explicitly — which the tests do: the index matching
    the constraint exactly stays unique, and the primary-key index stays unique and keeps
    update.

Testing

  • Added crates/bindings-typescript/tests/index_unique_exact_columns.test.ts, modelled
    on tests/index_prefix_filter.test.ts (same vi.mock('../src/server/procedures')
    cycle workaround, same makeTableView entry point). It builds the membership table
    above and asserts, on the real index-construction path:
    - byTenant exposes filter and no find; filter('acme') returns both
    matching rows and takes the point-scan syscall; filter(new Range(...)) returns
    both rows and takes the range-scan syscall;
    - byTenantEmail (exact match with the constraint) still exposes find and no
    filter, and find(['acme', 'ada@acme.test']) returns the row;
    - the primary-key index still exposes find, delete and update.
  • The test needed the host stub to be able to return rows, so it overrides
    row_iter_bsatn_advance and the two index-scan syscalls per test via
    vi.hoisted + vi.mock('spacetime:sys@2.0' | '@2.1') — the idiom from
    tests/schema_schedule.test.ts and tests/environment.test.ts. The shared
    tests/__mocks__/spacetime-sys.ts is untouched. (Both module specifiers have to be
    mocked: runtime.ts builds sys as { ..._syscalls2_0, ..._syscalls2_1 }, so
    mocking only 2.0 is overwritten by 2.1's originals.)
  • Confirmed the first test fails on master for the right reason
    (view.byTenant.filter is undefined) and that the other two pass on master — so
    the change does not move the cases it should not.
  • pnpm test in crates/bindings-typescript: 31 files, 319 tests, all passing.
  • pnpm build (tsup + tsc -p tsconfig.build.json) and pnpm lint
    (eslint . + prettier . --check) clean.
  • Reviewer: a live module check, since my coverage stops at the module-side accessor
    object. An index on a prefix of a composite unique constraint should behave like any
    other non-unique btree index end to end — worth one filter and one ranged delete
    against a real host.
  • Reviewer: whether you want ConstraintOpts.columns widened to a non-empty list in
    this PR or a follow-up.

…raint as unique

`makeTableView` classified an index as unique when its columns were a
*subset* of some unique constraint's columns:

    columnSet.isSubsetOf(new Set(x.data.value.columns))

A btree index on a proper subset — an index on `tenant` next to a unique
constraint on `(tenant, email)` — therefore got the unique accessors. The
runtime handed back an object with `find`/`delete(point)` and no `filter`,
even though `tenant` can match many rows: `find` returns whichever row the
point scan reaches first, `filter` is missing entirely, and since clockworklabs#5479 the
index never reaches the composite range-scan path.

Nothing else in the tree agrees with the subset rule. The client cache uses
exact equality (`table_cache.ts`, "An index is unique if it shares all
columns with a unique constraint"), the schema validator pairs a unique
constraint with an index by `ColSet` equality
(`crates/schema/src/def/validate/v9.rs`), and the server's own index type
(`AllUnique` in `lib/constraints.ts`) reports such an index as ranged — so
the returned object contradicted its own static type.

Require the index's column set to equal the constraint's, spelled as a
size check plus an `every(has)` membership test, so the predicate no
longer depends on `Set.prototype.isSubsetOf`.
@CLAassistant

CLAassistant commented Sep 22, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants