Skip to content

Commit 5466d30

Browse files
committed
feat(stack): add EQL v3 bigint domain family (i64, native JS bigint on protect-ffi 0.28)
Add BIGINT/BIGINT_EQ/BIGINT_ORD_ORE/BIGINT_ORD domain definitions against the public.bigint* concrete domains, the EncryptedBigint*Column classes, types.Bigint* factories, barrel exports, and union membership, following the existing per-domain pattern. The SDK-wide Plaintext union gains bigint, and PlaintextKind/PlaintextFromKind map cast_as 'bigint' to the JS bigint type (bigint columns always decrypt to a JS bigint). Shipped UN-GATED: protect-ffi 0.28 marshals a native JS bigint across the Neon boundary losslessly (encode on the way in, JsBigInt::from_i64 on decrypt), so no reconstruction is needed on the decrypt path and the live matrix suites exercise the round-trip directly. i64 bounds are enforced at the protect-ffi boundary. Index emission follows the numeric rule: bigint_eq -> unique (hm); bigint_ord/bigint_ord_ore -> ore (equality via ob). Removes the base's bigint skip changeset and flips the schema-v3 type guard from a negative (@ts-expect-error) to a positive assertion. The catalog-driven capability sweep and live-PG matrix pick up the four public.bigint* rows automatically (no liveGate). Re-expressed from james/cip-3291-bigint-stack 2495c65 + e593415 against the public.* domain surface and protect-ffi 0.28.
1 parent a7814ea commit 5466d30

11 files changed

Lines changed: 134 additions & 24 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@cipherstash/stack': minor
3+
---
4+
5+
Add the EQL v3 bigint domain family to the public DSL: `types.Bigint`,
6+
`types.BigintEq`, `types.BigintOrdOre`, and `types.BigintOrd`, backed by the
7+
`public.bigint*` concrete domains. Plaintext is a JS `bigint`, round-tripped
8+
losslessly across the protect-ffi 0.28 boundary (i64 bounds enforced at the
9+
FFI — out-of-range values surface as encryption errors). Index emission follows
10+
the numeric rule: `bigint_eq` → unique (hm); `bigint_ord`/`bigint_ord_ore`
11+
ore (equality answered via ob).

.changeset/skip-v3-bigint.md

Lines changed: 0 additions & 5 deletions
This file was deleted.

