Skip to content

Commit 7bad0b9

Browse files
calvinbrewerclaude
andcommitted
test(prisma-next): v3 live-PG integration + bundling isolation pins
Bundling isolation (test/bundling-isolation.test.ts): - v3.js joins the entry existence check and is pinned against both the contract-space artefacts (RUNTIME_FORBIDDEN) and the v2 wire plane - content-fingerprinted wire markers (encodeEqlV2EncryptedWire / makeCipherstashCellCodec vs CipherstashV3CellCodec / createV3CodecDescriptors / bulkEncryptMiddlewareV3): no code-split chunk may reference both wire planes, the v3 entry graph never reaches the v2 codec-runtime chunk, and middleware.js (v2-only entry) never loads the v3 wire Live-PG suites (test/live/, self-skipping via describeLivePg when CS creds — env vars or ~/.cipherstash profile — or DATABASE_URL are absent; `pnpm test:live` convenience script added): - helpers: live-gate (skip posture), eql-v3 (advisory-locked installEqlV3IfNeeded over the Task-8 migration source SQL), harness (real cipherstashFromStackV3 + real bulkEncryptMiddlewareV3 driven through real execution plans, operator SELECTs lowered by the real postgres adapter, envelope fromInternal().decrypt() read path) - operators-live-pg: eq/ne/inArray, contains (ilike), gt/lte/between, date + timestamp ranges, ord_term ordering, JSON containment (@> with eql_v3.query_jsonb — ruled decision 2), per-family decrypt - operators-null-live-pg: NULL cells round-trip, null operands rejected - boolean-storage-live-pg: storage-only round-trip, operators refuse - bigint-live-pg: lossless bigint beyond MAX_SAFE_INTEGER, eq/range/order - migration-apply-live-pg: applied SQL is byte-identical to the shipped ops.json, invariant id, domains + eql_v3 schema, op postchecks, no add_search_config - bulk-encrypt-live-pg: real EncryptionV3 through the real middleware, JSONB cells, ciphertext stamping, decrypt round-trip - side-by-side-clients-live-pg: v2 and v3 clients coexisting in-process against one database, distinct extension identities and wires Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a3fac7c commit 7bad0b9

13 files changed

Lines changed: 1777 additions & 2 deletions

packages/prisma-next/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
"dev": "tsup --watch",
7878
"test": "vitest run",
7979
"test:coverage": "vitest run --coverage",
80+
"test:live": "vitest run test/live",
8081
"typecheck": "tsc --project tsconfig.json --noEmit",
8182
"lint": "biome check . --error-on-warnings",
8283
"lint:fix": "biome check --write .",
@@ -109,6 +110,7 @@
109110
"@prisma-next/target-postgres": "0.14.0",
110111
"fast-check": "^4.8.0",
111112
"pathe": "^2.0.3",
113+
"postgres": "^3.4.8",
112114
"tsup": "catalog:repo",
113115
"typescript": "catalog:repo",
114116
"vitest": "catalog:repo"

packages/prisma-next/test/bundling-isolation.test.ts

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,20 @@
3636
* the current source.
3737
*/
3838

39-
import { existsSync, readFileSync } from 'node:fs'
39+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
4040
import { fileURLToPath } from 'node:url'
4141
import { dirname, join } from 'pathe'
4242
import { describe, expect, it } from 'vitest'
4343

4444
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
4545
const DIST = join(PACKAGE_ROOT, 'dist')
4646

47-
const ENTRY_FILES = ['control.js', 'runtime.js', 'middleware.js'] as const
47+
const ENTRY_FILES = [
48+
'control.js',
49+
'runtime.js',
50+
'middleware.js',
51+
'v3.js',
52+
] as const
4853

