Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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/fluent-eql-v3-encrypted.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cipherstash/stack': minor
---

Add the fluent `encrypted` namespace for EQL v3 schema authoring, providing SQL-aligned helpers such as `encrypted.text()`, `encrypted.integer()`, and `encrypted.timestamp()` that resolve to the existing concrete v3 column types.
5 changes: 5 additions & 0 deletions .changeset/fluent-free-text-search-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cipherstash/stack': patch
---

Preserve match-index options passed to direct fluent EQL v3 text search columns with `encrypted.text("body").freeTextSearch(opts)`.
5 changes: 5 additions & 0 deletions .github/workflows/fta-v3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ on:
- 'main'
paths:
- 'packages/stack/src/eql/v3/**'
# Shared match-index defaults live outside src/eql/v3 but shape every
# emitted v3 match block (load-bearing `k`/`m` ciphertext params), so edits
# here must trigger the v3 gate too.
- 'packages/stack/src/schema/match-defaults.ts'
- 'packages/stack/package.json'
- '.github/workflows/fta-v3.yml'
pull_request:
branches:
- "**"
paths:
- 'packages/stack/src/eql/v3/**'
- 'packages/stack/src/schema/match-defaults.ts'
- 'packages/stack/package.json'
- '.github/workflows/fta-v3.yml'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,10 @@ Implementation therefore includes a **baseline step**:
locally.
2. Record the reported mutation score for `eql/v3`.
3. Set `thresholds.break` just **below** the measured score (a small buffer, the
way FTA sets `--score-cap 72` against a current 71.08). This ensures the
current state passes while any regression that lowers the score fails the gate.
way FTA sets `--score-cap 69` against a current worst-file score of 68.00 —
re-baselined from the pre-split monolith's 71.08/72 after `eql/v3` was split
into per-file modules). This ensures the current state passes while any
regression that lowers the score fails the gate.
4. Set `high`/`low` to reasonable display bands (do not affect pass/fail).

If the measured baseline is very low (tests are weak), surface that to the user
Expand Down
44 changes: 43 additions & 1 deletion packages/stack/__tests__/schema-v3.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import type {
EncryptedTextSearchColumn,
InferEncrypted,
InferPlaintext,
QueryTypesForColumn,
} from '@/eql/v3'
import { encryptedTable, types } from '@/eql/v3'
import { encrypted, encryptedTable, types } from '@/eql/v3'
// v2 column builders — used to prove the v3 table type rejects a v2 column and
// to assert v2 backward-compat against the widened client types.
import {
Expand Down Expand Up @@ -83,6 +84,47 @@ describe('eql_v3 schema type inference', () => {
const invalid: typeof date = bool
void invalid
})

it('encrypted fluent namespace preserves plaintext and query inference', () => {
const users = encryptedTable('users', {
email: encrypted.text('email').equality().freeTextSearch(),
bio: encrypted.text('bio').freeTextSearch({ k: 8 }),
age: encrypted.integer('age').equality(),
createdAt: encrypted.timestamp('created_at').orderAndRange(),
active: encrypted.boolean('active'),
})

expectTypeOf<InferPlaintext<typeof users>>().toEqualTypeOf<{
email: string
bio: string
age: number
createdAt: Date
active: boolean
}>()
expectTypeOf<QueryTypesForColumn<typeof users.email>>().toEqualTypeOf<
'equality' | 'orderAndRange' | 'freeTextSearch'
>()
expectTypeOf<
QueryTypesForColumn<typeof users.age>
>().toEqualTypeOf<'equality'>()
expectTypeOf<QueryTypesForColumn<typeof users.createdAt>>().toEqualTypeOf<
'equality' | 'orderAndRange'
>()
expectTypeOf<
QueryTypesForColumn<typeof users.active>
>().toEqualTypeOf<never>()
})

it('encrypted fluent namespace rejects unsupported capability chains', () => {
// @ts-expect-error - integer columns do not support free-text search
encrypted.integer('age').freeTextSearch()

// @ts-expect-error - date columns do not support free-text search
encrypted.date('created_on').freeTextSearch()

// @ts-expect-error - boolean has no query-capability fluent methods
encrypted.boolean('active').equality()
})
})

describe('eql_v3 client integration (type-level acceptance)', () => {
Expand Down
95 changes: 95 additions & 0 deletions packages/stack/__tests__/schema-v3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
buildEncryptConfig,
EncryptedTable,
EncryptedTextSearchColumn,
encrypted,
encryptedTable,
types,
} from '@/eql/v3'
Expand Down Expand Up @@ -156,6 +157,100 @@ describe('eql_v3 text_match column', () => {
})
})

