Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eql-v3-ffi-0-27-concrete-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cipherstash/stack': patch
---

Upgrade `@cipherstash/protect-ffi` to 0.27.0 and update the EQL v3 concrete type factories/domains to the new names (`Integer`, `Smallint`, `Boolean`, `Real`, `Double`, and `Bigint`).
Comment thread
tobyhede marked this conversation as resolved.
Outdated
2 changes: 1 addition & 1 deletion .changeset/eql-v3-typed-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
'@cipherstash/stack': minor
---

Add EQL v3 schema builders for all generated SQL domains under `@cipherstash/stack/eql/v3`, exposed as the `types` namespace (one member per EQL v3 domain, e.g. `types.TextEq` / `types.Int4Ord` / `types.Timestamp`), including explicit query capability metadata (`getQueryCapabilities()` / `isQueryable()`) and v3 table support in model encryption helpers (`encryptModel` / `bulkEncryptModels`).
Add EQL v3 schema builders for all generated SQL domains under `@cipherstash/stack/eql/v3`, exposed as the `types` namespace (one member per EQL v3 domain, e.g. `types.TextEq` / `types.IntegerOrd` / `types.Timestamp`), including explicit query capability metadata (`getQueryCapabilities()` / `isQueryable()`) and v3 table support in model encryption helpers (`encryptModel` / `bulkEncryptModels`).