4954
/**
5055
* Forbidden in `control.js` and its transitive chunk graph.
@@ -154,6 +159,35 @@ const ALLOWED_SHARED_CHUNK_MARKER_SETS: ReadonlyArray<readonly string[]> = [
154159
['V3_DOMAIN_META_BY_CODEC_ID', 'V3_FACTORY_BY_NATIVE_TYPE', 'toV3CodecId'],
155160
] as const
156161

162+
/**
163+
* Fingerprints of the v2 WIRE plane — the composite-literal codec
164+
* (`encodeEqlV2EncryptedWire` produces the `("...")` wire) and the v2
165+
* cell-codec factory built on it. A v3 consumer must never load either:
166+
* the v3 wire is plain JSONB (`v3ToDriver`), and mixing the two wire
167+
* planes in one module graph is exactly the confusion decision 1b
168+
* (a client is v2 or v3, never both) exists to prevent.
169+
*
170+
* Marker choice notes: `bulkEncryptMiddleware` and `cipherstashFromStack`
171+
* are NOT usable as v2 markers — they are substrings of their v3
172+
* siblings (`bulkEncryptMiddlewareV3`, `cipherstashFromStackV3`), so a
173+
* substring scan would false-positive on every v3 chunk.
174+
*/
175+
const V2_WIRE_MARKERS = [
176+
'encodeEqlV2EncryptedWire',
177+
'makeCipherstashCellCodec',
178+
] as const
179+
180+
/**
181+
* Fingerprints of the v3 WIRE plane — the plain-JSONB cell codec, the
182+
* per-domain descriptor factory, and the v3 bulk-encrypt middleware.
183+
* A v2-only consumer (the `./middleware` entry) must never load these.
184+
*/
185+
const V3_WIRE_MARKERS = [
186+
'CipherstashV3CellCodec',
187+
'createV3CodecDescriptors',
188+
'bulkEncryptMiddlewareV3',
189+
] as const
190+
157191
interface ChunkFile {
158192
readonly file: string
159193
readonly body: string
@@ -258,6 +292,92 @@ describe('bundling isolation', () => {
258292
).toEqual([])
259293
})
260294