describe('eql_v3 fluent encrypted namespace', () => {
it('builds the example schema through SQL-aligned fluent aliases', () => {
const users = encryptedTable('users', {
email: encrypted.text('email').equality().freeTextSearch(),
age: encrypted.integer('age').equality(),
createdAt: encrypted.timestamp('created_at').orderAndRange(),
})

expect(users.email.getEqlType()).toBe('eql_v3.text_search')
expect(users.age.getEqlType()).toBe('eql_v3.int4_eq')
expect(users.createdAt.getEqlType()).toBe('eql_v3.timestamptz_ord')
})

it('delegates fluent chains to the existing concrete factories', () => {
expect(
encrypted.text('email').equality().freeTextSearch().build(),
).toStrictEqual(types.TextSearch('email').build())
expect(encrypted.integer('age').equality().build()).toStrictEqual(
types.Int4Eq('age').build(),
)
expect(
encrypted.timestamp('created_at').orderAndRange().build(),
).toStrictEqual(types.TimestamptzOrd('created_at').build())
})

it('preserves match options for direct fluent freeTextSearch', () => {
const opts = {
tokenizer: { kind: 'ngram' as const, token_length: 4 },
token_filters: [],
k: 8,
m: 4096,
include_original: false,
}
const built = encrypted.text('body').freeTextSearch(opts).build()

expect(built).toStrictEqual(
encryptedColumn('body').freeTextSearch(opts).build(),
)
expect(built.indexes.match).toEqual({
tokenizer: { kind: 'ngram', token_length: 4 },
token_filters: [],
k: 8,
m: 4096,
include_original: false,
})
})

it('preserves match options when upgrading a match-first text fluent', () => {
const opts = {
tokenizer: { kind: 'ngram' as const, token_length: 4 },
token_filters: [],
k: 8,
m: 4096,
include_original: false,
}

expect(
encrypted.text('body').freeTextSearch(opts).equality().build(),
).toStrictEqual(
encryptedColumn('body')
.freeTextSearch(opts)
.equality()
.orderAndRange()
.build(),
)
expect(
encrypted.text('body').freeTextSearch(opts).orderAndRange().build(),
).toStrictEqual(
encryptedColumn('body')
.freeTextSearch(opts)
.equality()
.orderAndRange()
.build(),
)
})

it('supports literal SQL-family aliases for the current v3 catalog', () => {
expect(encrypted.smallint('x').equality().getEqlType()).toBe(
'eql_v3.int2_eq',
)
expect(encrypted.numeric('x').orderAndRange().getEqlType()).toBe(
'eql_v3.numeric_ord',
)
expect(encrypted.real('x').equality().getEqlType()).toBe('eql_v3.float4_eq')
expect(encrypted.doublePrecision('x').orderAndRange().getEqlType()).toBe(
'eql_v3.float8_ord',
)
expect(encrypted.boolean('x').getEqlType()).toBe('eql_v3.bool')
expect(encrypted.timestamptz('x').orderAndRange().getEqlType()).toBe(
'eql_v3.timestamptz_ord',
)
})
})

