Skip to content

Commit a068a4d

Browse files
committed
fix(stack): address code-review findings on the v3 type restoration
- operators.test-d.ts: add the negative assertion the changeset claimed but never made — a `{ encrypt }` double returning `unknown` must be REJECTED by createEncryptionOperatorsV3. The prior positive `toBeCallableWith(double)` could not catch a re-erasure (a correct value is assignable to `unknown`), so the "pinned" guarantee was toothless. The `@ts-expect-error` now goes unused and fails the moment the client contract regresses to `unknown`. - query-builder.ts: type `EncryptedFilterState.encryptedValues` as `EncryptedQueryResult[]`, not `unknown[]`. `encryptCollectedTerms` was restored to return that type but the value widened straight back to `unknown` at this field, so the use site (`encryptedValues[i]`) still saw `unknown` — the restoration never reached the consumer for the base builder. Now it does. - query-builder-v3.ts: guard the `null` arm in `bulkEncryptGroup`. The restored `Encrypted | null` type makes null reachable; an unguarded null envelope would be `JSON.stringify`'d to the literal `"null"` and sent as a filter operand (matching the wrong rows). Treat it as a contract violation. - changeset: `patch` → `minor`. Tightening the public `createEncryptionOperatorsV3` client contract is a compile-time breaking change for loosely-typed downstream clients; corrected the "no runtime change" line to note the new null guard. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
1 parent c4787c0 commit a068a4d

4 files changed

Lines changed: 47 additions & 6 deletions

File tree

.changeset/eql-v3-adapter-type-robustness.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
'@cipherstash/stack': patch
2+
'@cipherstash/stack': minor
33
---
44

55
Restore the EQL v3 envelope and `Result` types the adapters were erasing.
@@ -23,6 +23,16 @@ resolves `Result<T, EncryptionError>`. The Supabase divergence the erasure hid i
2323
now explicit: the v2 path yields `encryptQuery` composite literals and the v3
2424
path yields `JSON.stringify`'d envelope strings, and both are `EncryptedQueryResult`.
2525

26-
No runtime change. The un-erasure has teeth: a `{ encrypt }` double returning
27-
`unknown` no longer satisfies `createEncryptionOperatorsV3`, pinned in
28-
`operators.test-d.ts`.
26+
Bumped `minor`, not `patch`: `createEncryptionOperatorsV3` is a public export
27+
(`@cipherstash/stack/eql/v3/drizzle`), and tightening its client contract from
28+
`unknown` to a typed operation surface is a compile-time breaking change — a
29+
downstream consumer passing a loosely-typed (`unknown`-returning) client double
30+
will now fail `tsc`. That tightening has teeth: `operators.test-d.ts` pins it
31+
with a negative type-test asserting an `unknown`-returning `{ encrypt }` double
32+
is rejected (a positive "correctly-typed double is accepted" assertion cannot
33+
catch a re-erasure, since a correct value is assignable to `unknown`).
34+
35+
Behaviour is otherwise unchanged, with one addition: the Supabase v3 bulk path
36+
now rejects a `null` envelope returned by `bulkEncrypt` (the restored
37+
`Encrypted | null` type makes that arm reachable, and a `null` would otherwise
38+
be `JSON.stringify`'d to the literal `"null"` and sent as a filter operand).

packages/stack/__tests__/drizzle-v3/operators.test-d.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,19 @@ describe('createEncryptionOperatorsV3 - client parameter (M1)', () => {
4747
}
4848
expectTypeOf(createEncryptionOperatorsV3).toBeCallableWith(double)
4949
})
50+
51+
it('rejects a { encrypt } double that returns `unknown` (the erasure regression)', () => {
52+
// The complement of the test above, and the one with teeth: `toBeCallableWith`
53+
// a correctly-typed double keeps passing even if the client contract
54+
// regressed to `unknown` (a correct value is assignable to `unknown`), so it
55+
// cannot catch re-erasure. This can. `encrypt` returning `unknown` must NOT
56+
// satisfy the factory, whose contract is `ChainableOperation<Encrypted>`; if
57+
// the erasure ever comes back, the `@ts-expect-error` goes unused and fails.
58+
const erased = {
59+
encrypt: (_plaintext: never, _opts: never): unknown => ({}),
60+
}
61+
// @ts-expect-error — `encrypt` returning `unknown` does not satisfy the
62+
// factory's `ChainableOperation<Encrypted>` client contract.
63+
createEncryptionOperatorsV3(erased)
64+
})
5065
})

packages/stack/src/supabase/query-builder-v3.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,19 @@ export class EncryptedQueryBuilderV3Impl<
516516
`bulk encryption returned ${encrypted.length} terms for ${values.length} values on column "${column.getName()}".`,
517517
)
518518
}
519-
return encrypted.map((term) => term.data)
519+
return encrypted.map((term, i) => {
520+
// `BulkEncryptedData` types the element as `Encrypted | null`. A `null`
521+
// envelope here would be `JSON.stringify`'d to the literal string `"null"`
522+
// and sent as the filter operand — silently matching whatever `"null"`
523+
// encodes to rather than failing. A query term should never encrypt to a
524+
// null envelope, so treat it as a contract violation, not a value.
525+
if (term.data === null) {
526+
this.encryptionFailure(
527+
`bulk encryption returned a null envelope at position ${i} for column "${column.getName()}".`,
528+
)
529+
}
530+
return term.data
531+
})
520532
}
521533

522534
/** Fallback for a client that predates `bulkEncrypt`. */

packages/stack/src/supabase/query-builder.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1533,7 +1533,11 @@ type TermMapping =
15331533
}
15341534

15351535
type EncryptedFilterState = {
1536-
encryptedValues: unknown[]
1536+
// `EncryptedQueryResult[]`, not `unknown[]` — `encryptCollectedTerms` returns
1537+
// that type, and typing the field to match is what lets the restored envelope
1538+
// type reach the use site (`encryptedValues[i]`) instead of widening back to
1539+
// `unknown` at this boundary.
1540+
encryptedValues: EncryptedQueryResult[]
15371541
termMap: TermMapping[]
15381542
}
15391543

0 commit comments

Comments
 (0)