295+
it('v3.js does not pull contract-space artefacts', () => {
296+
const entry = readChunk('v3.js')
297+
const leaks = findLeaksInEntry(entry, RUNTIME_FORBIDDEN)
298+
expect(leaks, `v3 entry leaks: ${leaks.join(', ')}`).toEqual([])
299+
})
300+
301+
it('v3.js does not pull the v2 composite-literal wire', () => {
302+
const entry = readChunk('v3.js')
303+
const leaks = findLeaksInEntry(entry, V2_WIRE_MARKERS)
304+
expect(leaks, `v3 entry leaks v2 wire: ${leaks.join(', ')}`).toEqual([])
305+
})
306+
307+
it('the v3 entry graph never reaches the v2 codec-runtime chunk', () => {
308+
// `v3.js` legitimately shares the version-neutral execution chunk
309+
// (envelope base/classes, routing, abort, `stampRoutingKeysFromAst`)
310+
// with the v2 entries — that chunk currently also DEFINES the v2
311+
// wire encoder, which the per-chunk disjointness test below pins.
312+
// What must never happen is the v3 graph importing the v2
313+
// codec-RUNTIME chunk (the six `createCipherstash*Codec` factories
314+
// built on the composite wire).
315+
const v3Chunks = collectGraph('v3.js')
316+
const offenders = [...v3Chunks.entries()]
317+
.filter(([file]) => file !== 'v3.js')
318+
.filter(([, chunk]) =>
319+
chunk.body.includes('createCipherstashStringCodec'),
320+
)
321+
.map(([file]) => file)
322+
expect(
323+
offenders,
324+
`v3 graph reaches v2 codec-runtime chunk(s): ${offenders.join(', ')}`,
325+
).toEqual([])
326+
})
327+
328+
it('middleware.js (v2 bulk-encrypt entry) does not pull the v3 wire plane', () => {
329+
const entry = readChunk('middleware.js')
330+
const leaks = findLeaksInEntry(entry, V3_WIRE_MARKERS)
331+
expect(
332+
leaks,
333+
`middleware entry leaks v3 wire: ${leaks.join(', ')}`,
334+
).toEqual([])
335+
})
336+
337+
it('no code-split chunk mixes the v2 composite wire with the v3 JSONB wire', () => {
338+
// Chunk-level two-wires-never-co-located pin. A chunk "references a
339+
// wire plane" when any of that plane's marker identifiers appears in
340+
// its body — which covers both defining the symbol and importing it
341+
// by name from another chunk (`import { encodeEqlV2EncryptedWire }
342+
// from "./chunk-*.js"`). Entry files are exempt: `runtime.js` and
343+
// `stack.js` deliberately aggregate the v2 AND v3 public API (each
344+
// client picks one at construction time); the invariant is that no
345+
// shared IMPLEMENTATION chunk fuses the two wire codecs, which is
346+
// what would let one wire's encode path silently reach the other's
347+
// consumers.
348+
const chunkFiles = readdirSync(DIST).filter((f) =>
349+
SHARED_CHUNK_PATTERN.test(f),
350+
)
351+
expect(chunkFiles.length).toBeGreaterThan(0)
352+
const mixed = chunkFiles.filter((file) => {
353+
const body = readChunk(file).body
354+
const referencesV2Wire = V2_WIRE_MARKERS.some((m) => body.includes(m))
355+
const referencesV3Wire = V3_WIRE_MARKERS.some((m) => body.includes(m))
356+
return referencesV2Wire && referencesV3Wire
357+
})
358+
expect(
359+
mixed,
360+
`chunk(s) mix v2 and v3 wire planes: ${mixed.join(', ')}`,
361+
).toEqual([])
362+
})
363+
364+
it('v3-wire chunks reachable from runtime.js never reference the v2 composite encoder', () => {
365+
// The plan's original Task-10 assertion, made chunk-name-agnostic:
366+
// identify v3 chunks by CONTENT (they carry a v3 wire marker), not
367+
// by tsup's hash-named files.
368+
const runtimeChunks = collectGraph('runtime.js')
369+
for (const [file, chunk] of runtimeChunks) {
370+
if (file === 'runtime.js') continue
371+
const isV3WireChunk = V3_WIRE_MARKERS.some((m) => chunk.body.includes(m))
372+
if (!isV3WireChunk) continue
373+
const leaks = V2_WIRE_MARKERS.filter((m) => chunk.body.includes(m))
374+
expect(
375+
leaks,
376+
`v3 chunk ${file} references v2 wire: ${leaks.join(', ')}`,
377+
).toEqual([])
378+
}
379+
})
380+
261381
it('control vs middleware chunk graphs are disjoint (modulo shared constants chunk)', () => {
262382
const controlChunks = new Set(collectGraph('control.js').keys())
263383
const middlewareChunks = new Set(collectGraph('middleware.js').keys())
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* Live-PG bigint family: `public.eql_v3_bigint` (storage-only),
3+
* `eql_v3_bigint_eq` (equality), `eql_v3_bigint_ord` (equality +
4+
* order/range). Proves a JS `bigint` plaintext survives losslessly —
5+
* including a value ABOVE `Number.MAX_SAFE_INTEGER`, which a
6+
* number-typed pipeline would corrupt — and that the encrypted
7+
* ordering terms order the real domain correctly.
8+
*/
9+
10+
import type postgres from 'postgres'
11+
import { afterAll, beforeAll, expect, it } from 'vitest'
12+
import { EncryptedBigInt } from '../../src/execution/envelope-bigint'
13+
import { cipherstashV3Asc } from '../../src/v3/operators-v3'
14+
import {
15+
callOperator,
16+
columnAccessorV3,
17+
getOperator,
18+
} from '../v3/operator-lowering-v3.helpers'
19+
import { installEqlV3IfNeeded } from './helpers/eql-v3'
20+
import {
21+
BIGINT_EQ,
22+
BIGINT_ORD,
23+
BIGINT_STORAGE,
24+
createLiveTable,
25+
decryptCell,
26+
insertEncryptedRows,
27+
type LiveV3Client,
28+
liveConnection,
29+
liveV3Contract,
30+
runLoweredSelect,
31+
selectCell,
32+
selectIdsOrderBy,
33+
selectIdsWhere,
34+
setupLiveV3,
35+
} from './helpers/harness'
36+
import { describeLivePg } from './helpers/live-gate'
37+
38+
const TABLE = 'pn_v3_live_bigint'
39+
const COLUMNS = {
40+
balance: BIGINT_STORAGE,
41+
balance_eq: BIGINT_EQ,
42+
balance_ord: BIGINT_ORD,
43+
} as const
44+
const contract = liveV3Contract(TABLE, COLUMNS)
45+
46+
// 2^53 + 1: NOT representable as a JS number. Losslessness here proves
47+
// the pipeline is bigint end-to-end (protect-ffi's eqlVersion-3 int8).
48+
const UNSAFE_BIG = 9_007_199_254_740_993n
49+
50+
const SEED = [
51+
{ id: 'small', value: 42n },
52+
{ id: 'negative', value: -7n },
53+
{ id: 'huge', value: UNSAFE_BIG },
54+
] as const
55+
56+
describeLivePg('v3 bigint domains against live Postgres', () => {
57+
let sql: postgres.Sql
58+
let live: LiveV3Client
59+
60+
beforeAll(async () => {
61+
sql = liveConnection()
62+
await installEqlV3IfNeeded(sql)
63+
await createLiveTable(sql, TABLE, COLUMNS)
64+
live = await setupLiveV3(contract)
65+
await insertEncryptedRows(
66+
sql,
67+
live.middleware,
68+
TABLE,
69+
SEED.map((row) => ({
70+
id: row.id,
71+
cells: {
72+
balance: {
73+
codecId: BIGINT_STORAGE,
74+
value: EncryptedBigInt.from(row.value),
75+
},
76+
balance_eq: {
77+
codecId: BIGINT_EQ,
78+
value: EncryptedBigInt.from(row.value),
79+
},
80+
balance_ord: {
81+
codecId: BIGINT_ORD,
82+
value: EncryptedBigInt.from(row.value),
83+
},
84+
},
85+
})),
86+
)
87+
}, 240_000)
88+
89+
afterAll(async () => {
90+
if (sql) await sql.end()
91+
})
92+
93+
it('decrypts to a JS bigint, losslessly, beyond Number.MAX_SAFE_INTEGER', async () => {
94+
for (const column of ['balance', 'balance_eq', 'balance_ord'] as const) {
95+
const decrypted = await decryptCell(live.sdk, EncryptedBigInt, {
96+
cell: await selectCell(sql, TABLE, column, 'huge'),
97+
table: TABLE,
98+
column,
99+
})
100+
expect(typeof decrypted, `${column} must decrypt to bigint`).toBe(
101+
'bigint',
102+
)
103+
expect(decrypted).toBe(UNSAFE_BIG)
104+
}
105+
106+
const negative = await decryptCell(live.sdk, EncryptedBigInt, {
107+
cell: await selectCell(sql, TABLE, 'balance', 'negative'),
108+
table: TABLE,
109+
column: 'balance',
110+
})
111+
expect(negative).toBe(-7n)
112+
}, 60_000)
113+
114+
it('cipherstashEq on bigint_eq selects exactly the matching row', async () => {
115+
const result = await runLoweredSelect(
116+
sql,
117+
live.middleware,
118+
contract,
119+
selectIdsWhere(
120+
TABLE,
121+
callOperator(
122+
getOperator('cipherstashEq'),
123+
columnAccessorV3(TABLE, 'balance_eq', BIGINT_EQ),
124+
UNSAFE_BIG,
125+
),
126+
),
127+
)
128+
expect(result.sql).toContain('::eql_v3.query_bigint_eq')
129+
expect(result.ids).toEqual(['huge'])
130+
}, 60_000)
131+
132+
it('range predicates on bigint_ord respect numeric order across the huge value', async () => {
133+
const ord = () => columnAccessorV3(TABLE, 'balance_ord', BIGINT_ORD)
134+
const gt = await runLoweredSelect(
135+
sql,
136+
live.middleware,
137+
contract,
138+
selectIdsWhere(
139+
TABLE,
140+
callOperator(getOperator('cipherstashGt'), ord(), 42n),
141+
),
142+
)
143+
expect(gt.ids).toEqual(['huge'])
144+
145+
const between = await runLoweredSelect(
146+
sql,
147+
live.middleware,
148+
contract,
149+
selectIdsWhere(
150+
TABLE,
151+
callOperator(getOperator('cipherstashBetween'), ord(), -100n, 100n),
152+
),
153+
)
154+
expect(between.ids.sort()).toEqual(['negative', 'small'])
155+
}, 60_000)
156+
157+
it('orders by the encrypted order term in numeric order', async () => {
158+
const asc = await runLoweredSelect(
159+
sql,
160+
live.middleware,
161+
contract,
162+
selectIdsOrderBy(TABLE, [
163+
cipherstashV3Asc(columnAccessorV3(TABLE, 'balance_ord', BIGINT_ORD)),
164+
]),
165+
)
166+
expect(asc.sql).toContain('eql_v3.ord_term')
167+
expect(asc.ids).toEqual(['negative', 'small', 'huge'])
168+
}, 60_000)
169+
170+
it('the storage-only bigint domain refuses search operators', () => {
171+
expect(() =>
172+
callOperator(
173+
getOperator('cipherstashEq'),
174+
columnAccessorV3(TABLE, 'balance', BIGINT_STORAGE),
175+
42n,
176+
),
177+
).toThrow(/equality/)
178+
})
179+
})

0 commit comments

Comments
 (0)