describe('eql_v3 encryptedTable', () => {
it('creates a table exposing column builders as properties', () => {
const users = encryptedTable('users', {
Expand Down
76 changes: 49 additions & 27 deletions packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@
* and not the other. Dispatch mirrors the priority `resolveIndexType` itself
* uses (match > unique > ore > none):
* - match (text_match, text_search): `eql_v3.match_term` + `bloom_filter`
* - eq (*_eq domains): `eql_v3.eq_term` + `hmac_256`
* - ord (*_ord / *_ord_ore domains): `eql_v3.ord_term` + `ore_block_256`,
* queried with `queryType:'equality'` — the exact path Part A fixed. Most
* ord-tier domains (all but text) have no `eq_term` at all in the real
* `eql_v3` SQL (verified against the fixture), so this is not a stylistic
* choice: it is the only equality path that exists for them.
* - eq (any `unique`/`hm` domain): `eql_v3.eq_term` + `hmac_256`
* - ord (any `ore`/`ob` domain): `eql_v3.ord_term` + `ore_block_256`.
* Pure-ORE domains (numeric/date `*_ord`/`*_ord_ore`) are queried with
* `queryType:'equality'` — the equality-via-ORE path Part A fixed — because
* `ob` is their only index and they have no `eq_term` at all in the real
* `eql_v3` SQL (verified against the fixture). Text order domains carry BOTH
* `hm` and `ob`, so they run the eq proof AND the ord proof; their `ob` term
* is built with `queryType:'orderAndRange'` (equality would resolve to `hm`).
* - storage (no index): no query is possible; proves the ciphertext, cast to
* THIS SPECIFIC Postgres domain type, survives a real INSERT/SELECT and
* still decrypts — the one thing the FFI-only round-trip can't show.
Expand Down Expand Up @@ -85,34 +87,45 @@ const columns = Object.fromEntries(
const table = encryptedTable(TABLE_NAME, columns as never)

/**
* The one proof each domain's configured indexes call for — mirrors the
* priority `resolveIndexType`/`inferIndexType` themselves use: match wins over
* unique wins over ore. `text_search` carries all three but gets the match
* proof (its distinguishing, richest capability); the plain `*_eq` domains get
* the eq proof; every `*_ord`/`*_ord_ore` domain (including the text ones,
* which also have an `eq_term` but are queried the same way as their
* non-text siblings for consistency) gets the equality-via-ORE proof.
* Which proofs a domain's configured indexes call for. Unlike a single-kind
* classifier these lists are NOT mutually exclusive — a domain runs EVERY proof
* its indexes support:
*
* - eq (`hm`): every domain carrying a `unique` index → `eq_term`/`hmac_256`.
* - ord (`ob`): every domain carrying an `ore` index → `ord_term`/`ore_block_256`.
* - match (`bf`): every domain carrying a `match` index → `match_term`/`bloom_filter`.
* - storage: a domain with NO index — only the ciphertext round-trip proof.
*
* Text order domains (`text_ord`/`text_ord_ore`) carry BOTH `unique` and `ore`,
* so they appear in `eqDomains` AND `ordDomains` and run both proofs — a
* wrong-valued `ob` would otherwise slip through an eq-only check (text equality
* is HMAC-based, so `queryType:'equality'` on them resolves to `hm`, never `ob`;
* their ord term is built with `queryType:'orderAndRange'` below). `text_search`
* also carries all three indexes but is deliberately exercised by the match
* proof ALONE — its distinguishing, richest capability and the one canonical
* example per tier — so it is excluded from the eq/ord lists via `!match`.
*/
type ProofKind = 'match' | 'eq' | 'ord' | 'storage'
function proofKindFor(indexes: DomainSpec['indexes']): ProofKind {
const idx = indexes ?? {}
if (idx.match) return 'match'
if (idx.unique) return 'eq'
if (idx.ore) return 'ord'
return 'storage'
}
const hasIndex = (
indexes: DomainSpec['indexes'],
key: 'unique' | 'ore' | 'match',
): boolean => Boolean((indexes ?? {})[key])

const matchDomains = domains.filter(
([, spec]) => proofKindFor(spec.indexes) === 'match',
const matchDomains = domains.filter(([, spec]) =>
hasIndex(spec.indexes, 'match'),
)
const eqDomains = domains.filter(
([, spec]) => proofKindFor(spec.indexes) === 'eq',
([, spec]) =>
hasIndex(spec.indexes, 'unique') && !hasIndex(spec.indexes, 'match'),
)
const ordDomains = domains.filter(
([, spec]) => proofKindFor(spec.indexes) === 'ord',
([, spec]) =>
hasIndex(spec.indexes, 'ore') && !hasIndex(spec.indexes, 'match'),
)
const storageDomains = domains.filter(
([, spec]) => proofKindFor(spec.indexes) === 'storage',
([, spec]) =>
!hasIndex(spec.indexes, 'unique') &&
!hasIndex(spec.indexes, 'ore') &&
!hasIndex(spec.indexes, 'match'),
)
const textOreDomains = domains.filter(
([t]) =>
Expand Down Expand Up @@ -204,13 +217,22 @@ beforeAll(async () => {
)
}
for (const [t, spec] of ordDomains) {
// Pure-ORE domains (numeric/date) answer equality via ORE, so
// `queryType:'equality'` resolves to the `ob` term — the exact
// equality-via-ORE path Part A fixed. Text order domains ALSO carry `hm`,
// where `equality` resolves to HMAC by the shared `unique > … > ore`
// priority; force `orderAndRange` there so the term still carries the `ob`
// this proof extracts with `ore_block_256`.
const queryType = hasIndex(spec.indexes, 'unique')
? 'orderAndRange'
: 'equality'
ordTerms[slug(t)] = unwrapResult(
await client.encryptQuery(
spec.samples[0] as never,
{
table,
column: columnRef(t),
queryType: 'equality',
queryType,
} as never,
),
)
Expand Down
2 changes: 1 addition & 1 deletion packages/stack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@
"prebuild": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
"build": "tsup",
"dev": "tsup --watch",
"analyze:complexity": "fta src/eql/v3 --score-cap 72",
"analyze:complexity": "fta src/eql/v3 --score-cap 69",
"db:eql-v3:install": "tsx scripts/install-eql-v3.ts",
"test": "vitest run",
"test:types": "vitest --run --typecheck.only",
Expand Down
Loading
Loading