From 924b75b1ea5afcfd9151137ff8187637e0b8bafd Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 6 Jul 2026 11:24:48 +1000 Subject: [PATCH 1/4] feat: add fluent EQL v3 schema helpers --- .changeset/fluent-eql-v3-encrypted.md | 5 + packages/stack/__tests__/schema-v3.test-d.ts | 42 +++- packages/stack/__tests__/schema-v3.test.ts | 44 ++++ packages/stack/src/encryption/v3.ts | 6 +- packages/stack/src/eql/v3/encrypted.ts | 249 +++++++++++++++++++ packages/stack/src/eql/v3/index.ts | 2 +- 6 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 .changeset/fluent-eql-v3-encrypted.md create mode 100644 packages/stack/src/eql/v3/encrypted.ts diff --git a/.changeset/fluent-eql-v3-encrypted.md b/.changeset/fluent-eql-v3-encrypted.md new file mode 100644 index 00000000..c2fcbdbc --- /dev/null +++ b/.changeset/fluent-eql-v3-encrypted.md @@ -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. diff --git a/packages/stack/__tests__/schema-v3.test-d.ts b/packages/stack/__tests__/schema-v3.test-d.ts index 4ddeadee..923c51f6 100644 --- a/packages/stack/__tests__/schema-v3.test-d.ts +++ b/packages/stack/__tests__/schema-v3.test-d.ts @@ -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 { @@ -83,6 +84,45 @@ 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(), + age: encrypted.integer('age').equality(), + createdAt: encrypted.timestamp('created_at').orderAndRange(), + active: encrypted.boolean('active'), + }) + + expectTypeOf>().toEqualTypeOf<{ + email: string + age: number + createdAt: Date + active: boolean + }>() + expectTypeOf>().toEqualTypeOf< + 'equality' | 'orderAndRange' | 'freeTextSearch' + >() + expectTypeOf< + QueryTypesForColumn + >().toEqualTypeOf<'equality'>() + expectTypeOf>().toEqualTypeOf< + 'equality' | 'orderAndRange' + >() + expectTypeOf< + QueryTypesForColumn + >().toEqualTypeOf() + }) + + 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)', () => { diff --git a/packages/stack/__tests__/schema-v3.test.ts b/packages/stack/__tests__/schema-v3.test.ts index d44b5f09..c886a64b 100644 --- a/packages/stack/__tests__/schema-v3.test.ts +++ b/packages/stack/__tests__/schema-v3.test.ts @@ -4,6 +4,7 @@ import { buildEncryptConfig, EncryptedTable, EncryptedTextSearchColumn, + encrypted, encryptedTable, types, } from '@/eql/v3' @@ -156,6 +157,49 @@ 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('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', { diff --git a/packages/stack/src/encryption/v3.ts b/packages/stack/src/encryption/v3.ts index 39455e8f..b4d17cc0 100644 --- a/packages/stack/src/encryption/v3.ts +++ b/packages/stack/src/encryption/v3.ts @@ -206,9 +206,11 @@ export function typedClient( * * @example * ```typescript - * import { EncryptionV3, encryptedTable, types } from "@cipherstash/stack/v3" + * import { EncryptionV3, encrypted, encryptedTable } from "@cipherstash/stack/v3" * - * const users = encryptedTable("users", { email: types.TextSearch("email") }) + * const users = encryptedTable("users", { + * email: encrypted.text("email").equality().freeTextSearch(), + * }) * const client = await EncryptionV3({ schemas: [users] }) * * await client.encrypt("a@b.com", { table: users, column: users.email }) diff --git a/packages/stack/src/eql/v3/encrypted.ts b/packages/stack/src/eql/v3/encrypted.ts new file mode 100644 index 00000000..cf30ab3c --- /dev/null +++ b/packages/stack/src/eql/v3/encrypted.ts @@ -0,0 +1,249 @@ +import type { MatchIndexOpts } from '@/schema' +import type { + EncryptedDateColumn, + EncryptedDateEqColumn, + EncryptedDateOrdColumn, + EncryptedFloat4Column, + EncryptedFloat4EqColumn, + EncryptedFloat4OrdColumn, + EncryptedFloat8Column, + EncryptedFloat8EqColumn, + EncryptedFloat8OrdColumn, + EncryptedInt2Column, + EncryptedInt2EqColumn, + EncryptedInt2OrdColumn, + EncryptedInt4Column, + EncryptedInt4EqColumn, + EncryptedInt4OrdColumn, + EncryptedNumericColumn, + EncryptedNumericEqColumn, + EncryptedNumericOrdColumn, + EncryptedTextColumn, + EncryptedTextEqColumn, + EncryptedTextMatchColumn, + EncryptedTextOrdColumn, + EncryptedTextSearchColumn, + EncryptedTimestamptzColumn, + EncryptedTimestamptzEqColumn, + EncryptedTimestamptzOrdColumn, +} from './columns' +import { types } from './types' + +type TextSearchFluent = EncryptedTextSearchColumn & { + equality(): TextSearchFluent + orderAndRange(): TextSearchFluent +} + +type TextOrdFluent = EncryptedTextOrdColumn & { + equality(): TextOrdFluent + freeTextSearch(opts?: MatchIndexOpts): TextSearchFluent +} + +type TextMatchFluent = EncryptedTextMatchColumn & { + equality(): TextSearchFluent + orderAndRange(): TextSearchFluent +} + +type TextEqFluent = EncryptedTextEqColumn & { + freeTextSearch(opts?: MatchIndexOpts): TextSearchFluent + orderAndRange(): TextOrdFluent +} + +type TextFluent = EncryptedTextColumn & { + equality(): TextEqFluent + freeTextSearch(): TextMatchFluent + orderAndRange(): TextOrdFluent +} + +type OrderedFluent = Ord & { + equality(): OrderedFluent +} + +type EqualityFluent = Eq & { + orderAndRange(): OrderedFluent +} + +type ScalarFluent = Base & { + equality(): EqualityFluent + orderAndRange(): OrderedFluent +} + +function textSearch(name: string, opts?: MatchIndexOpts): TextSearchFluent { + const column = types.TextSearch(name) + if (opts) { + column.freeTextSearch(opts) + } + return Object.assign(column, { + equality: () => column, + orderAndRange: () => column, + }) as TextSearchFluent +} + +function textOrd(name: string): TextOrdFluent { + const column = types.TextOrd(name) + return Object.assign(column, { + equality: () => column, + freeTextSearch: (opts?: MatchIndexOpts) => textSearch(name, opts), + }) as TextOrdFluent +} + +function textEq(name: string): TextEqFluent { + return Object.assign(types.TextEq(name), { + freeTextSearch: (opts?: MatchIndexOpts) => textSearch(name, opts), + orderAndRange: () => textOrd(name), + }) as TextEqFluent +} + +function textMatch(name: string): TextMatchFluent { + return Object.assign(types.TextMatch(name), { + equality: () => textSearch(name), + orderAndRange: () => textSearch(name), + }) as TextMatchFluent +} + +function text(name: string): TextFluent { + return Object.assign(types.Text(name), { + equality: () => textEq(name), + freeTextSearch: () => textMatch(name), + orderAndRange: () => textOrd(name), + }) as TextFluent +} + +function scalar( + base: () => Base, + eq: () => Eq, + ord: () => Ord, +): ScalarFluent { + const ordered = (): OrderedFluent => { + const column = ord() + return Object.assign(column as object, { + equality: () => column, + }) as OrderedFluent + } + + return Object.assign(base() as object, { + equality: () => + Object.assign(eq() as object, { + orderAndRange: ordered, + }) as EqualityFluent, + orderAndRange: ordered, + }) as ScalarFluent +} + +function integer( + name: string, +): ScalarFluent< + EncryptedInt4Column, + EncryptedInt4EqColumn, + EncryptedInt4OrdColumn +> { + return scalar( + () => types.Int4(name), + () => types.Int4Eq(name), + () => types.Int4Ord(name), + ) +} + +function smallint( + name: string, +): ScalarFluent< + EncryptedInt2Column, + EncryptedInt2EqColumn, + EncryptedInt2OrdColumn +> { + return scalar( + () => types.Int2(name), + () => types.Int2Eq(name), + () => types.Int2Ord(name), + ) +} + +function date( + name: string, +): ScalarFluent< + EncryptedDateColumn, + EncryptedDateEqColumn, + EncryptedDateOrdColumn +> { + return scalar( + () => types.Date(name), + () => types.DateEq(name), + () => types.DateOrd(name), + ) +} + +function timestamptz( + name: string, +): ScalarFluent< + EncryptedTimestamptzColumn, + EncryptedTimestamptzEqColumn, + EncryptedTimestamptzOrdColumn +> { + return scalar( + () => types.Timestamptz(name), + () => types.TimestamptzEq(name), + () => types.TimestamptzOrd(name), + ) +} + +function numeric( + name: string, +): ScalarFluent< + EncryptedNumericColumn, + EncryptedNumericEqColumn, + EncryptedNumericOrdColumn +> { + return scalar( + () => types.Numeric(name), + () => types.NumericEq(name), + () => types.NumericOrd(name), + ) +} + +function real( + name: string, +): ScalarFluent< + EncryptedFloat4Column, + EncryptedFloat4EqColumn, + EncryptedFloat4OrdColumn +> { + return scalar( + () => types.Float4(name), + () => types.Float4Eq(name), + () => types.Float4Ord(name), + ) +} + +function doublePrecision( + name: string, +): ScalarFluent< + EncryptedFloat8Column, + EncryptedFloat8EqColumn, + EncryptedFloat8OrdColumn +> { + return scalar( + () => types.Float8(name), + () => types.Float8Eq(name), + () => types.Float8Ord(name), + ) +} + +/** + * SQL-aligned, fluent authoring namespace for EQL v3 columns. + * + * This is a thin layer over the concrete `types.*` factories: every terminal + * chain returns the same concrete column class that `types.*` would have + * returned, preserving v3 plaintext and query-capability inference. + */ +export const encrypted = { + text, + integer, + smallint, + date, + timestamptz, + timestamp: timestamptz, + numeric, + boolean: types.Bool, + real, + doublePrecision, +} as const diff --git a/packages/stack/src/eql/v3/index.ts b/packages/stack/src/eql/v3/index.ts index 629a2d2c..43900ce2 100644 --- a/packages/stack/src/eql/v3/index.ts +++ b/packages/stack/src/eql/v3/index.ts @@ -55,6 +55,7 @@ export { EncryptedTimestamptzOrdOreColumn, TEXT_SEARCH_EQL_TYPE, } from './columns' +export { encrypted } from './encrypted' export type { AnyV3Table, ColumnsOf, @@ -65,6 +66,5 @@ export type { V3EncryptedModel, V3ModelInput, } from './table' - export { buildEncryptConfig, EncryptedTable, encryptedTable } from './table' export { types } from './types' From 1931115dd3250b57c5b5058af45def9fd92c971a Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 6 Jul 2026 11:37:24 +1000 Subject: [PATCH 2/4] fix fluent text search match options --- .changeset/fluent-free-text-search-options.md | 5 ++ packages/stack/__tests__/schema-v3.test-d.ts | 2 + packages/stack/__tests__/schema-v3.test.ts | 51 ++++++++++++++ packages/stack/src/eql/v3/columns.ts | 66 +++++++++++++------ packages/stack/src/eql/v3/encrypted.ts | 13 ++-- packages/stack/src/eql/v3/types.ts | 3 +- 6 files changed, 111 insertions(+), 29 deletions(-) create mode 100644 .changeset/fluent-free-text-search-options.md diff --git a/.changeset/fluent-free-text-search-options.md b/.changeset/fluent-free-text-search-options.md new file mode 100644 index 00000000..9aeb3323 --- /dev/null +++ b/.changeset/fluent-free-text-search-options.md @@ -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)`. diff --git a/packages/stack/__tests__/schema-v3.test-d.ts b/packages/stack/__tests__/schema-v3.test-d.ts index 923c51f6..22b5d196 100644 --- a/packages/stack/__tests__/schema-v3.test-d.ts +++ b/packages/stack/__tests__/schema-v3.test-d.ts @@ -88,6 +88,7 @@ describe('eql_v3 schema type inference', () => { 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'), @@ -95,6 +96,7 @@ describe('eql_v3 schema type inference', () => { expectTypeOf>().toEqualTypeOf<{ email: string + bio: string age: number createdAt: Date active: boolean diff --git a/packages/stack/__tests__/schema-v3.test.ts b/packages/stack/__tests__/schema-v3.test.ts index c886a64b..4d1f8062 100644 --- a/packages/stack/__tests__/schema-v3.test.ts +++ b/packages/stack/__tests__/schema-v3.test.ts @@ -182,6 +182,57 @@ describe('eql_v3 fluent encrypted namespace', () => { ).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', diff --git a/packages/stack/src/eql/v3/columns.ts b/packages/stack/src/eql/v3/columns.ts index b70c3ef6..b886483f 100644 --- a/packages/stack/src/eql/v3/columns.ts +++ b/packages/stack/src/eql/v3/columns.ts @@ -3,6 +3,7 @@ import { type BuiltMatchIndexOpts, cloneMatchOpts, defaultMatchOpts, + resolveMatchOpts, } from '@/schema/match-defaults' /** @@ -422,33 +423,29 @@ export class EncryptedTextSearchColumn extends EncryptedV3Column< * on for this type. Merge semantics mirror v2's `opts?.x ?? default`. */ freeTextSearch(opts?: MatchIndexOpts): this { - // A fresh defaults object per call supplies the `?? ` fallbacks, so no - // nested default object is ever shared into `this.matchOpts` by reference. - const defaults = defaultMatchOpts() - // Clone-on-write: deep-copy the nested tokenizer / token_filters when - // storing them so a caller mutating their own opts object between - // freeTextSearch(opts) and build() cannot leak into the emitted config. - this.matchOpts = cloneMatchOpts({ - tokenizer: opts?.tokenizer ?? defaults.tokenizer, - token_filters: opts?.token_filters ?? defaults.token_filters, - k: opts?.k ?? defaults.k, - m: opts?.m ?? defaults.m, - include_original: opts?.include_original ?? defaults.include_original, - }) + // Shared merge+clone (schema/match-defaults) — one source of truth with the + // v2 `freeTextSearch()` builder. `resolveMatchOpts` merges each key over the + // per-call defaults and deep-clones, so a caller mutating their own opts + // object between `freeTextSearch(opts)` and `build()` cannot leak into the + // emitted config (clone-on-write). + this.matchOpts = resolveMatchOpts(opts) return this } /** Emit the encrypt-config column. Byte-identical to a v2 equality+order+match column. */ override build(): ColumnSchema { - // Deep-clone the match block so the returned config NEVER aliases this - // builder's internal `matchOpts` (or any caller-supplied opts merged into - // it). A caller mutating the returned object cannot corrupt this builder's - // state or another column's defaults. + // Derive `cast_as` + the `unique`/`ore` blocks from the shared + // capability→index mapping (this domain's TEXT_SEARCH capabilities produce + // exactly `{ unique, ore, match }`), then override ONLY `match` with this + // builder's tuned opts. Hand-writing the unique/ore shape here would let it + // silently drift from `indexesForCapabilities` — the exact divergence class + // behind the text_ord `hm`-index bug. Deep-clone the match block so the + // returned config never aliases this builder's internal `matchOpts`. + const base = super.build() return { - cast_as: 'string', + ...base, indexes: { - unique: { token_filters: [] }, - ore: {}, + ...base.indexes, match: cloneMatchOpts(this.matchOpts), }, } @@ -530,7 +527,34 @@ export class EncryptedTextColumn extends EncryptedV3Column {} export class EncryptedTextEqColumn extends EncryptedV3Column {} export class EncryptedTextMatchColumn extends EncryptedV3Column< typeof TEXT_MATCH -> {} +> { + private matchOpts: BuiltMatchIndexOpts + + constructor(columnName: string) { + super(columnName, TEXT_MATCH) + this.matchOpts = defaultMatchOpts() + } + + /** + * Tune the match index. This mirrors EncryptedTextSearchColumn but keeps the + * match-only domain shape. + */ + freeTextSearch(opts?: MatchIndexOpts): this { + this.matchOpts = resolveMatchOpts(opts) + return this + } + + override build(): ColumnSchema { + const base = super.build() + return { + ...base, + indexes: { + ...base.indexes, + match: cloneMatchOpts(this.matchOpts), + }, + } + } +} export class EncryptedTextOrdOreColumn extends EncryptedV3Column< typeof TEXT_ORD_ORE > {} diff --git a/packages/stack/src/eql/v3/encrypted.ts b/packages/stack/src/eql/v3/encrypted.ts index cf30ab3c..bddf4932 100644 --- a/packages/stack/src/eql/v3/encrypted.ts +++ b/packages/stack/src/eql/v3/encrypted.ts @@ -51,7 +51,7 @@ type TextEqFluent = EncryptedTextEqColumn & { type TextFluent = EncryptedTextColumn & { equality(): TextEqFluent - freeTextSearch(): TextMatchFluent + freeTextSearch(opts?: MatchIndexOpts): TextMatchFluent orderAndRange(): TextOrdFluent } @@ -94,17 +94,18 @@ function textEq(name: string): TextEqFluent { }) as TextEqFluent } -function textMatch(name: string): TextMatchFluent { - return Object.assign(types.TextMatch(name), { - equality: () => textSearch(name), - orderAndRange: () => textSearch(name), +function textMatch(name: string, opts?: MatchIndexOpts): TextMatchFluent { + const column = types.TextMatch(name).freeTextSearch(opts) + return Object.assign(column, { + equality: () => textSearch(name, opts), + orderAndRange: () => textSearch(name, opts), }) as TextMatchFluent } function text(name: string): TextFluent { return Object.assign(types.Text(name), { equality: () => textEq(name), - freeTextSearch: () => textMatch(name), + freeTextSearch: (opts?: MatchIndexOpts) => textMatch(name, opts), orderAndRange: () => textOrd(name), }) as TextFluent } diff --git a/packages/stack/src/eql/v3/types.ts b/packages/stack/src/eql/v3/types.ts index 067e0c6f..809d388a 100644 --- a/packages/stack/src/eql/v3/types.ts +++ b/packages/stack/src/eql/v3/types.ts @@ -61,7 +61,6 @@ import { NUMERIC_ORD_ORE, TEXT, TEXT_EQ, - TEXT_MATCH, TEXT_ORD, TEXT_ORD_ORE, TIMESTAMPTZ, @@ -139,7 +138,7 @@ export const types = { // text Text: (name: string) => new EncryptedTextColumn(name, TEXT), TextEq: (name: string) => new EncryptedTextEqColumn(name, TEXT_EQ), - TextMatch: (name: string) => new EncryptedTextMatchColumn(name, TEXT_MATCH), + TextMatch: (name: string) => new EncryptedTextMatchColumn(name), TextOrdOre: (name: string) => new EncryptedTextOrdOreColumn(name, TEXT_ORD_ORE), TextOrd: (name: string) => new EncryptedTextOrdColumn(name, TEXT_ORD), From 709ccaad7b407cae495548916da48eeb278222b9 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 6 Jul 2026 12:09:24 +1000 Subject: [PATCH 3/4] fix v3 match option helper export --- packages/stack/src/schema/match-defaults.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/stack/src/schema/match-defaults.ts b/packages/stack/src/schema/match-defaults.ts index e1a4090b..721b8a60 100644 --- a/packages/stack/src/schema/match-defaults.ts +++ b/packages/stack/src/schema/match-defaults.ts @@ -52,3 +52,24 @@ export function cloneMatchOpts(opts: BuiltMatchIndexOpts): BuiltMatchIndexOpts { token_filters: opts.token_filters.map((f) => ({ ...f })), } } + +/** + * Resolve user-supplied `freeTextSearch(opts)` input into a fully-built match + * block: each provided key replaces its default, omitted keys keep the default + * (`opts?.x ?? default.x`). The single source of truth for that five-field merge + * shared by the v2 `freeTextSearch()` builder and the v3 domain builders. + * + * The result is deep-cloned ({@link cloneMatchOpts}) so a caller mutating their + * own `opts` object (or its nested `tokenizer`/`token_filters`) after this call + * can never leak into the stored builder state or emitted config. + */ +export function resolveMatchOpts(opts?: MatchIndexOpts): BuiltMatchIndexOpts { + const defaults = defaultMatchOpts() + return cloneMatchOpts({ + tokenizer: opts?.tokenizer ?? defaults.tokenizer, + token_filters: opts?.token_filters ?? defaults.token_filters, + k: opts?.k ?? defaults.k, + m: opts?.m ?? defaults.m, + include_original: opts?.include_original ?? defaults.include_original, + }) +} From ee77e208216a1c8f17e0484f39918ad82567798a Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 6 Jul 2026 12:14:10 +1000 Subject: [PATCH 4/4] fix v3 fluent follow-up checks --- .github/workflows/fta-v3.yml | 5 ++ .../2026-07-02-stryker-v3-ci-gate-design.md | 6 +- .../v3-matrix/matrix-live-pg.test.ts | 76 ++++++++++++------- packages/stack/package.json | 2 +- packages/stack/src/encryption/v3.ts | 37 ++++++++- packages/stack/src/eql/v3/table.ts | 19 +++-- packages/stack/src/schema/index.ts | 17 ++--- packages/stack/src/schema/match-defaults.ts | 3 +- 8 files changed, 111 insertions(+), 54 deletions(-) diff --git a/.github/workflows/fta-v3.yml b/.github/workflows/fta-v3.yml index 541ffc33..15b2c1f9 100644 --- a/.github/workflows/fta-v3.yml +++ b/.github/workflows/fta-v3.yml @@ -12,6 +12,10 @@ 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: @@ -19,6 +23,7 @@ on: - "**" paths: - 'packages/stack/src/eql/v3/**' + - 'packages/stack/src/schema/match-defaults.ts' - 'packages/stack/package.json' - '.github/workflows/fta-v3.yml' diff --git a/docs/superpowers/specs/2026-07-02-stryker-v3-ci-gate-design.md b/docs/superpowers/specs/2026-07-02-stryker-v3-ci-gate-design.md index a149cdad..82eb2f41 100644 --- a/docs/superpowers/specs/2026-07-02-stryker-v3-ci-gate-design.md +++ b/docs/superpowers/specs/2026-07-02-stryker-v3-ci-gate-design.md @@ -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 diff --git a/packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts b/packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts index f9a433bc..6793e0a5 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts @@ -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. @@ -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]) => @@ -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, ), ) diff --git a/packages/stack/package.json b/packages/stack/package.json index 9163f6da..872c459f 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -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", diff --git a/packages/stack/src/encryption/v3.ts b/packages/stack/src/encryption/v3.ts index b4d17cc0..db5fe37b 100644 --- a/packages/stack/src/encryption/v3.ts +++ b/packages/stack/src/encryption/v3.ts @@ -9,7 +9,7 @@ import type { V3EncryptedModel, V3ModelInput, } from '@/eql/v3' -import type { EncryptionError } from '@/errors' +import { type EncryptionError, EncryptionErrorTypes } from '@/errors' import type { LockContextInput } from '@/identity' import type { BulkDecryptPayload, @@ -164,8 +164,34 @@ function rowReconstructor( */ export function typedClient( client: EncryptionClient, - ..._schemas: S + ...schemas: S ): TypedEncryptionClient { + // Precompute one row reconstructor per schema table at construction. This runs + // each table's `build()` — which throws on duplicate DB column names — ONCE, + // here, off the Result-returning decrypt path. `decryptModel`/ + // `bulkDecryptModels` therefore never call `build()` (whose throw would surface + // as a promise rejection and break their `Promise>` contract) and no + // longer rebuild the row-invariant config on every call. + const reconstructors = new Map< + AnyV3Table, + (row: Record) => Record + >() + for (const table of schemas) { + reconstructors.set(table, rowReconstructor(table)) + } + + // A table not among the schemas this client was initialized with (only + // reachable by bypassing the `Table extends S[number]` type constraint) has no + // precomputed reconstructor. Return a Result failure rather than building one + // inline, which could throw and reject the Result-shaped decrypt promise. + const unknownTableFailure: { failure: EncryptionError } = { + failure: { + type: EncryptionErrorTypes.DecryptionError, + message: + '[eql/v3]: decryptModel received a table this client was not initialized with — pass the same table object(s) given to EncryptionV3/typedClient', + }, + } + return { encrypt: (plaintext, opts) => client.encrypt(plaintext as never, opts as never), @@ -177,16 +203,19 @@ export function typedClient( client.bulkEncryptModels(input as never, table as never) as never, decrypt: (encrypted) => client.decrypt(encrypted), decryptModel: async (input, table, lockContext) => { + const reconstruct = reconstructors.get(table) + if (!reconstruct) return unknownTableFailure as never const op = client.decryptModel(input as never) const result = await (lockContext ? op.withLockContext(lockContext) : op) if (result.failure) return result as never - return { data: rowReconstructor(table)(result.data) } as never + return { data: reconstruct(result.data) } as never }, bulkDecryptModels: async (input, table, lockContext) => { + const reconstruct = reconstructors.get(table) + if (!reconstruct) return unknownTableFailure as never const op = client.bulkDecryptModels(input as never) const result = await (lockContext ? op.withLockContext(lockContext) : op) if (result.failure) return result as never - const reconstruct = rowReconstructor(table) return { data: result.data.map((row) => reconstruct(row as Record), diff --git a/packages/stack/src/eql/v3/table.ts b/packages/stack/src/eql/v3/table.ts index c5b3f069..8ea14497 100644 --- a/packages/stack/src/eql/v3/table.ts +++ b/packages/stack/src/eql/v3/table.ts @@ -211,11 +211,14 @@ export type V3EncryptedModel = { : T[K] } -/** The decrypted result model: schema columns become their plaintext type, others pass through. */ -export type V3DecryptedModel
= { - [K in keyof T]: K extends keyof InferPlaintext
- ? null extends T[K] - ? InferPlaintext
[K] | null - : InferPlaintext
[K] - : T[K] -} +/** + * The decrypted result model: schema columns become their plaintext type, others + * pass through. Structurally identical to {@link V3ModelInput} — decrypt yields + * the same plaintext shape encrypt accepts — so it is aliased rather than + * re-declared to keep the input and output shapes from silently drifting when + * one copy is edited. The distinct name is kept for call-site readability. + */ +export type V3DecryptedModel
= V3ModelInput< + Table, + T +> diff --git a/packages/stack/src/schema/index.ts b/packages/stack/src/schema/index.ts index 8672c328..52bbc0f8 100644 --- a/packages/stack/src/schema/index.ts +++ b/packages/stack/src/schema/index.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import type { BuildableTable, Encrypted } from '@/types' -import { defaultMatchOpts } from './match-defaults' +import { resolveMatchOpts } from './match-defaults' // ------------------------ // Zod schemas @@ -352,16 +352,11 @@ export class EncryptedColumn { * ``` */ freeTextSearch(opts?: MatchIndexOpts) { - // Shared defaults (schema/match-defaults) — one source of truth with the - // EQL v3 domain builders. The factory returns fresh nested objects. - const defaults = defaultMatchOpts() - this.indexesValue.match = { - tokenizer: opts?.tokenizer ?? defaults.tokenizer, - token_filters: opts?.token_filters ?? defaults.token_filters, - k: opts?.k ?? defaults.k, - m: opts?.m ?? defaults.m, - include_original: opts?.include_original ?? defaults.include_original, - } + // Shared merge+clone (schema/match-defaults) — one source of truth with the + // EQL v3 domain builders. `resolveMatchOpts` deep-clones, so a caller + // mutating their own `opts` (or its nested tokenizer/token_filters) after + // this call cannot leak into the stored schema. + this.indexesValue.match = resolveMatchOpts(opts) return this } diff --git a/packages/stack/src/schema/match-defaults.ts b/packages/stack/src/schema/match-defaults.ts index 721b8a60..e7518590 100644 --- a/packages/stack/src/schema/match-defaults.ts +++ b/packages/stack/src/schema/match-defaults.ts @@ -61,7 +61,8 @@ export function cloneMatchOpts(opts: BuiltMatchIndexOpts): BuiltMatchIndexOpts { * * The result is deep-cloned ({@link cloneMatchOpts}) so a caller mutating their * own `opts` object (or its nested `tokenizer`/`token_filters`) after this call - * can never leak into the stored builder state or emitted config. + * can never leak into the stored builder state or the emitted config — clone-on- + * write for both builders, not just v3. */ export function resolveMatchOpts(opts?: MatchIndexOpts): BuiltMatchIndexOpts { const defaults = defaultMatchOpts()