@@ -28,6 +28,7 @@ import {
2828 eqlTypeSlug as slug ,
2929 sortedKeysFor as sortedKeysForKit ,
3030 typedEntries ,
31+ unwrapResult ,
3132 V3_MATRIX ,
3233} from '@cipherstash/test-kit'
3334import {
@@ -52,9 +53,15 @@ import { makeEqlV3Column } from '@/eql/v3/drizzle/column'
5253
5354const sqlClient = postgres ( databaseUrl ( ) , { prepare : false } )
5455
55- const TABLE_NAME = 'protect_ci_v3_drizzle_matrix'
56- const ACCOUNT_TABLE_NAME = 'protect_ci_v3_drizzle_matrix_accounts'
57- const RUN = `run-${ Date . now ( ) } -${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } `
56+ // Per-run table suffix so two runs sharing a database (a persistent/reused CI
57+ // database, a developer's local DB, or re-enabled file parallelism) never
58+ // operate on the same physical table — one run's `beforeAll` DROP would
59+ // otherwise blow away a table another run is mid-query on. Mirrors the family
60+ // suites' run-scoped naming (`rows.ts` `planTable`).
61+ const RID = Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 )
62+ const TABLE_NAME = `protect_ci_v3_drizzle_matrix_${ RID } `
63+ const ACCOUNT_TABLE_NAME = `protect_ci_v3_drizzle_matrix_accounts_${ RID } `
64+ const RUN = `run-${ Date . now ( ) } -${ RID } `
5865const ROW_A = 'row-a'
5966const ROW_B = 'row-b'
6067// A third row. With only two rows every predicate can return just [A], [B],
@@ -103,7 +110,7 @@ const accountsTable = pgTable(ACCOUNT_TABLE_NAME, {
103110// known at compile time, so it exercises A3 end-to-end with ZERO casts: the
104111// insert takes envelope rows and the select yields `Encrypted` values ready for
105112// decrypt.
106- const BIGINT_TABLE_NAME = 'protect_ci_v3_drizzle_bigint'
113+ const BIGINT_TABLE_NAME = `protect_ci_v3_drizzle_bigint_ ${ RID } `
107114const bigintTable = pgTable ( BIGINT_TABLE_NAME , {
108115 id : integer ( 'id' ) . primaryKey ( ) . generatedAlwaysAsIdentity ( ) ,
109116 rowKey : text ( 'row_key' ) . notNull ( ) ,
@@ -190,11 +197,6 @@ async function selectRowKeys(condition: SQL | undefined): Promise<string[]> {
190197 return rows . map ( ( row ) => row . rowKey )
191198}
192199
193- function unwrap < T > ( result : { data ?: T ; failure ?: { message : string } } ) : T {
194- if ( result . failure ) throw new Error ( result . failure . message )
195- return result . data as T
196- }
197-
198200function encryptedInsertRows ( ) : MatrixPlainRow [ ] {
199201 return ROWS . map ( ( rowKey ) => {
200202 const row : MatrixPlainRow = {
@@ -218,12 +220,13 @@ beforeAll(async () => {
218220 . map ( ( [ eqlType ] ) => `"${ slug ( eqlType ) } " ${ eqlType } NOT NULL` )
219221 . join ( ',\n ' )
220222
221- // DROP, not `CREATE TABLE IF NOT EXISTS`. A table left by an earlier run keeps
222- // its old columns, so a change to the catalog silently reuses the stale schema.
223- // That bit: after the `_ord_ore` domains were filtered out of `matrixEntries`,
224- // the leftover table still carried its nine ORE columns, and on managed
225- // Postgres every INSERT raised `ore_domain_unavailable` — even leaving those
226- // columns NULL, because the domain CHECK calls a function that RAISEs.
223+ // Table names are run-scoped (see RID), so DROP IF EXISTS is normally a no-op;
224+ // it stays as a belt-and-braces guard against an id collision. Recreating from
225+ // scratch each run also means a catalog change can never silently reuse a
226+ // stale schema — the bug that bit once when the `_ord_ore` domains were
227+ // filtered out of `matrixEntries` but a leftover fixed-name table kept its nine
228+ // ORE columns, making every INSERT raise `ore_domain_unavailable` on managed
229+ // Postgres (the domain CHECK RAISEs even for a NULL value).
227230 await sqlClient . unsafe ( `
228231 DROP TABLE IF EXISTS ${ TABLE_NAME }
229232 ` )
@@ -260,7 +263,7 @@ beforeAll(async () => {
260263 )
261264 ` )
262265
263- const encryptedRows = unwrap (
266+ const encryptedRows = unwrapResult (
264267 await client . bulkEncryptModels ( encryptedInsertRows ( ) , schema ) ,
265268 )
266269 await db . insert ( matrixTable ) . values ( encryptedRows )
@@ -276,7 +279,7 @@ beforeAll(async () => {
276279 // ROW_B exists so the filter proofs below have a row they must EXCLUDE. On a
277280 // one-row table `gt(balance, 0n)` returning every row is indistinguishable
278281 // from it returning the right row.
279- const bigintRows = unwrap (
282+ const bigintRows = unwrapResult (
280283 await client . bulkEncryptModels (
281284 [
282285 {
@@ -299,9 +302,11 @@ beforeAll(async () => {
299302} , 120000 )
300303
301304afterAll ( async ( ) => {
302- await sqlClient `DELETE FROM ${ sqlClient ( TABLE_NAME ) } WHERE test_run_id = ${ RUN } `
303- await sqlClient `DELETE FROM ${ sqlClient ( ACCOUNT_TABLE_NAME ) } WHERE test_run_id = ${ RUN } `
304- await sqlClient `DELETE FROM ${ sqlClient ( BIGINT_TABLE_NAME ) } WHERE test_run_id = ${ RUN } `
305+ // Tables are run-scoped, so drop them outright rather than DELETE-ing rows —
306+ // no other run shares them, and this leaves nothing behind on a persistent DB.
307+ await sqlClient . unsafe ( `DROP TABLE IF EXISTS ${ TABLE_NAME } ` )
308+ await sqlClient . unsafe ( `DROP TABLE IF EXISTS ${ ACCOUNT_TABLE_NAME } ` )
309+ await sqlClient . unsafe ( `DROP TABLE IF EXISTS ${ BIGINT_TABLE_NAME } ` )
305310 await sqlClient . end ( )
306311} , 30000 )
307312
@@ -449,6 +454,25 @@ describe('v3 drizzle — relational, needle guards, pagination', () => {
449454 expect ( rows ) . toEqual ( [ ROW_B , ROW_C ] )
450455 } , 30000 )
451456
457+ it ( 'not(between()) negates the whole range, not just the lower bound' , async ( ) => {
458+ // integer_ord: ROW_A=0, ROW_B=-42, ROW_C=2147483647. `not(between(0, 0))`
459+ // must return every row whose value != 0 → ROW_B and ROW_C.
460+ //
461+ // `between` emits a TWO-clause conjunction, and Drizzle's passthrough `not`
462+ // renders a bare `NOT <cond>`. Postgres binds NOT tighter than AND, so this
463+ // only works because `v3Dialect.range` already parenthesises `(gte AND lte)`.
464+ // Without those parens, `NOT gte(0) AND lte(0)` parses as
465+ // `value < 0 AND value <= 0` = `value < 0` = ROW_B alone, silently dropping
466+ // ROW_C. Asserting ROW_C is present is what discriminates the two — a
467+ // single-bound complement would satisfy the buggy form too.
468+ const rows = await selectRowKeys (
469+ ops . not (
470+ await ops . between ( matrixColumn ( 'public.eql_v3_integer_ord' ) , 0 , 0 ) ,
471+ ) ,
472+ )
473+ expect ( rows ) . toEqual ( [ ROW_B , ROW_C ] )
474+ } , 30000 )
475+
452476 it ( 'isNull and isNotNull work on nullable encrypted columns' , async ( ) => {
453477 expect ( await selectRowKeys ( ops . isNull ( matrixTable . nullableTextEq ) ) ) . toEqual (
454478 [ ROW_A ] ,
@@ -578,7 +602,7 @@ describe('v3 drizzle — relational, needle guards, pagination', () => {
578602 )
579603 expect ( encrypted ) . toHaveLength ( 1 )
580604
581- const decrypted = unwrap (
605+ const decrypted = unwrapResult (
582606 await client . decryptModel ( encrypted [ 0 ] , bigintSchema ) ,
583607 )
584608 expect ( decrypted . balance ) . toBe ( BIGINT_BALANCE )
0 commit comments