packages/stack/__tests__/schema-v3.test-d.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expectTypeOf, it } from 'vitest'
22
import { Encryption, type EncryptionClient } from '@/encryption'
33
import type {
4+
EncryptedBigintColumn,
45
EncryptedTextSearchColumn,
56
InferEncrypted,
67
InferPlaintext,
@@ -21,9 +22,9 @@ describe('eql_v3 schema type inference', () => {
2122
expectTypeOf(col).toEqualTypeOf<EncryptedTextSearchColumn>()
2223
})
2324

24-
it('does not expose bigint factories until native bigint round-tripping lands', () => {
25-
// @ts-expect-error - bigint v3 domains are intentionally not public yet
26-
types.Bigint('large')
25+
it('types.Bigint returns an EncryptedBigintColumn (native bigint round-trip on protect-ffi 0.28)', () => {
26+
const col = types.Bigint('large')
27+
expectTypeOf(col).toEqualTypeOf<EncryptedBigintColumn>()
2728
})
2829

2930
it('encryptedTable exposes column builders as typed properties', () => {
@@ -65,6 +66,7 @@ describe('eql_v3 schema type inference', () => {
6566
active: types.Boolean('active'),
6667
createdAt: types.Timestamp('created_at'),
6768
score: types.Double('score'),
69+
balance: types.BigintOrd('balance'),
6870
})
6971

7072
type Plaintext = InferPlaintext<typeof metrics>
@@ -75,6 +77,7 @@ describe('eql_v3 schema type inference', () => {
7577
active: boolean
7678
createdAt: Date
7779
score: number
80+
balance: bigint
7881
}>()
7982
})
8083

packages/stack/__tests__/schema-v3.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,11 @@ describe('eql_v3 concrete type names', () => {
170170
)
171171
expect(types.SmallintOrd('n').getEqlType()).toBe('public.smallint_ord')
172172

173+
expect(types.Bigint('n').getEqlType()).toBe('public.bigint')
174+
expect(types.BigintEq('n').getEqlType()).toBe('public.bigint_eq')
175+
expect(types.BigintOrdOre('n').getEqlType()).toBe('public.bigint_ord_ore')
176+
expect(types.BigintOrd('n').getEqlType()).toBe('public.bigint_ord')
177+
173178
expect(types.Boolean('b').getEqlType()).toBe('public.boolean')
174179
expect(types.Real('n').getEqlType()).toBe('public.real')
175180
expect(types.RealEq('n').getEqlType()).toBe('public.real_eq')

packages/stack/__tests__/v3-matrix/catalog.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import type {
2323
QueryCapabilities,
2424
} from '@/eql/v3'
2525
import {
26+
EncryptedBigintColumn,
27+
EncryptedBigintEqColumn,
28+
EncryptedBigintOrdColumn,
29+
EncryptedBigintOrdOreColumn,
2630
EncryptedBooleanColumn,
2731
EncryptedDateColumn,
2832
EncryptedDateEqColumn,
@@ -99,7 +103,7 @@ export type DomainSpec = Readonly<{
99103
* tell `integer` from `double`, and a fractional value on an int-named domain is
100104
* untested territory (it would truncate against a real narrow PG column).
101105
*/
102-
samples: ReadonlyArray<string | number | boolean | Date>
106+
samples: ReadonlyArray<string | number | bigint | boolean | Date>
103107
/**
104108
* Values that MUST fail encryption. Number domains reject `NaN`/`±Infinity`
105109
* via a global guard; other domains omit this.
@@ -175,6 +179,16 @@ const TEXT_SEARCH_IDX: Indexes = {
175179
// smallint/integer, fractionals for real/double/numeric. See `DomainSpec.samples`.
176180
const SMALLINT_S = [0, -1, 32767, -32768] as const
177181
const INTEGER_S = [0, -42, 2147483647, -2147483648] as const
182+
// Full i64 bounds (enforced at the protect-ffi boundary): i64::MAX, i64::MIN,
183+
// zero, a negative, and a mid value beyond Number.MAX_SAFE_INTEGER to prove
184+
// protect-ffi 0.28 round-trips a JS bigint losslessly.
185+
const BIGINT_S = [
186+
4611686018427387904n,
187+
-42n,
188+
9223372036854775807n,
189+
-9223372036854775808n,
190+
0n,
191+
] as const
178192
const REAL_S = [0, 77.5, -117.25, 0.5] as const
179193
const DOUBLE_S = [0, -117.123456, 1e15, -1e15] as const
180194
const NUMERIC_S = [0, 12345.678, -42, -0.5] as const
@@ -220,6 +234,11 @@ export const V3_MATRIX = {
220234
'public.smallint_eq': { builder: types.SmallintEq, ColumnClass: EncryptedSmallintEqColumn, castAs: 'number', capabilities: EQ, indexes: UNIQUE_IDX, samples: SMALLINT_S, errorSamples: NUM_ERR },
221235
'public.smallint_ord_ore': { builder: types.SmallintOrdOre, ColumnClass: EncryptedSmallintOrdOreColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: SMALLINT_S, errorSamples: NUM_ERR },
222236
'public.smallint_ord': { builder: types.SmallintOrd, ColumnClass: EncryptedSmallintOrdColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: SMALLINT_S, errorSamples: NUM_ERR },
237+
// bigint (int8) — native JS bigint round-trip on protect-ffi 0.28; ungated
238+
'public.bigint': { builder: types.Bigint, ColumnClass: EncryptedBigintColumn, castAs: 'bigint', capabilities: STORAGE, indexes: NONE, samples: BIGINT_S },
239+
'public.bigint_eq': { builder: types.BigintEq, ColumnClass: EncryptedBigintEqColumn, castAs: 'bigint', capabilities: EQ, indexes: UNIQUE_IDX, samples: BIGINT_S },
240+
'public.bigint_ord_ore': { builder: types.BigintOrdOre, ColumnClass: EncryptedBigintOrdOreColumn, castAs: 'bigint', capabilities: ORD, indexes: ORE_IDX, samples: BIGINT_S },
241+
'public.bigint_ord': { builder: types.BigintOrd, ColumnClass: EncryptedBigintOrdColumn, castAs: 'bigint', capabilities: ORD, indexes: ORE_IDX, samples: BIGINT_S },
223242
// date
224243
'public.date': { builder: types.Date, ColumnClass: EncryptedDateColumn, castAs: 'date', capabilities: STORAGE, indexes: NONE, samples: DATE_S },
225244
'public.date_eq': { builder: types.DateEq, ColumnClass: EncryptedDateEqColumn, castAs: 'date', capabilities: EQ, indexes: UNIQUE_IDX, samples: DATE_S },

packages/stack/__tests__/v3-matrix/matrix.test-d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const records = encryptedTable('records', {
2525
count: V3_MATRIX['public.integer'].builder('count'), // number, storage-only
2626
score: V3_MATRIX['public.integer_eq'].builder('score'), // number, equality
2727
rank: V3_MATRIX['public.integer_ord'].builder('rank'), // number, order + range
28+
balance: V3_MATRIX['public.bigint_ord'].builder('balance'), // bigint, order + range
2829
createdAt: V3_MATRIX['public.timestamp_ord'].builder('created_at'), // date
2930
email: V3_MATRIX['public.text_search'].builder('email'), // string, full-text
3031
active: V3_MATRIX['public.boolean'].builder('active'), // boolean, storage-only
@@ -36,6 +37,7 @@ describe('eql_v3 type-driven matrix (types)', () => {
3637
count: number
3738
score: number
3839
rank: number
40+
balance: bigint
3941
createdAt: Date
4042
email: string
4143
active: boolean
@@ -52,6 +54,9 @@ describe('eql_v3 type-driven matrix (types)', () => {
5254
expectTypeOf<QueryTypesForColumn<typeof records.rank>>().toEqualTypeOf<
5355
'equality' | 'orderAndRange'
5456
>()
57+
expectTypeOf<QueryTypesForColumn<typeof records.balance>>().toEqualTypeOf<
58+
'equality' | 'orderAndRange'
59+
>()
5560
expectTypeOf<QueryTypesForColumn<typeof records.createdAt>>().toEqualTypeOf<
5661
'equality' | 'orderAndRange'
5762
>()

packages/stack/src/encryption/v3.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,10 @@ export interface TypedEncryptionClient<S extends readonly AnyV3Table[]> {
128128
* row-invariant, but non-trivial to build — is derived once per call site,
129129
* not once per row on the bulk path.
130130
*
131-
* NOTE: `bigint` (int8) reconstruction is intentionally absent — int8 domains are
132-
* omitted from the v3 SDK until the native FFI supports lossless bigint I/O.
131+
* NOTE: only date-like casts need per-row reconstruction. `bigint` (int8)
132+
* needs none — protect-ffi 0.28 returns a native JS `bigint` on decrypt
133+
* (and bounds-checks/encodes it on encrypt), so those columns pass through
134+
* unchanged, exactly like `string`/`number`/`boolean`.
133135
*/
134136
function rowReconstructor(
135137
table: AnyV3Table,

packages/stack/src/eql/v3/columns.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export type DateLikeCast = (typeof DATE_LIKE_CASTS)[number]
4040

4141
/** The plaintext (TypeScript) kind a v3 domain decrypts to. A subset of the
4242
* SDK `CastAs` enum, restricted to the scalar kinds v3 domains actually use. */
43-
type PlaintextKind = 'string' | 'number' | 'boolean' | DateLikeCast
43+
type PlaintextKind = 'string' | 'number' | 'bigint' | 'boolean' | DateLikeCast
4444

4545
/**
4646
* The full, literal definition of a v3 domain. This is the LOAD-BEARING type:
@@ -152,6 +152,31 @@ export const SMALLINT_ORD = {
152152
capabilities: ORDER_AND_RANGE,
153153
} as const
154154

155+
// bigint (int8) domains. Plaintext is a JS `bigint` (always decrypts to
156+
// `bigint`); bounds are the full i64 range, enforced at the protect-ffi
157+
// boundary, which round-trips a native bigint losslessly. (`*_ord_ope`
158+
// variants are out of scope — CIP-3403.)
159+
export const BIGINT = {
160+
eqlType: 'public.bigint',
161+
castAs: 'bigint',
162+
capabilities: STORAGE_ONLY,
163+
} as const
164+
export const BIGINT_EQ = {
165+
eqlType: 'public.bigint_eq',
166+
castAs: 'bigint',
167+
capabilities: EQUALITY_ONLY,
168+
} as const
169+
export const BIGINT_ORD_ORE = {
170+
eqlType: 'public.bigint_ord_ore',
171+
castAs: 'bigint',
172+
capabilities: ORDER_AND_RANGE,
173+
} as const
174+
export const BIGINT_ORD = {
175+
eqlType: 'public.bigint_ord',
176+
castAs: 'bigint',
177+
capabilities: ORDER_AND_RANGE,
178+
} as const
179+
155180
export const DATE = {
156181
eqlType: 'public.date',
157182
castAs: 'date',
@@ -497,6 +522,19 @@ export class EncryptedSmallintOrdColumn extends EncryptedV3Column<
497522
typeof SMALLINT_ORD
498523
> {}
499524

525+
// bigint (int8) — plaintext is a JS `bigint`, round-tripped losslessly by the
526+
// native protect-ffi boundary (see the BIGINT domain definitions above).
527+
export class EncryptedBigintColumn extends EncryptedV3Column<typeof BIGINT> {}
528+
export class EncryptedBigintEqColumn extends EncryptedV3Column<
529+
typeof BIGINT_EQ
530+
> {}
531+
export class EncryptedBigintOrdOreColumn extends EncryptedV3Column<
532+
typeof BIGINT_ORD_ORE
533+
> {}
534+
export class EncryptedBigintOrdColumn extends EncryptedV3Column<
535+
typeof BIGINT_ORD
536+
> {}
537+
500538
// date
501539
export class EncryptedDateColumn extends EncryptedV3Column<typeof DATE> {}
502540
export class EncryptedDateEqColumn extends EncryptedV3Column<typeof DATE_EQ> {}
@@ -584,6 +622,10 @@ export type AnyEncryptedV3Column =
584622
| EncryptedSmallintEqColumn
585623
| EncryptedSmallintOrdOreColumn
586624
| EncryptedSmallintOrdColumn
625+
| EncryptedBigintColumn
626+
| EncryptedBigintEqColumn
627+
| EncryptedBigintOrdOreColumn
628+
| EncryptedBigintOrdColumn
587629
| EncryptedDateColumn
588630
| EncryptedDateEqColumn
589631
| EncryptedDateOrdOreColumn
@@ -625,11 +667,13 @@ type PlaintextFromKind<K extends PlaintextKind> = K extends 'string'
625667
? string
626668
: K extends 'number'
627669
? number
628-
: K extends 'boolean'
629-
? boolean
630-
: K extends DateLikeCast
631-
? Date
632-
: never
670+
: K extends 'bigint'
671+
? bigint
672+
: K extends 'boolean'
673+
? boolean
674+
: K extends DateLikeCast
675+
? Date
676+
: never
633677

634678
/**
635679
* The plaintext type for a single v3 column, read from the literal domain

packages/stack/src/eql/v3/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export type {
1818
} from './columns'
1919

2020
export {
21+
EncryptedBigintColumn,
22+
EncryptedBigintEqColumn,
23+
EncryptedBigintOrdColumn,
24+
EncryptedBigintOrdOreColumn,
2125
EncryptedBooleanColumn,
2226
EncryptedDateColumn,
2327
EncryptedDateEqColumn,

packages/stack/src/eql/v3/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import {
2+
BIGINT,
3+
BIGINT_EQ,
4+
BIGINT_ORD,
5+
BIGINT_ORD_ORE,
26
BOOLEAN,
37
DATE,
48
DATE_EQ,
@@ -8,6 +12,10 @@ import {
812
DOUBLE_EQ,
913
DOUBLE_ORD,
1014
DOUBLE_ORD_ORE,
15+
EncryptedBigintColumn,
16+
EncryptedBigintEqColumn,
17+
EncryptedBigintOrdColumn,
18+
EncryptedBigintOrdOreColumn,
1119
EncryptedBooleanColumn,
1220
EncryptedDateColumn,
1321
EncryptedDateEqColumn,
@@ -117,6 +125,14 @@ export const types = {
117125
SmallintOrd: (name: string) =>
118126
new EncryptedSmallintOrdColumn(name, SMALLINT_ORD),
119127

128+
// bigint (int8) — plaintext is a JS `bigint`, round-tripped losslessly by
129+
// the native protect-ffi boundary (see ./columns)
130+
Bigint: (name: string) => new EncryptedBigintColumn(name, BIGINT),
131+
BigintEq: (name: string) => new EncryptedBigintEqColumn(name, BIGINT_EQ),
132+
BigintOrdOre: (name: string) =>
133+
new EncryptedBigintOrdOreColumn(name, BIGINT_ORD_ORE),
134+
BigintOrd: (name: string) => new EncryptedBigintOrdColumn(name, BIGINT_ORD),
135+
120136
// date
121137
Date: (name: string) => new EncryptedDateColumn(name, DATE),
122138
DateEq: (name: string) => new EncryptedDateEqColumn(name, DATE_EQ),

0 commit comments

Comments
 (0)