You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
join() shipped with zero cases in the generic tabular suite — the only shared block asserts which path ran, and the pushdown/fallback disagreement it missed shipped one day later #947
5f83ae85f (feat(storage)!: add join() to ITabularStorage with SQL pushdown, #893) made join a required member of ITabularStorage (ITabularStorage.ts:796, no ?) with two implementations of the same semantics: the SQL pushdown in BaseSqlTabularStorage.runSqlJoin, and the hash fallback in BaseTabularStorage.join that every other backend inherits.
The shared contract got one block for it, and it asserts path selection, not results:
$ grep -n 'it(\|itImpl(' packages/test/src/contract/tabular-storage/assertions/joinPushdown.ts
61: itImpl( "runs a same-connection join without querying the right side"
78: itImpl( "falls back to the hash join for a right side on another connection"
Two cases. Both use the same spec — type: "left", one on pair, one orderBy, no where, no limit, no offset, no compound key, no null keys — and both assert rightQuery was/was not called plus a two-row shape.
The generic suite that every backend rides has nothing:
So seven of the ten backends that ride the tabular contract get no join assertion of any kind, and all seven use the hash fallback: InMemory, SharedInMemory, IndexedDb, Cached, Telemetry, FsFolder, Supabase.
Why it matters
The bug this predicts already happened, one day after the feature landed. 701589c30 (#934):
planSqlJoin sent a join to the hash fallback when the right-hand storage sat inside a connection transaction the left side was not enlisted in, by testing right.inTransaction. … on a real pg.Pool the guard was dead: the pushdown ran, read the right-hand table off a different pooled client, and missed the transaction's uncommitted rows. An inner join returned zero rows where the same call on SQLite, or through the hash fallback, returned the row — a wrong answer with no error, differing between backends and between the two strategies for identical inputs.
That is precisely the property the contract does not state. joinPushdown would have stayed green through it: its own fixture is not inside a transaction, and it never compares the two strategies' rows for one spec.
The interface docstring lists the semantics that differ and are unasserted anywhere shared (ITabularStorage.ts:409-796): where.right"applied as part of the join condition rather than after it, so under a left join an unmatched left row survives"; limit/offset"applied to the joined rows"; the hash join "bounds the left read — and stops early — only when the joined order does not need a sort"; "A join key that is null (or absent) on either side never matches, as in SQL." Four rules with two implementations each and no shared case.
Per-backend coverage exists and is good (45 co-located cases across JoinSql.test.ts 11, hashJoin.test.ts 27, joinDelegate.test.ts 7, plus SQLite/Postgres/Scoped/Cached integration cases) — but it tests each half in isolation. Nothing runs the same spec down both paths and compares.
Proposed fix
Add a joinSemanticsBlock to runTabularStorageContract (so all ten backends run it), covering the four documented rules above: null key never matches, where.right under a left join, compound on, limit/offset applied to joined rows, orderBy across sides.
Add the parity case the fallback/pushdown split needs and joinPushdown does not have: on a backend that supports both, run one non-trivial spec through the pushdown and through the fallback (same rows, sibling storage vs a deliberately foreign right side) and assert equal results, not just which path ran. guardParity.ts in the same directory is the template — it compares outcomes across two routes over a derived method set rather than listing them.
Measured on origin/main @ 2d36880 (0.6.0). bun scripts/test.ts vitest unit storage → 59 passed / 2 skipped of 63 files, 1176 tests (the two failures here are wall-clock timeouts on PGlite boot in a slow sandbox, not assertions).
What
5f83ae85f(feat(storage)!: add join() to ITabularStorage with SQL pushdown, #893) madejoina required member ofITabularStorage(ITabularStorage.ts:796, no?) with two implementations of the same semantics: the SQL pushdown inBaseSqlTabularStorage.runSqlJoin, and the hash fallback inBaseTabularStorage.jointhat every other backend inherits.The shared contract got one block for it, and it asserts path selection, not results:
Two cases. Both use the same spec —
type: "left", oneonpair, oneorderBy, nowhere, nolimit, nooffset, no compound key, no null keys — and both assertrightQuerywas/was not called plus a two-row shape.The generic suite that every backend rides has nothing:
And
joinPushdownis gated oncreateSiblingStorage, which only three backends supply:So seven of the ten backends that ride the tabular contract get no join assertion of any kind, and all seven use the hash fallback:
InMemory,SharedInMemory,IndexedDb,Cached,Telemetry,FsFolder,Supabase.Why it matters
The bug this predicts already happened, one day after the feature landed.
701589c30(#934):That is precisely the property the contract does not state.
joinPushdownwould have stayed green through it: its own fixture is not inside a transaction, and it never compares the two strategies' rows for one spec.The interface docstring lists the semantics that differ and are unasserted anywhere shared (
ITabularStorage.ts:409-796):where.right"applied as part of the join condition rather than after it, so under aleftjoin an unmatched left row survives";limit/offset"applied to the joined rows"; the hash join "bounds the left read — and stops early — only when the joined order does not need a sort";"A join key that isnull(or absent) on either side never matches, as in SQL."Four rules with two implementations each and no shared case.Per-backend coverage exists and is good (45 co-located cases across
JoinSql.test.ts11,hashJoin.test.ts27,joinDelegate.test.ts7, plus SQLite/Postgres/Scoped/Cached integration cases) — but it tests each half in isolation. Nothing runs the same spec down both paths and compares.Proposed fix
joinSemanticsBlocktorunTabularStorageContract(so all ten backends run it), covering the four documented rules above: null key never matches,where.rightunder aleftjoin, compoundon,limit/offsetapplied to joined rows,orderByacross sides.joinPushdowndoes not have: on a backend that supports both, run one non-trivial spec through the pushdown and through the fallback (same rows, sibling storage vs a deliberately foreign right side) and assert equal results, not just which path ran.guardParity.tsin the same directory is the template — it compares outcomes across two routes over a derived method set rather than listing them.InMemoryTabularStorageis the free reference implementation for the semantics half, the same way it is for the new vector contract (The new IVectorStorage contract runs on 6 of 8 implementations — the two left out are the one the KnowledgeBase uses on SQLite and the Supabase backend #941).Measured on
origin/main@2d36880(0.6.0).bun scripts/test.ts vitest unit storage→ 59 passed / 2 skipped of 63 files, 1176 tests (the two failures here are wall-clock timeouts on PGlite boot in a slow sandbox, not assertions).Found during the 2026-09-14 review of
packages/.