Fix TS index uniqueness treating a prefix of a composite unique constraint as unique - #5969
Open
captain-mirage wants to merge 1 commit into
Open
captain-mirage wants to merge 1 commit into
captain-mirage wants to merge 1 commit into
Conversation
…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`.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description of Changes
makeTableViewdecides whether an index is unique by testing whether the index's columnsare a subset of some unique constraint's columns:
crates/bindings-typescript/src/server/runtime.ts:1197-1200(on1906706)A proper subset is not unique. Given a unique constraint on
(tenant, email)and a btreeindex on
tenantalone,{tenant} ⊆ {tenant, email}holds, so the index is built as aUniqueIndex. On a table with two rows sharing atenant:Three consequences for
byTenant:filterdoes not exist, soctx.db.membership.byTenant.filter('acme')— the documentedway to read a non-unique index, and the only thing the index's type offers (see below)
— is a
TypeErrorat runtime.finddoes exist and returns one of the matching rows: the unique branch callsdatastore_index_scan_point_bsatnand thentableIterateOne, which reads the first rowand drops the rest. Silently, with no indication that more matched.
deletetakes the unique branch's point form, so aRangenever reachesdatastore_delete_by_index_scan_range_bsatn, and since Fix TS composite btree range scans #5479 the index is also routedaround the composite range-scan fix.
Nothing else in the tree agrees with the subset rule:
src/sdk/table_cache.ts:188-194, comment and all: "An index is unique if it shares allcolumns with a unique constraint", implemented as
deepEqual(constraint.columns, idx.columns). So the server and client halves of thispackage classify the same schema differently.
ColSetequality —
crates/schema/src/def/validate/v9.rs:281-289,.any(|i| ColSet::from(i.algorithm.columns()) == **unique_cols). The comment right aboveit 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".
Index<TableDef, I>insrc/lib/indexes.tsbranches onI['unique'], whichtable.tscomputes asAllUnique<TableDef, Cols>— true only when every column of the index carries its ownunique()/primaryKey()metadata. ForbyTenantthat isfalse, so the static type isRangedIndexwithfilter/deleteand nofind. The runtime was handing back anobject 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:#5558 also rewrites this predicate (to
[...columnSet].every(column => constraintColumns.has(column)), which keeps the subset semantics), so the two PRs touchthe 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.isPrimaryKeyderives fromisUniquebutadds its own exact column-list comparison against
table.primaryKey, so it was alreadyfalse 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 bycomparing 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:
ConstraintOptsinsrc/lib/constraints.tstypescolumnsas a one-element tuple(
{ constraint: 'unique'; columns: [AllowedCol] }), so a composite unique constraintcurrently cannot be declared through the typed
table()API without a cast, even thoughtable()maps overconstraintOpts.columns,RawConstraintDefV10'sUniquepayload is acolumn list,
UniqueConstraintDatais aColSet, andcrates/codegen/src/typescript.rsemits
columns: [...]from whatever the constraint holds. That means today the bug isreachable 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
ConstraintOptsin this PR if you'd preferthem 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 }. Codecalling
findon such an index will now get aTypeError.I think that is the right trade and not a break in practice:
findon such an index was never correct — it returns an arbitrary one of the matchingrows.
RangedIndex, so callingfindon itnever type-checked; reaching it required an
anyor a cast.(the
ConstraintOptsnote 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
isUniqueselects between four index-construction branches inmakeTableView, so it isworth 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
crates/bindings-typescript/tests/index_unique_exact_columns.test.ts, modelledon
tests/index_prefix_filter.test.ts(samevi.mock('../src/server/procedures')cycle workaround, same
makeTableViewentry point). It builds themembershiptableabove and asserts, on the real index-construction path:
-
byTenantexposesfilterand nofind;filter('acme')returns bothmatching rows and takes the point-scan syscall;
filter(new Range(...))returnsboth rows and takes the range-scan syscall;
-
byTenantEmail(exact match with the constraint) still exposesfindand nofilter, andfind(['acme', 'ada@acme.test'])returns the row;- the primary-key index still exposes
find,deleteandupdate.row_iter_bsatn_advanceand the two index-scan syscalls per test viavi.hoisted+vi.mock('spacetime:sys@2.0' | '@2.1')— the idiom fromtests/schema_schedule.test.tsandtests/environment.test.ts. The sharedtests/__mocks__/spacetime-sys.tsis untouched. (Both module specifiers have to bemocked:
runtime.tsbuildssysas{ ..._syscalls2_0, ..._syscalls2_1 }, somocking only 2.0 is overwritten by 2.1's originals.)
masterfor the right reason(
view.byTenant.filterisundefined) and that the other two pass onmaster— sothe change does not move the cases it should not.
pnpm testincrates/bindings-typescript: 31 files, 319 tests, all passing.pnpm build(tsup+tsc -p tsconfig.build.json) andpnpm lint(
eslint .+prettier . --check) clean.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
filterand one rangeddeleteagainst a real host.
ConstraintOpts.columnswidened to a non-empty list inthis PR or a follow-up.