Also widen the accepted plaintext input type for `encrypt` / `encryptQuery` to include `Date` and `bigint` (via the new `Plaintext` type), so v3 `date` / `timestamp` / `int8` domains can be encrypted and queried with their natural JavaScript values.
2 changes: 1 addition & 1 deletion packages/stack/__tests__/cjs-require.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ describe('CJS consumers can require the built bundles', () => {
`if (typeof v3.encryptedTable !== 'function') { throw new Error('missing v3 CJS export: encryptedTable') }`,
`if (typeof v3.buildEncryptConfig !== 'function') { throw new Error('missing v3 CJS export: buildEncryptConfig') }`,
`if (typeof v3.types !== 'object' || v3.types === null) { throw new Error('missing v3 CJS export: types namespace') }`,
`const requiredTypes = ['TextSearch', 'TextEq', 'Int4Ord', 'Bool', 'Timestamp']`,
`const requiredTypes = ['TextSearch', 'TextEq', 'IntegerOrd', 'Boolean', 'Timestamp', 'Bigint']`,
`const missing = requiredTypes.filter((k) => typeof v3.types[k] !== 'function')`,
`if (missing.length > 0) { throw new Error('missing v3 types.* CJS members: ' + missing.join(', ')) }`,
].join('\n')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const users = encryptedTable('users', {
})

const usersV3 = encryptedTableV3('users_v3', {
score: types.Int4Ord('score'),
score: types.IntegerOrd('score'),
})

// biome-ignore lint/suspicious/noExplicitAny: test helper reads the Result union
Expand Down
4 changes: 2 additions & 2 deletions packages/stack/__tests__/schema-v3-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import { describeLive, LIVE_CIPHERSTASH_ENABLED } from './helpers/live-gate'

const users = encryptedTable('schema_v3_client_users', {
email: types.TextSearch('email'),
age: types.Int4Ord('age'),
age: types.IntegerOrd('age'),
nickname: types.TextEq('nickname'),
body: types.TextMatch('body'),
notes: types.Text('notes'),
active: types.Bool('active'),
active: types.Boolean('active'),
// camelCase JS property → snake_case DB name on purpose: the model path must
// match models by JS property (`createdOn`) yet address the FFI/config by DB
// name (`created_on`). The round-trip tests below exercise that mapping.
Expand Down
18 changes: 9 additions & 9 deletions packages/stack/__tests__/schema-v3-pg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
})

const typedTable = encryptedTable('protect_ci_v3_typed_domains', {
age: types.Int4Ord('age'),
age: types.IntegerOrd('age'),
nickname: types.TextEq('nickname'),
active: types.Bool('active'),
active: types.Boolean('active'),
})

const TEST_RUN_ID = `test-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
Expand Down Expand Up @@ -104,12 +104,12 @@
)
`

await sql`

Check failure on line 107 in packages/stack/__tests__/schema-v3-pg.test.ts

View workflow job for this annotation

GitHub Actions / Run Tests (Node 22)

__tests__/schema-v3-pg.test.ts

PostgresError: type "eql_v3.integer_ord" does not exist ❯ ErrorResponse ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/connection.js:815:30 ❯ handle ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/connection.js:489:6 ❯ Socket.data ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/connection.js:324:9 ❯ cachedError ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/query.js:170:23 ❯ new Query ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/query.js:36:24 ❯ sql ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/index.js:112:11 ❯ __tests__/schema-v3-pg.test.ts:107:9 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { severity_local: 'ERROR', severity: 'ERROR', code: '42704', position: '132', file: 'parse_type.c', routine: 'typenameType', query: '\n CREATE TABLE IF NOT EXISTS protect_ci_v3_typed_domains (\n id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,\n age eql_v3.integer_ord NOT NULL,\n nickname eql_v3.text_eq NOT NULL,\n active eql_v3.boolean NOT NULL,\n test_run_id TEXT NOT NULL\n )\n ', parameters: [], args: [], types: [] }

Check failure on line 107 in packages/stack/__tests__/schema-v3-pg.test.ts

View workflow job for this annotation

GitHub Actions / Run Tests (Node 24)

__tests__/schema-v3-pg.test.ts

PostgresError: type "eql_v3.integer_ord" does not exist ❯ ErrorResponse ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/connection.js:815:30 ❯ handle ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/connection.js:489:6 ❯ Socket.data ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/connection.js:324:9 ❯ cachedError ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/query.js:170:23 ❯ new Query ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/query.js:36:24 ❯ sql ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/index.js:112:11 ❯ __tests__/schema-v3-pg.test.ts:107:9 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { severity_local: 'ERROR', severity: 'ERROR', code: '42704', position: '132', file: 'parse_type.c', routine: 'typenameType', query: '\n CREATE TABLE IF NOT EXISTS protect_ci_v3_typed_domains (\n id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,\n age eql_v3.integer_ord NOT NULL,\n nickname eql_v3.text_eq NOT NULL,\n active eql_v3.boolean NOT NULL,\n test_run_id TEXT NOT NULL\n )\n ', parameters: [], args: [], types: [] }
CREATE TABLE IF NOT EXISTS protect_ci_v3_typed_domains (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
age eql_v3.int4_ord NOT NULL,
age eql_v3.integer_ord NOT NULL,
nickname eql_v3.text_eq NOT NULL,
active eql_v3.bool NOT NULL,
active eql_v3.boolean NOT NULL,
test_run_id TEXT NOT NULL
)
`
Expand All @@ -135,7 +135,7 @@
DELETE FROM protect_ci_v3_text_search
WHERE test_run_id = ${TEST_RUN_ID}
`
await sql`

Check failure on line 138 in packages/stack/__tests__/schema-v3-pg.test.ts

View workflow job for this annotation

GitHub Actions / Run Tests (Node 22)

__tests__/schema-v3-pg.test.ts

PostgresError: relation "protect_ci_v3_typed_domains" does not exist ❯ ErrorResponse ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/connection.js:815:30 ❯ handle ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/connection.js:489:6 ❯ Socket.data ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/connection.js:324:9 ❯ cachedError ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/query.js:170:23 ❯ new Query ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/query.js:36:24 ❯ sql ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/index.js:112:11 ❯ __tests__/schema-v3-pg.test.ts:138:9 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { severity_local: 'ERROR', severity: 'ERROR', code: '42P01', position: '18', file: 'parse_relation.c', routine: 'parserOpenTable', query: '\n DELETE FROM protect_ci_v3_typed_domains\n WHERE test_run_id = $1\n ', parameters: [ 'test-run-1783470788689-6bk2j7' ], args: [ 'test-run-1783470788689-6bk2j7' ], types: [ +0 ] }

Check failure on line 138 in packages/stack/__tests__/schema-v3-pg.test.ts

View workflow job for this annotation

GitHub Actions / Run Tests (Node 24)

__tests__/schema-v3-pg.test.ts

PostgresError: relation "protect_ci_v3_typed_domains" does not exist ❯ ErrorResponse ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/connection.js:815:30 ❯ handle ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/connection.js:489:6 ❯ Socket.data ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/connection.js:324:9 ❯ cachedError ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/query.js:170:23 ❯ new Query ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/query.js:36:24 ❯ sql ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/index.js:112:11 ❯ __tests__/schema-v3-pg.test.ts:138:9 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { severity_local: 'ERROR', severity: 'ERROR', code: '42P01', position: '18', file: 'parse_relation.c', routine: 'parserOpenTable', query: '\n DELETE FROM protect_ci_v3_typed_domains\n WHERE test_run_id = $1\n ', parameters: [ 'test-run-1783470802005-adj6ru' ], args: [ 'test-run-1783470802005-adj6ru' ], types: [ +0 ] }
DELETE FROM protect_ci_v3_typed_domains
WHERE test_run_id = ${TEST_RUN_ID}
`
Expand Down Expand Up @@ -280,9 +280,9 @@
const [inserted] = await sql<{ id: number }[]>`
INSERT INTO protect_ci_v3_typed_domains (age, nickname, active, test_run_id)
VALUES (
${sql.json(age as postgres.JSONValue)}::eql_v3.int4_ord,
${sql.json(age as postgres.JSONValue)}::eql_v3.integer_ord,
${sql.json(nickname as postgres.JSONValue)}::eql_v3.text_eq,
${sql.json(active as postgres.JSONValue)}::eql_v3.bool,
${sql.json(active as postgres.JSONValue)}::eql_v3.boolean,
${TEST_RUN_ID}
)
RETURNING id
Expand Down Expand Up @@ -310,7 +310,7 @@
// regression proves `resolveIndexType` resolves equality to `ore` instead of
// throwing; this proves the resulting term actually SELECTS the right rows
// against real Postgres, using the SQL `=` operator on the ORE term.
it('selects the exact row for an equality term via ORE on an int4_ord column', async () => {
it('selects the exact row for an equality term via ORE on an integer_ord column', async () => {
async function insertAge(age: number): Promise<number> {
const ageCt = unwrapResult(
await protectClient.encrypt(age, {
Expand All @@ -333,9 +333,9 @@
const [row] = await sql<{ id: number }[]>`
INSERT INTO protect_ci_v3_typed_domains (age, nickname, active, test_run_id)
VALUES (
${sql.json(ageCt)}::eql_v3.int4_ord,
${sql.json(ageCt)}::eql_v3.integer_ord,
${sql.json(nick)}::eql_v3.text_eq,
${sql.json(act)}::eql_v3.bool,
${sql.json(act)}::eql_v3.boolean,
${TEST_RUN_ID}
)
RETURNING id
Expand Down
20 changes: 10 additions & 10 deletions packages/stack/__tests__/schema-v3.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ describe('eql_v3 schema type inference', () => {
it('InferPlaintext maps v3 concrete domains to plaintext TypeScript types', () => {
const metrics = encryptedTable('metrics', {
name: types.Text('name'),
age: types.Int4('age'),
active: types.Bool('active'),
age: types.Integer('age'),
active: types.Boolean('active'),
createdAt: types.Timestamp('created_at'),
score: types.Float8('score'),
score: types.Double('score'),
})

type Plaintext = InferPlaintext<typeof metrics>
Expand All @@ -75,12 +75,12 @@ describe('eql_v3 schema type inference', () => {

it('v3 domain classes remain nominal by literal domain definition', () => {
const date = types.Date('created_on')
const bool = types.Bool('active')
const boolean = types.Boolean('active')

expectTypeOf(date).not.toEqualTypeOf<typeof bool>()
expectTypeOf(date).not.toEqualTypeOf<typeof boolean>()

// @ts-expect-error - storage-only bool is not assignable to storage-only date
const invalid: typeof date = bool
// @ts-expect-error - storage-only boolean is not assignable to storage-only date
const invalid: typeof date = boolean
void invalid
})
})
Expand Down Expand Up @@ -194,7 +194,7 @@ describe('eql_v3 client integration (type-level acceptance)', () => {
it('encryptQuery rejects storage-only v3 columns at compile time', () => {
const users = encryptedTable('users', {
email: types.Text('email'),
active: types.Bool('active'),
active: types.Boolean('active'),
})
const client = {} as EncryptionClient

Expand All @@ -206,7 +206,7 @@ describe('eql_v3 client integration (type-level acceptance)', () => {

client.encryptQuery(true, {
table: users,
// @ts-expect-error - storage-only v3 bool column is not queryable
// @ts-expect-error - storage-only v3 boolean column is not queryable
column: users.active,
})
})
Expand All @@ -216,7 +216,7 @@ describe('eql_v3 model encryption inference', () => {
it('encryptModel and bulkEncryptModels infer encrypted fields from v3 tables', () => {
const users = encryptedTable('users', {
email: types.TextSearch('email'),
active: types.Bool('active'),
active: types.Boolean('active'),
})
const client = {} as EncryptionClient

Expand Down
37 changes: 34 additions & 3 deletions packages/stack/__tests__/schema-v3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,37 @@ describe('eql_v3 text_match column', () => {
})
})

describe('eql_v3 concrete type names', () => {
it('exposes the renamed concrete type factories and emits the new EQL v3 domain names', () => {
expect(types.Integer('n').getEqlType()).toBe('eql_v3.integer')
expect(types.IntegerEq('n').getEqlType()).toBe('eql_v3.integer_eq')
expect(types.IntegerOrdOre('n').getEqlType()).toBe('eql_v3.integer_ord_ore')
expect(types.IntegerOrd('n').getEqlType()).toBe('eql_v3.integer_ord')

expect(types.Smallint('n').getEqlType()).toBe('eql_v3.smallint')
expect(types.SmallintEq('n').getEqlType()).toBe('eql_v3.smallint_eq')
expect(types.SmallintOrdOre('n').getEqlType()).toBe(
'eql_v3.smallint_ord_ore',
)
expect(types.SmallintOrd('n').getEqlType()).toBe('eql_v3.smallint_ord')

expect(types.Bigint('n').getEqlType()).toBe('eql_v3.bigint')
expect(types.BigintEq('n').getEqlType()).toBe('eql_v3.bigint_eq')
expect(types.BigintOrdOre('n').getEqlType()).toBe('eql_v3.bigint_ord_ore')
expect(types.BigintOrd('n').getEqlType()).toBe('eql_v3.bigint_ord')

expect(types.Boolean('b').getEqlType()).toBe('eql_v3.boolean')
expect(types.Real('n').getEqlType()).toBe('eql_v3.real')
expect(types.RealEq('n').getEqlType()).toBe('eql_v3.real_eq')
expect(types.RealOrdOre('n').getEqlType()).toBe('eql_v3.real_ord_ore')
expect(types.RealOrd('n').getEqlType()).toBe('eql_v3.real_ord')
expect(types.Double('n').getEqlType()).toBe('eql_v3.double')
expect(types.DoubleEq('n').getEqlType()).toBe('eql_v3.double_eq')
expect(types.DoubleOrdOre('n').getEqlType()).toBe('eql_v3.double_ord_ore')
expect(types.DoubleOrd('n').getEqlType()).toBe('eql_v3.double_ord')
})
})

describe('eql_v3 encryptedTable', () => {
it('creates a table exposing column builders as properties', () => {
const users = encryptedTable('users', {
Expand Down Expand Up @@ -402,7 +433,7 @@ describe('eql_v3 equality via ORE on order-capable columns (regression)', () =>
// domains DO carry `hm` and resolve equality to `unique` instead — see the
// text-order regression below.)
it.each([
['int4_ord', types.Int4Ord],
['int4_ord', types.IntegerOrd],
Comment thread
tobyhede marked this conversation as resolved.
Outdated
['date_ord', types.DateOrd],
['numeric_ord', types.NumericOrd],
] as const)('%s resolves equality to the ore index', (_name, builder) => {
Expand Down Expand Up @@ -439,8 +470,8 @@ describe('eql_v3 text order domains carry the hm (unique) index (regression)', (
})

it.each([
['int4_ord_ore', types.Int4OrdOre],
['int4_ord', types.Int4Ord],
['int4_ord_ore', types.IntegerOrdOre],
['int4_ord', types.IntegerOrd],
['date_ord_ore', types.DateOrdOre],
['numeric_ord', types.NumericOrd],
] as const)('%s (numeric/date order) emits ore only — no unique', (_name, builder) => {
Expand Down
6 changes: 3 additions & 3 deletions packages/stack/__tests__/typed-client-v3.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ const users = encryptedTable('users', {
createdAt: types.TimestampOrd('created_at'), // equality + order
})

// A second registered table whose `weight` domain (int4_ord) is NOT present in
// A second registered table whose `weight` domain (integer_ord) is NOT present in
// `users`, so borrowing it is a genuine cross-table type error.
const other = encryptedTable('other', {
weight: types.Int4Ord('weight'),
weight: types.IntegerOrd('weight'),
})

const client = typedClient({} as EncryptionClient, users, other)
Expand Down Expand Up @@ -166,7 +166,7 @@ describe('typed v3 client — soundness', () => {
// error is the column itself failing the `ColumnsOf<typeof users>` constraint.
client.encrypt('x', {
table: users,
// @ts-expect-error - int4_ord column from `other` is not in ColumnsOf<typeof users>
// @ts-expect-error - integer_ord column from `other` is not in ColumnsOf<typeof users>
column: other.weight,
})
})
Expand Down
Loading
Loading