From b9a75f6cc09bb9276e66f366de1c1547b666d3e7 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 13:31:04 +1000 Subject: [PATCH 01/12] refactor(cli): replace SearchOp tuple with V3Domain on ColumnDef --- packages/cli/src/commands/init/types.ts | 26 ++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index be521ea5..3078e8fc 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -6,12 +6,32 @@ export type Integration = 'drizzle' | 'supabase' | 'prisma-next' | 'postgresql' export type DataType = 'string' | 'number' | 'boolean' | 'date' | 'json' -export type SearchOp = 'equality' | 'orderAndRange' | 'freeTextSearch' +/** + * A concrete EQL v3 domain factory name on the `types` namespace + * (`@cipherstash/stack/eql/v3`). v3 has no chainable capability tuners — a + * column's query capabilities are fixed by which domain you pick. The scaffold + * offers the subset below; the full numeric/date lattice + * (Smallint/Bigint/Numeric/Real/Double/Timestamp) is left to the user's real + * schema files, exactly as `pgTypeToDataType` collapses those types today. + */ +export type V3Domain = + | 'Text' + | 'TextEq' + | 'TextOrd' + | 'TextMatch' + | 'TextSearch' + | 'Integer' + | 'IntegerEq' + | 'IntegerOrd' + | 'Date' + | 'DateEq' + | 'DateOrd' + | 'Boolean' + | 'Json' export interface ColumnDef { name: string - dataType: DataType - searchOps: SearchOp[] + domain: V3Domain } export interface SchemaDef { From d52e9deb842da47888008e126901b64cbdce647f Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 13:32:04 +1000 Subject: [PATCH 02/12] feat(cli): add v3 candidateDomains/defaultDomain helpers, drop allSearchOps --- .../init/lib/__tests__/introspect.test.ts | 71 +++++++++++++++++++ .../cli/src/commands/init/lib/introspect.ts | 56 +++++++++++++-- 2 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/commands/init/lib/__tests__/introspect.test.ts diff --git a/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts b/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts new file mode 100644 index 00000000..76b73f46 --- /dev/null +++ b/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + candidateDomains, + defaultDomain, + pgTypeToDataType, +} from '../introspect.js' + +describe('pgTypeToDataType', () => { + it('maps integer/float/numeric udt names to number', () => { + for (const udt of ['int2', 'int4', 'int8', 'float4', 'float8', 'numeric']) { + expect(pgTypeToDataType(udt)).toBe('number') + } + }) + + it('maps bool to boolean, date/timestamp to date, json/jsonb to json', () => { + expect(pgTypeToDataType('bool')).toBe('boolean') + expect(pgTypeToDataType('date')).toBe('date') + expect(pgTypeToDataType('timestamptz')).toBe('date') + expect(pgTypeToDataType('jsonb')).toBe('json') + }) + + it('falls back to string for unknown udt names', () => { + expect(pgTypeToDataType('citext')).toBe('string') + }) +}) + +describe('candidateDomains', () => { + it('offers the full text ladder for strings', () => { + const values = candidateDomains('string').map((o) => o.value) + expect(values).toEqual(['Text', 'TextEq', 'TextOrd', 'TextMatch', 'TextSearch']) + }) + + it('offers the integer ladder for numbers', () => { + const values = candidateDomains('number').map((o) => o.value) + expect(values).toEqual(['Integer', 'IntegerEq', 'IntegerOrd']) + }) + + it('offers the date ladder for dates', () => { + const values = candidateDomains('date').map((o) => o.value) + expect(values).toEqual(['Date', 'DateEq', 'DateOrd']) + }) + + it('offers a single storage-only domain for boolean and json', () => { + expect(candidateDomains('boolean').map((o) => o.value)).toEqual(['Boolean']) + expect(candidateDomains('json').map((o) => o.value)).toEqual(['Json']) + }) + + it('gives every option a label and a hint', () => { + for (const opt of candidateDomains('string')) { + expect(opt.label.length).toBeGreaterThan(0) + expect(opt.hint.length).toBeGreaterThan(0) + } + }) +}) + +describe('defaultDomain', () => { + it('defaults to the widest searchable domain per type', () => { + expect(defaultDomain('string')).toBe('TextSearch') + expect(defaultDomain('number')).toBe('IntegerOrd') + expect(defaultDomain('date')).toBe('DateOrd') + expect(defaultDomain('boolean')).toBe('Boolean') + expect(defaultDomain('json')).toBe('Json') + }) + + it('always returns a member of that type candidate set', () => { + for (const dt of ['string', 'number', 'date', 'boolean', 'json'] as const) { + const values = candidateDomains(dt).map((o) => o.value) + expect(values).toContain(defaultDomain(dt)) + } + }) +}) diff --git a/packages/cli/src/commands/init/lib/introspect.ts b/packages/cli/src/commands/init/lib/introspect.ts index 2f81dc65..0a7cc276 100644 --- a/packages/cli/src/commands/init/lib/introspect.ts +++ b/packages/cli/src/commands/init/lib/introspect.ts @@ -1,6 +1,6 @@ import * as p from '@clack/prompts' import pg from 'pg' -import type { ColumnDef, DataType, SchemaDef, SearchOp } from '../types.js' +import type { ColumnDef, DataType, SchemaDef, V3Domain } from '../types.js' export interface DbColumn { columnName: string @@ -99,12 +99,56 @@ export async function introspectDatabase( } } -function allSearchOps(dataType: DataType): SearchOp[] { - const ops: SearchOp[] = ['equality', 'orderAndRange'] - if (dataType === 'string') { - ops.push('freeTextSearch') +/** + * The v3 domains offerable for a scaffolded column of the given `DataType`, + * ordered narrowest→widest so the interactive picker reads as an escalating + * ladder. Each domain's query capability is fixed by its type — there is no + * capability tuple. `boolean` and `json` have exactly one domain (storage + * only); numeric and date types collapse to the `Integer*` / `Date*` families + * because `pgTypeToDataType` carries no width/precision signal. + */ +export function candidateDomains( + dataType: DataType, +): Array<{ value: V3Domain; label: string; hint: string }> { + switch (dataType) { + case 'string': + return [ + { value: 'Text', label: 'Text', hint: 'storage only — encrypt/decrypt, no queries' }, + { value: 'TextEq', label: 'TextEq', hint: 'equality (=, IN)' }, + { value: 'TextOrd', label: 'TextOrd', hint: 'equality + order/range (<, >, BETWEEN, sort)' }, + { value: 'TextMatch', label: 'TextMatch', hint: 'free-text match only' }, + { value: 'TextSearch', label: 'TextSearch', hint: 'equality + order/range + free-text' }, + ] + case 'number': + return [ + { value: 'Integer', label: 'Integer', hint: 'storage only' }, + { value: 'IntegerEq', label: 'IntegerEq', hint: 'equality (=, IN)' }, + { value: 'IntegerOrd', label: 'IntegerOrd', hint: 'equality + order/range' }, + ] + case 'date': + return [ + { value: 'Date', label: 'Date', hint: 'storage only' }, + { value: 'DateEq', label: 'DateEq', hint: 'equality (=, IN)' }, + { value: 'DateOrd', label: 'DateOrd', hint: 'equality + order/range' }, + ] + case 'boolean': + return [{ value: 'Boolean', label: 'Boolean', hint: 'storage only' }] + case 'json': + return [{ value: 'Json', label: 'Json', hint: 'encrypted-JSONB containment + selectors' }] } - return ops +} + +/** + * The default domain pre-selected in the picker: the widest searchable domain + * for the type. Mirrors the pre-v3 scaffold, which enabled every capability on + * every selected column by default. Derived from `candidateDomains` (whose + * lists are ordered narrowest→widest) so the "widest is the default" invariant + * has a single source of truth — reordering a candidate list moves the default + * with it, and the two can never silently drift. + */ +export function defaultDomain(dataType: DataType): V3Domain { + const options = candidateDomains(dataType) + return options[options.length - 1].value } /** From ba882c46c94bc8f945911b0b34365081b765cb0e Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 13:33:32 +1000 Subject: [PATCH 03/12] feat(cli): pick a v3 domain per column in schema build picker --- .../init/lib/__tests__/introspect.test.ts | 111 +++++++++++++++++- .../cli/src/commands/init/lib/introspect.ts | 33 ++++-- 2 files changed, 131 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts b/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts index 76b73f46..5777e63d 100644 --- a/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts @@ -1,10 +1,27 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as p from '@clack/prompts' import { candidateDomains, defaultDomain, pgTypeToDataType, + selectTableColumns, } from '../introspect.js' +const selectMock = vi.fn() +const multiselectMock = vi.fn() +// Real clack signals cancellation by returning a unique symbol that `isCancel` +// checks by identity. Model that faithfully with a sentinel so cancellation +// branches are reachable — a hardcoded `isCancel: () => false` would make them +// structurally untestable. +const CANCEL = Symbol('clack.cancel') +vi.mock('@clack/prompts', () => ({ + select: (...args: unknown[]) => selectMock(...args), + multiselect: (...args: unknown[]) => multiselectMock(...args), + isCancel: (v: unknown) => v === CANCEL, + log: { info: vi.fn(), success: vi.fn() }, + spinner: () => ({ start: vi.fn(), stop: vi.fn() }), +})) + describe('pgTypeToDataType', () => { it('maps integer/float/numeric udt names to number', () => { for (const udt of ['int2', 'int4', 'int8', 'float4', 'float8', 'numeric']) { @@ -69,3 +86,95 @@ describe('defaultDomain', () => { } }) }) + +describe('selectTableColumns', () => { + beforeEach(() => { + selectMock.mockReset() + multiselectMock.mockReset() + }) + + const tables = [ + { + tableName: 'users', + columns: [ + { columnName: 'email', dataType: 'text', udtName: 'text', isEqlEncrypted: false }, + { columnName: 'age', dataType: 'integer', udtName: 'int4', isEqlEncrypted: false }, + { columnName: 'verified', dataType: 'boolean', udtName: 'bool', isEqlEncrypted: false }, + ], + }, + ] + + it('assigns the per-column domain chosen at the select prompt', async () => { + // 1st select: which table. Then one select per multi-domain column. + selectMock + .mockResolvedValueOnce('users') // table pick + .mockResolvedValueOnce('TextEq') // email domain + .mockResolvedValueOnce('IntegerOrd') // age domain + // boolean has a single domain → no prompt for it. + multiselectMock.mockResolvedValueOnce(['email', 'age', 'verified']) + + const schema = await selectTableColumns(tables) + + expect(schema).toEqual({ + tableName: 'users', + columns: [ + { name: 'email', domain: 'TextEq' }, + { name: 'age', domain: 'IntegerOrd' }, + { name: 'verified', domain: 'Boolean' }, + ], + }) + }) + + it('does not prompt for single-domain (boolean/json) columns', async () => { + selectMock.mockResolvedValueOnce('users') + multiselectMock.mockResolvedValueOnce(['verified']) + + const schema = await selectTableColumns(tables) + + // Only the table pick consumed a select; the boolean column did not. + expect(selectMock).toHaveBeenCalledTimes(1) + expect(schema?.columns).toEqual([{ name: 'verified', domain: 'Boolean' }]) + }) + + it('returns undefined when the table prompt is cancelled', async () => { + selectMock.mockResolvedValueOnce(CANCEL) + expect(await selectTableColumns(tables)).toBeUndefined() + }) + + it('returns undefined when the column multiselect is cancelled', async () => { + selectMock.mockResolvedValueOnce('users') + multiselectMock.mockResolvedValueOnce(CANCEL) + expect(await selectTableColumns(tables)).toBeUndefined() + }) + + it('returns undefined when a per-column domain prompt is cancelled', async () => { + selectMock + .mockResolvedValueOnce('users') // table pick + .mockResolvedValueOnce(CANCEL) // email domain → cancelled + multiselectMock.mockResolvedValueOnce(['email', 'age']) + expect(await selectTableColumns(tables)).toBeUndefined() + }) + + it('pre-selects eql-encrypted columns and still assigns them a domain', async () => { + const withEql = [ + { + tableName: 'accounts', + columns: [ + { columnName: 'ssn', dataType: 'text', udtName: 'eql_v2_encrypted', isEqlEncrypted: true }, + ], + }, + ] + selectMock + .mockResolvedValueOnce('accounts') // table pick + .mockResolvedValueOnce('TextSearch') // ssn domain + multiselectMock.mockResolvedValueOnce(['ssn']) + + const schema = await selectTableColumns(withEql) + + // The "detected N pre-selected" notice fired for the eql column… + expect(p.log.info).toHaveBeenCalled() + // …and the column is mapped through pgTypeToDataType('eql_v2_encrypted') + // which falls back to 'string', so it still receives its chosen domain. + expect(schema?.columns).toEqual([{ name: 'ssn', domain: 'TextSearch' }]) + }) +}) diff --git a/packages/cli/src/commands/init/lib/introspect.ts b/packages/cli/src/commands/init/lib/introspect.ts index 0a7cc276..6141eb6b 100644 --- a/packages/cli/src/commands/init/lib/introspect.ts +++ b/packages/cli/src/commands/init/lib/introspect.ts @@ -201,24 +201,33 @@ export async function selectTableColumns( if (p.isCancel(selectedColumns)) return undefined - const searchable = await p.confirm({ - message: - 'Enable searchable encryption on these columns? (you can fine-tune indexes later)', - initialValue: true, - }) - - if (p.isCancel(searchable)) return undefined - - const columns: ColumnDef[] = selectedColumns.map((colName) => { + const columns: ColumnDef[] = [] + for (const colName of selectedColumns) { const dbCol = table.columns.find((c) => c.columnName === colName) if (!dbCol) { // Unreachable — multiselect only emits values from the source array. throw new Error(`Column ${colName} not found in table ${selectedTable}`) } const dataType = pgTypeToDataType(dbCol.udtName) - const searchOps = searchable ? allSearchOps(dataType) : [] - return { name: colName, dataType, searchOps } - }) + const options = candidateDomains(dataType) + + // Single-domain types (boolean, json) have nothing to choose — assign the + // only domain without interrupting the user with a one-option prompt. + if (options.length === 1) { + columns.push({ name: colName, domain: options[0].value }) + continue + } + + const domain = await p.select({ + message: `Encryption domain for "${colName}" (${dataType})?`, + options, + initialValue: defaultDomain(dataType), + }) + + if (p.isCancel(domain)) return undefined + + columns.push({ name: colName, domain }) + } p.log.success( `Schema defined: ${selectedTable} with ${columns.length} encrypted column${columns.length !== 1 ? 's' : ''}`, From 59de3377ee29bda33fb34d0b254593bd561b7479 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 13:34:47 +1000 Subject: [PATCH 04/12] refactor(cli): codegen reads V3Domain directly, delete v3DomainFactory bridge --- .../init/__tests__/utils-codegen.test.ts | 66 ++++++++++++++++++ packages/cli/src/commands/init/utils.ts | 68 ++----------------- 2 files changed, 73 insertions(+), 61 deletions(-) create mode 100644 packages/cli/src/commands/init/__tests__/utils-codegen.test.ts diff --git a/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts new file mode 100644 index 00000000..609098e7 --- /dev/null +++ b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import type { SchemaDef } from '../types.js' +import { generateClientFromSchemas } from '../utils.js' + +const schemas: SchemaDef[] = [ + { + tableName: 'users', + columns: [ + { name: 'email', domain: 'TextSearch' }, + { name: 'age', domain: 'IntegerOrd' }, + { name: 'verified', domain: 'Boolean' }, + ], + }, +] + +describe('generateClientFromSchemas', () => { + it('emits the chosen v3 domain factory per column (generic/postgresql)', () => { + const out = generateClientFromSchemas('postgresql', schemas) + expect(out).toContain("email: types.TextSearch('email'),") + expect(out).toContain("age: types.IntegerOrd('age'),") + expect(out).toContain("verified: types.Boolean('verified'),") + expect(out).toContain("from '@cipherstash/stack/v3'") + expect(out).toContain('EncryptionV3(') + }) + + it('emits the chosen v3 domain factory per column (drizzle)', () => { + const out = generateClientFromSchemas('drizzle', schemas) + expect(out).toContain("email: types.TextSearch('email'),") + expect(out).toContain("age: types.IntegerOrd('age'),") + expect(out).toContain('extractEncryptionSchemaV3') + expect(out).toContain("from '@cipherstash/stack-drizzle/v3'") + }) + + it('carries no residual v2 capability vocabulary', () => { + const generic = generateClientFromSchemas('postgresql', schemas) + const drizzle = generateClientFromSchemas('drizzle', schemas) + for (const out of [generic, drizzle]) { + expect(out).not.toMatch(/searchOps/) + expect(out).not.toMatch(/freeTextSearch|orderAndRange|\.equality\(/) + } + }) +}) + +// Exhaustive round-trip over the closed V3Domain union: the sample fixtures +// above only exercise 3 of 13 domains, so every domain is proven to emit +// verbatim through both generators. `V3Domain` and `DataType` are finite closed +// unions, so enumeration is complete — a fast-check property would sample the +// same finite set and add nothing (and `packages/cli` has no fast-check dep). +const ALL_DOMAINS: import('../types.js').V3Domain[] = [ + 'Text', 'TextEq', 'TextOrd', 'TextMatch', 'TextSearch', + 'Integer', 'IntegerEq', 'IntegerOrd', + 'Date', 'DateEq', 'DateOrd', + 'Boolean', 'Json', +] + +describe.each(['postgresql', 'drizzle'] as const)( + 'generateClientFromSchemas domain round-trip (%s)', + (integration) => { + it.each(ALL_DOMAINS)('emits types.%s verbatim', (domain) => { + const s: SchemaDef[] = [{ tableName: 'x', columns: [{ name: 'c', domain }] }] + expect(generateClientFromSchemas(integration, s)).toContain( + `c: types.${domain}('c'),`, + ) + }) + }, +) diff --git a/packages/cli/src/commands/init/utils.ts b/packages/cli/src/commands/init/utils.ts index d2a86ff7..3a6c35d7 100644 --- a/packages/cli/src/commands/init/utils.ts +++ b/packages/cli/src/commands/init/utils.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' -import type { DataType, Integration, SchemaDef, SearchOp } from './types.js' +import type { Integration, SchemaDef } from './types.js' /** * Checks if a package is installed and loadable from the current project. @@ -264,67 +264,14 @@ function toCamelCase(str: string): string { return str.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) } -/** - * Map a column's v2-style capability request ({@link DataType} plus the set of - * requested {@link SearchOp}s) to the name of the concrete EQL **v3** domain - * factory on the `types` namespace (e.g. `'TextSearch'`, `'IntegerOrd'`). - * - * v3 has no chainable capability tuners — each domain's query capabilities are - * FIXED by the type — so a `{ equality, orderAndRange, freeTextSearch }` request - * does not map mechanically to a flag object; it picks the domain whose fixed - * capability set is the tightest match. The mapping mirrors the capability sets - * in `@cipherstash/stack/eql/v3` (`columns.ts`): - * - * storage-only → `Text` / `Integer` / `Date` / … (no searchOps) - * equality → `TextEq` / `IntegerEq` / `DateEq` (EQUALITY_ONLY) - * order & range → `TextOrd` / `IntegerOrd` / `DateOrd` (ORDER_AND_RANGE, also answers equality) - * free-text → `TextMatch` (MATCH_ONLY), or `TextSearch` when combined with eq/ord (TEXT_SEARCH) - * - * `boolean` and `json` each have a single domain (no searchable variants), so - * their searchOps are informational only. - * - * The numeric `DataType` collapses to the `Integer*` family — the scaffold has - * no width/precision signal to distinguish `Numeric`/`Real`/`Double`/`Bigint`, - * exactly as the v2 scaffold collapsed every number to one `dataType: 'number'`. - * The user's real schema files stay authoritative; they pick the precise domain. - */ -function v3DomainFactory(dataType: DataType, searchOps: SearchOp[]): string { - const eq = searchOps.includes('equality') - const ord = searchOps.includes('orderAndRange') - const text = searchOps.includes('freeTextSearch') - - switch (dataType) { - case 'json': - return 'Json' - case 'boolean': - // Boolean is storage-only in v3 — no eq/ord/match domain exists. - return 'Boolean' - case 'number': - if (ord) return 'IntegerOrd' - if (eq) return 'IntegerEq' - return 'Integer' - case 'date': - if (ord) return 'DateOrd' - if (eq) return 'DateEq' - return 'Date' - case 'string': - if (text && (eq || ord)) return 'TextSearch' - if (text) return 'TextMatch' - if (ord) return 'TextOrd' - if (eq) return 'TextEq' - return 'Text' - } -} - function generateDrizzleFromSchemas(schemas: SchemaDef[]): string { const tableDefs = schemas.map((schema) => { const varName = `${toCamelCase(schema.tableName)}Table` const schemaVarName = `${toCamelCase(schema.tableName)}Schema` - const columnDefs = schema.columns.map((col) => { - const factory = v3DomainFactory(col.dataType, col.searchOps) - return ` ${col.name}: types.${factory}('${col.name}'),` - }) + const columnDefs = schema.columns.map( + (col) => ` ${col.name}: types.${col.domain}('${col.name}'),`, + ) return `export const ${varName} = pgTable('${schema.tableName}', { id: integer('id').primaryKey().generatedAlwaysAsIdentity(), @@ -353,10 +300,9 @@ function generateGenericFromSchemas(schemas: SchemaDef[]): string { const tableDefs = schemas.map((schema) => { const varName = `${toCamelCase(schema.tableName)}Table` - const columnDefs = schema.columns.map((col) => { - const factory = v3DomainFactory(col.dataType, col.searchOps) - return ` ${col.name}: types.${factory}('${col.name}'),` - }) + const columnDefs = schema.columns.map( + (col) => ` ${col.name}: types.${col.domain}('${col.name}'),`, + ) return `export const ${varName} = encryptedTable('${schema.tableName}', { ${columnDefs.join('\n')} From 97fe35b305f347a0224327c456c68b5bcb28d684 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 13:35:18 +1000 Subject: [PATCH 05/12] =?UTF-8?q?test(cli):=20integration=20cover=20schema?= =?UTF-8?q?=20build=20pick=E2=86=92loop=E2=86=92codegen=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/build-flow.integration.test.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 packages/cli/src/commands/init/lib/__tests__/build-flow.integration.test.ts diff --git a/packages/cli/src/commands/init/lib/__tests__/build-flow.integration.test.ts b/packages/cli/src/commands/init/lib/__tests__/build-flow.integration.test.ts new file mode 100644 index 00000000..2c7a1bd4 --- /dev/null +++ b/packages/cli/src/commands/init/lib/__tests__/build-flow.integration.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const selectMock = vi.fn() +const multiselectMock = vi.fn() +const confirmMock = vi.fn() +const CANCEL = Symbol('clack.cancel') +vi.mock('@clack/prompts', () => ({ + select: (...a: unknown[]) => selectMock(...a), + multiselect: (...a: unknown[]) => multiselectMock(...a), + confirm: (...a: unknown[]) => confirmMock(...a), + isCancel: (v: unknown) => v === CANCEL, + log: { info: vi.fn(), success: vi.fn(), error: vi.fn() }, + spinner: () => ({ start: vi.fn(), stop: vi.fn() }), +})) + +const queryMock = vi.fn() +vi.mock('pg', () => ({ + default: { + Client: vi.fn(() => ({ + connect: vi.fn(async () => {}), + query: queryMock, + end: vi.fn(async () => {}), + })), + }, +})) + +const { buildSchemasFromDatabase } = await import('../introspect.js') +const { generateClientFromSchemas } = await import('../../utils.js') + +describe('build flow (introspect → pick → codegen)', () => { + beforeEach(() => { + selectMock.mockReset() + multiselectMock.mockReset() + confirmMock.mockReset() + queryMock.mockResolvedValue({ + rows: [ + { table_name: 'users', column_name: 'email', data_type: 'text', udt_name: 'text' }, + { table_name: 'orders', column_name: 'total', data_type: 'integer', udt_name: 'int4' }, + ], + }) + }) + + it('walks two tables and emits a v3 client with the chosen domains', async () => { + selectMock + .mockResolvedValueOnce('users').mockResolvedValueOnce('TextEq') // table 1 + email + .mockResolvedValueOnce('orders').mockResolvedValueOnce('IntegerOrd') // table 2 + total + multiselectMock + .mockResolvedValueOnce(['email']) + .mockResolvedValueOnce(['total']) + confirmMock.mockResolvedValueOnce(true) // "encrypt columns in another table?" + + const schemas = await buildSchemasFromDatabase('postgresql://x') + expect(schemas).toHaveLength(2) + + const out = generateClientFromSchemas('postgresql', schemas!) + expect(out).toContain("email: types.TextEq('email'),") + expect(out).toContain("total: types.IntegerOrd('total'),") + expect(out).not.toMatch(/searchOps|v3DomainFactory/) + }) + + it('stops after the first table when the user declines another', async () => { + selectMock.mockResolvedValueOnce('users').mockResolvedValueOnce('TextEq') + multiselectMock.mockResolvedValueOnce(['email']) + confirmMock.mockResolvedValueOnce(false) // decline "another table?" + + const schemas = await buildSchemasFromDatabase('postgresql://x') + expect(schemas).toEqual([ + { tableName: 'users', columns: [{ name: 'email', domain: 'TextEq' }] }, + ]) + }) + + it('returns undefined for an empty public schema', async () => { + queryMock.mockResolvedValueOnce({ rows: [] }) + expect(await buildSchemasFromDatabase('postgresql://x')).toBeUndefined() + }) +}) From f78fd7a418550146c65c1ad446236436e28885f3 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 13:38:37 +1000 Subject: [PATCH 06/12] docs(cli): changeset + skill sync for v3 domain picker --- .changeset/schema-build-v3-domain-picker.md | 11 +++++++++++ skills/stash-cli/SKILL.md | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .changeset/schema-build-v3-domain-picker.md diff --git a/.changeset/schema-build-v3-domain-picker.md b/.changeset/schema-build-v3-domain-picker.md new file mode 100644 index 00000000..8f6a031d --- /dev/null +++ b/.changeset/schema-build-v3-domain-picker.md @@ -0,0 +1,11 @@ +--- +'stash': patch +--- + +`stash schema build` now picks a concrete EQL v3 domain per column +(`TextSearch`, `IntegerOrd`, `TextEq`, …) instead of the legacy v2 +"searchable capabilities" toggle. Boolean and JSON columns are assigned +their single storage-only domain automatically; other columns default to +the widest searchable domain, matching the previous behaviour. The +internal `SearchOp` capability tuple and the `v3DomainFactory` translation +shim are removed, unblocking EQL v2 removal (#707, #751). diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index 814ed4b8..2f4555d4 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -458,7 +458,7 @@ Not implemented — prints a warning and exits. Placeholder for future encrypt-c #### `schema build` -Connects to the database, lets you select tables and columns, asks about searchable indexes, and generates a typed encryption client. Flags: `--supabase`, `--database-url`. +Connects to the database, lets you select tables and columns, and for each column picks a concrete EQL v3 domain (`TextSearch`, `IntegerOrd`, `TextEq`, …) — defaulting to the widest searchable domain for the column's type, with `boolean`/`json` columns assigned their single storage-only domain automatically — then generates a typed encryption client. Flags: `--supabase`, `--database-url`. For AI-guided integration that edits your existing schema files in place, prefer `stash plan` → `stash impl`. From da91354f2853dd54102538b4e5de912ad17dbb53 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 13:38:55 +1000 Subject: [PATCH 07/12] style(cli): apply biome formatting to v3 picker files --- .../init/__tests__/utils-codegen.test.ts | 41 ++++++++++++------- .../__tests__/build-flow.integration.test.ts | 20 +++++++-- .../init/lib/__tests__/introspect.test.ts | 38 ++++++++++++++--- .../cli/src/commands/init/lib/introspect.ts | 38 ++++++++++++++--- 4 files changed, 106 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts index 609098e7..e87879a9 100644 --- a/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts +++ b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts @@ -47,20 +47,31 @@ describe('generateClientFromSchemas', () => { // unions, so enumeration is complete — a fast-check property would sample the // same finite set and add nothing (and `packages/cli` has no fast-check dep). const ALL_DOMAINS: import('../types.js').V3Domain[] = [ - 'Text', 'TextEq', 'TextOrd', 'TextMatch', 'TextSearch', - 'Integer', 'IntegerEq', 'IntegerOrd', - 'Date', 'DateEq', 'DateOrd', - 'Boolean', 'Json', + 'Text', + 'TextEq', + 'TextOrd', + 'TextMatch', + 'TextSearch', + 'Integer', + 'IntegerEq', + 'IntegerOrd', + 'Date', + 'DateEq', + 'DateOrd', + 'Boolean', + 'Json', ] -describe.each(['postgresql', 'drizzle'] as const)( - 'generateClientFromSchemas domain round-trip (%s)', - (integration) => { - it.each(ALL_DOMAINS)('emits types.%s verbatim', (domain) => { - const s: SchemaDef[] = [{ tableName: 'x', columns: [{ name: 'c', domain }] }] - expect(generateClientFromSchemas(integration, s)).toContain( - `c: types.${domain}('c'),`, - ) - }) - }, -) +describe.each([ + 'postgresql', + 'drizzle', +] as const)('generateClientFromSchemas domain round-trip (%s)', (integration) => { + it.each(ALL_DOMAINS)('emits types.%s verbatim', (domain) => { + const s: SchemaDef[] = [ + { tableName: 'x', columns: [{ name: 'c', domain }] }, + ] + expect(generateClientFromSchemas(integration, s)).toContain( + `c: types.${domain}('c'),`, + ) + }) +}) diff --git a/packages/cli/src/commands/init/lib/__tests__/build-flow.integration.test.ts b/packages/cli/src/commands/init/lib/__tests__/build-flow.integration.test.ts index 2c7a1bd4..be94fd47 100644 --- a/packages/cli/src/commands/init/lib/__tests__/build-flow.integration.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/build-flow.integration.test.ts @@ -34,16 +34,28 @@ describe('build flow (introspect → pick → codegen)', () => { confirmMock.mockReset() queryMock.mockResolvedValue({ rows: [ - { table_name: 'users', column_name: 'email', data_type: 'text', udt_name: 'text' }, - { table_name: 'orders', column_name: 'total', data_type: 'integer', udt_name: 'int4' }, + { + table_name: 'users', + column_name: 'email', + data_type: 'text', + udt_name: 'text', + }, + { + table_name: 'orders', + column_name: 'total', + data_type: 'integer', + udt_name: 'int4', + }, ], }) }) it('walks two tables and emits a v3 client with the chosen domains', async () => { selectMock - .mockResolvedValueOnce('users').mockResolvedValueOnce('TextEq') // table 1 + email - .mockResolvedValueOnce('orders').mockResolvedValueOnce('IntegerOrd') // table 2 + total + .mockResolvedValueOnce('users') + .mockResolvedValueOnce('TextEq') // table 1 + email + .mockResolvedValueOnce('orders') + .mockResolvedValueOnce('IntegerOrd') // table 2 + total multiselectMock .mockResolvedValueOnce(['email']) .mockResolvedValueOnce(['total']) diff --git a/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts b/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts index 5777e63d..a47bafcf 100644 --- a/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts @@ -1,5 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' import * as p from '@clack/prompts' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { candidateDomains, defaultDomain, @@ -44,7 +44,13 @@ describe('pgTypeToDataType', () => { describe('candidateDomains', () => { it('offers the full text ladder for strings', () => { const values = candidateDomains('string').map((o) => o.value) - expect(values).toEqual(['Text', 'TextEq', 'TextOrd', 'TextMatch', 'TextSearch']) + expect(values).toEqual([ + 'Text', + 'TextEq', + 'TextOrd', + 'TextMatch', + 'TextSearch', + ]) }) it('offers the integer ladder for numbers', () => { @@ -97,9 +103,24 @@ describe('selectTableColumns', () => { { tableName: 'users', columns: [ - { columnName: 'email', dataType: 'text', udtName: 'text', isEqlEncrypted: false }, - { columnName: 'age', dataType: 'integer', udtName: 'int4', isEqlEncrypted: false }, - { columnName: 'verified', dataType: 'boolean', udtName: 'bool', isEqlEncrypted: false }, + { + columnName: 'email', + dataType: 'text', + udtName: 'text', + isEqlEncrypted: false, + }, + { + columnName: 'age', + dataType: 'integer', + udtName: 'int4', + isEqlEncrypted: false, + }, + { + columnName: 'verified', + dataType: 'boolean', + udtName: 'bool', + isEqlEncrypted: false, + }, ], }, ] @@ -160,7 +181,12 @@ describe('selectTableColumns', () => { { tableName: 'accounts', columns: [ - { columnName: 'ssn', dataType: 'text', udtName: 'eql_v2_encrypted', isEqlEncrypted: true }, + { + columnName: 'ssn', + dataType: 'text', + udtName: 'eql_v2_encrypted', + isEqlEncrypted: true, + }, ], }, ] diff --git a/packages/cli/src/commands/init/lib/introspect.ts b/packages/cli/src/commands/init/lib/introspect.ts index 6141eb6b..b730d4c7 100644 --- a/packages/cli/src/commands/init/lib/introspect.ts +++ b/packages/cli/src/commands/init/lib/introspect.ts @@ -113,17 +113,37 @@ export function candidateDomains( switch (dataType) { case 'string': return [ - { value: 'Text', label: 'Text', hint: 'storage only — encrypt/decrypt, no queries' }, + { + value: 'Text', + label: 'Text', + hint: 'storage only — encrypt/decrypt, no queries', + }, { value: 'TextEq', label: 'TextEq', hint: 'equality (=, IN)' }, - { value: 'TextOrd', label: 'TextOrd', hint: 'equality + order/range (<, >, BETWEEN, sort)' }, - { value: 'TextMatch', label: 'TextMatch', hint: 'free-text match only' }, - { value: 'TextSearch', label: 'TextSearch', hint: 'equality + order/range + free-text' }, + { + value: 'TextOrd', + label: 'TextOrd', + hint: 'equality + order/range (<, >, BETWEEN, sort)', + }, + { + value: 'TextMatch', + label: 'TextMatch', + hint: 'free-text match only', + }, + { + value: 'TextSearch', + label: 'TextSearch', + hint: 'equality + order/range + free-text', + }, ] case 'number': return [ { value: 'Integer', label: 'Integer', hint: 'storage only' }, { value: 'IntegerEq', label: 'IntegerEq', hint: 'equality (=, IN)' }, - { value: 'IntegerOrd', label: 'IntegerOrd', hint: 'equality + order/range' }, + { + value: 'IntegerOrd', + label: 'IntegerOrd', + hint: 'equality + order/range', + }, ] case 'date': return [ @@ -134,7 +154,13 @@ export function candidateDomains( case 'boolean': return [{ value: 'Boolean', label: 'Boolean', hint: 'storage only' }] case 'json': - return [{ value: 'Json', label: 'Json', hint: 'encrypted-JSONB containment + selectors' }] + return [ + { + value: 'Json', + label: 'Json', + hint: 'encrypted-JSONB containment + selectors', + }, + ] } } From ca40e2a9417fe0855c44059a4c262a9221df4aeb Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 13:57:23 +1000 Subject: [PATCH 08/12] refactor(cli): address review nits on v3 domain picker - reuse computed candidateDomains in the picker via defaultDomain(options) - refresh stale InitState.schemas comment (search ops -> column domains) - drop non-null assertion in build-flow integration test --- .../__tests__/build-flow.integration.test.ts | 3 ++- .../cli/src/commands/init/lib/introspect.ts | 20 ++++++++++++------- packages/cli/src/commands/init/types.ts | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/init/lib/__tests__/build-flow.integration.test.ts b/packages/cli/src/commands/init/lib/__tests__/build-flow.integration.test.ts index be94fd47..f2338816 100644 --- a/packages/cli/src/commands/init/lib/__tests__/build-flow.integration.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/build-flow.integration.test.ts @@ -63,8 +63,9 @@ describe('build flow (introspect → pick → codegen)', () => { const schemas = await buildSchemasFromDatabase('postgresql://x') expect(schemas).toHaveLength(2) + if (!schemas) throw new Error('expected schemas to be defined') - const out = generateClientFromSchemas('postgresql', schemas!) + const out = generateClientFromSchemas('postgresql', schemas) expect(out).toContain("email: types.TextEq('email'),") expect(out).toContain("total: types.IntegerOrd('total'),") expect(out).not.toMatch(/searchOps|v3DomainFactory/) diff --git a/packages/cli/src/commands/init/lib/introspect.ts b/packages/cli/src/commands/init/lib/introspect.ts index b730d4c7..79441a8b 100644 --- a/packages/cli/src/commands/init/lib/introspect.ts +++ b/packages/cli/src/commands/init/lib/introspect.ts @@ -167,13 +167,19 @@ export function candidateDomains( /** * The default domain pre-selected in the picker: the widest searchable domain * for the type. Mirrors the pre-v3 scaffold, which enabled every capability on - * every selected column by default. Derived from `candidateDomains` (whose - * lists are ordered narrowest→widest) so the "widest is the default" invariant - * has a single source of truth — reordering a candidate list moves the default - * with it, and the two can never silently drift. + * every selected column by default. Reads the last entry of the `candidateDomains` + * list (ordered narrowest→widest) so the "widest is the default" invariant has a + * single source of truth — reordering a candidate list moves the default with it, + * and the two can never silently drift. + * + * Accepts either a `DataType` (looks the candidates up) or an already-computed + * candidate list, so a caller that already holds the options — like the picker + * loop — can reuse them instead of recomputing `candidateDomains`. */ -export function defaultDomain(dataType: DataType): V3Domain { - const options = candidateDomains(dataType) +export function defaultDomain( + from: DataType | Array<{ value: V3Domain }>, +): V3Domain { + const options = Array.isArray(from) ? from : candidateDomains(from) return options[options.length - 1].value } @@ -247,7 +253,7 @@ export async function selectTableColumns( const domain = await p.select({ message: `Encryption domain for "${colName}" (${dataType})?`, options, - initialValue: defaultDomain(dataType), + initialValue: defaultDomain(options), }) if (p.isCancel(domain)) return undefined diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index 3078e8fc..59b2b89e 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -80,7 +80,7 @@ export interface InitState { /** Schema definitions written to the encryption client. Carries every * table the user picked during introspection (or the single placeholder * for empty databases). The generated client file is still the canonical - * source for the full set of column types and search ops. */ + * source for the full set of column domains. */ schemas?: SchemaDef[] /** Names of env keys observed in `.env*` files at init time. Never the * values. Set by build-schema (so the baseline context.json has them); From cfe4cad4f47fd280b8346f16da9402af592b5c70 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 13:58:21 +1000 Subject: [PATCH 09/12] =?UTF-8?q?docs(cli):=20correct=20JSON=20domain=20de?= =?UTF-8?q?scription=20=E2=80=94=20queryable,=20not=20storage-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Json's v3 domain supports encrypted-JSONB containment + selector queries; only Boolean is storage-only. Fixes the changeset, the candidateDomains doc comment, and the schema build skill copy, which had lumped the two. --- .changeset/schema-build-v3-domain-picker.md | 12 +++++++----- packages/cli/src/commands/init/lib/introspect.ts | 7 ++++--- skills/stash-cli/SKILL.md | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.changeset/schema-build-v3-domain-picker.md b/.changeset/schema-build-v3-domain-picker.md index 8f6a031d..b5ae06bf 100644 --- a/.changeset/schema-build-v3-domain-picker.md +++ b/.changeset/schema-build-v3-domain-picker.md @@ -4,8 +4,10 @@ `stash schema build` now picks a concrete EQL v3 domain per column (`TextSearch`, `IntegerOrd`, `TextEq`, …) instead of the legacy v2 -"searchable capabilities" toggle. Boolean and JSON columns are assigned -their single storage-only domain automatically; other columns default to -the widest searchable domain, matching the previous behaviour. The -internal `SearchOp` capability tuple and the `v3DomainFactory` translation -shim are removed, unblocking EQL v2 removal (#707, #751). +"searchable capabilities" toggle. Boolean columns are assigned the +storage-only `types.Boolean` domain automatically, while JSON columns are +assigned the queryable `types.Json` domain, with encrypted containment and +selector queries. Other columns default to the widest searchable domain, +matching the previous behaviour. The internal `SearchOp` capability tuple +and the `v3DomainFactory` translation shim are removed, unblocking EQL v2 +removal (#707, #751). diff --git a/packages/cli/src/commands/init/lib/introspect.ts b/packages/cli/src/commands/init/lib/introspect.ts index 79441a8b..23a1aa30 100644 --- a/packages/cli/src/commands/init/lib/introspect.ts +++ b/packages/cli/src/commands/init/lib/introspect.ts @@ -103,9 +103,10 @@ export async function introspectDatabase( * The v3 domains offerable for a scaffolded column of the given `DataType`, * ordered narrowest→widest so the interactive picker reads as an escalating * ladder. Each domain's query capability is fixed by its type — there is no - * capability tuple. `boolean` and `json` have exactly one domain (storage - * only); numeric and date types collapse to the `Integer*` / `Date*` families - * because `pgTypeToDataType` carries no width/precision signal. + * capability tuple. `boolean` has exactly one storage-only domain; `json` has + * exactly one queryable domain (encrypted containment + selectors). Numeric + * and date types collapse to the `Integer*` / `Date*` families because + * `pgTypeToDataType` carries no width/precision signal. */ export function candidateDomains( dataType: DataType, diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index 2f4555d4..895239db 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -458,7 +458,7 @@ Not implemented — prints a warning and exits. Placeholder for future encrypt-c #### `schema build` -Connects to the database, lets you select tables and columns, and for each column picks a concrete EQL v3 domain (`TextSearch`, `IntegerOrd`, `TextEq`, …) — defaulting to the widest searchable domain for the column's type, with `boolean`/`json` columns assigned their single storage-only domain automatically — then generates a typed encryption client. Flags: `--supabase`, `--database-url`. +Connects to the database, lets you select tables and columns, and for each column picks a concrete EQL v3 domain (`TextSearch`, `IntegerOrd`, `TextEq`, …) — defaulting to the widest searchable domain for the column's type. Boolean columns are assigned the storage-only `types.Boolean` domain automatically; JSON columns are assigned the queryable `types.Json` domain, which supports encrypted containment and selector queries. Flags: `--supabase`, `--database-url`. For AI-guided integration that edits your existing schema files in place, prefer `stash plan` → `stash impl`. From 4121bcbe58ead45e140dbe15bfc3540faa7478fd Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 16:04:50 +1000 Subject: [PATCH 10/12] feat(cli): accurate operator hints + enforced v3 domain drift guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #755 review (coderdan): - Picker hints now name the operators/functions each domain answers. Corrected two inaccurate reviewer suggestions: TextMatch uses the eql_v3.matches() function, not the FTS @@ operator; Json supports @> and -> (plus scalar compare/order at a selector path), not <@ / ->>. - Add a runtime drift guard asserting every domain the picker can offer is a real @cipherstash/stack/eql/v3 factory. Enforced by vitest (CI) because packages/cli has no tsc --noEmit step — a type-only alias is not checked. Answers the 'avoid drift' review point without deriving V3Domain from the 40-key types superset (which would over-widen the curated 13). --- .../init/lib/__tests__/introspect.test.ts | 33 +++++++++++++++++++ .../cli/src/commands/init/lib/introspect.ts | 31 ++++++++++++----- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts b/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts index a47bafcf..a3585c5b 100644 --- a/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts @@ -1,5 +1,7 @@ +import { types } from '@cipherstash/stack/eql/v3' import * as p from '@clack/prompts' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DataType } from '../../types.js' import { candidateDomains, defaultDomain, @@ -204,3 +206,34 @@ describe('selectTableColumns', () => { expect(schema?.columns).toEqual([{ name: 'ssn', domain: 'TextSearch' }]) }) }) + +describe('v3 domain drift guard', () => { + // The picker's V3Domain names are a curated subset of the factory names on + // the real `types` namespace from `@cipherstash/stack/eql/v3` — codegen emits + // `types.(...)`, so a domain the picker offers that no longer exists + // upstream would generate a client that fails to compile in the user's repo. + // This asserts every offered domain resolves to a real factory, so a rename + // or removal in stack fails here (in CI) rather than silently. It is the + // enforced counterpart to the reviewer's "avoid drift" concern; a plain type + // alias is not enough because `packages/cli` has no `tsc --noEmit` step (its + // build is tsup + Biome + vitest, none of which check an unused type). + it('every domain the picker can offer is a real @cipherstash/stack/eql/v3 factory', () => { + const dataTypes: DataType[] = [ + 'string', + 'number', + 'boolean', + 'date', + 'json', + ] + const offered = dataTypes.flatMap((dt) => + candidateDomains(dt).map((o) => o.value), + ) + + for (const domain of offered) { + expect( + types, + `@cipherstash/stack/eql/v3 has no "${domain}" factory — the picker would emit types.${domain}(...) which no longer exists`, + ).toHaveProperty(domain) + } + }) +}) diff --git a/packages/cli/src/commands/init/lib/introspect.ts b/packages/cli/src/commands/init/lib/introspect.ts index 23a1aa30..2e4a4c73 100644 --- a/packages/cli/src/commands/init/lib/introspect.ts +++ b/packages/cli/src/commands/init/lib/introspect.ts @@ -111,6 +111,11 @@ export async function introspectDatabase( export function candidateDomains( dataType: DataType, ): Array<{ value: V3Domain; label: string; hint: string }> { + // Hints show capability + the operators/functions each domain answers. + // EQL v3 emits `eql_v3.*()` function calls for comparison/match (not native + // SQL operators); JSON is the exception, using real `@>` (containment) and + // `->` (selector) operators. The bracketed sets below are logical shorthand + // matching the Drizzle-facing operator names, grounded in the v3 sql-dialect. switch (dataType) { case 'string': return [ @@ -119,38 +124,46 @@ export function candidateDomains( label: 'Text', hint: 'storage only — encrypt/decrypt, no queries', }, - { value: 'TextEq', label: 'TextEq', hint: 'equality (=, IN)' }, + { value: 'TextEq', label: 'TextEq', hint: 'equality (=, <>, IN)' }, { value: 'TextOrd', label: 'TextOrd', - hint: 'equality + order/range (<, >, BETWEEN, sort)', + hint: 'equality + order/range (=, <, >, <=, >=, BETWEEN, ORDER BY)', }, { value: 'TextMatch', label: 'TextMatch', - hint: 'free-text match only', + hint: 'free-text match only (matches())', }, { value: 'TextSearch', label: 'TextSearch', - hint: 'equality + order/range + free-text', + hint: 'equality + order/range + free-text (=, <, >, BETWEEN, ORDER BY, matches())', }, ] case 'number': return [ { value: 'Integer', label: 'Integer', hint: 'storage only' }, - { value: 'IntegerEq', label: 'IntegerEq', hint: 'equality (=, IN)' }, + { + value: 'IntegerEq', + label: 'IntegerEq', + hint: 'equality (=, <>, IN)', + }, { value: 'IntegerOrd', label: 'IntegerOrd', - hint: 'equality + order/range', + hint: 'equality + order/range (=, <, >, <=, >=, BETWEEN, ORDER BY)', }, ] case 'date': return [ { value: 'Date', label: 'Date', hint: 'storage only' }, - { value: 'DateEq', label: 'DateEq', hint: 'equality (=, IN)' }, - { value: 'DateOrd', label: 'DateOrd', hint: 'equality + order/range' }, + { value: 'DateEq', label: 'DateEq', hint: 'equality (=, <>, IN)' }, + { + value: 'DateOrd', + label: 'DateOrd', + hint: 'equality + order/range (=, <, >, <=, >=, BETWEEN, ORDER BY)', + }, ] case 'boolean': return [{ value: 'Boolean', label: 'Boolean', hint: 'storage only' }] @@ -159,7 +172,7 @@ export function candidateDomains( { value: 'Json', label: 'Json', - hint: 'encrypted-JSONB containment + selectors', + hint: 'encrypted-JSONB containment + selectors (@>, ->; =, <, >, BETWEEN, ORDER BY at a path)', }, ] } From 9a0f79bf625189fa7302775c9182e9593e8ccba4 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 16:11:58 +1000 Subject: [PATCH 11/12] test(cli): close v3 picker coverage gaps flagged in PR review Address @auxesis review threads on PR #755: - assert the picker passes initialValue: defaultDomain(options) so the widest searchable domain is pre-selected (a regression to the narrowest otherwise passes the whole suite) - assert defaultDomain's candidate-list (array) overload returns the last value - cover the supabase codegen routing (shares the generic generator with postgresql; previously only postgresql/drizzle were exercised) --- .../init/__tests__/utils-codegen.test.ts | 10 ++++++ .../init/lib/__tests__/introspect.test.ts | 32 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts index e87879a9..ce34bb14 100644 --- a/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts +++ b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts @@ -39,6 +39,16 @@ describe('generateClientFromSchemas', () => { expect(out).not.toMatch(/freeTextSearch|orderAndRange|\.equality\(/) } }) + + it('routes supabase through the generic generator, not drizzle', () => { + // `supabase` and `postgresql` share generateGenericFromSchemas; a misroute + // (dropping the `case 'supabase':` fallthrough, or routing it to drizzle) + // is otherwise uncovered — the other cases only exercise postgresql/drizzle. + const out = generateClientFromSchemas('supabase', schemas) + expect(out).toContain("email: types.TextSearch('email'),") + expect(out).toContain("from '@cipherstash/stack/v3'") + expect(out).not.toContain('@cipherstash/stack-drizzle') + }) }) // Exhaustive round-trip over the closed V3Domain union: the sample fixtures diff --git a/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts b/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts index a3585c5b..40a011e9 100644 --- a/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts @@ -93,6 +93,14 @@ describe('defaultDomain', () => { expect(values).toContain(defaultDomain(dt)) } }) + + it('returns the last value of a supplied candidate list (array overload)', () => { + // The picker loop passes its already-computed options through this branch; + // asserts it reads the widest (last), not the narrowest (first). + expect(defaultDomain([{ value: 'TextEq' }, { value: 'TextSearch' }])).toBe( + 'TextSearch', + ) + }) }) describe('selectTableColumns', () => { @@ -205,6 +213,30 @@ describe('selectTableColumns', () => { // which falls back to 'string', so it still receives its chosen domain. expect(schema?.columns).toEqual([{ name: 'ssn', domain: 'TextSearch' }]) }) + + it('pre-selects the widest searchable domain as the per-column default', async () => { + // Asserts the ARGS to the domain prompt, not just its return: the picker + // must pass initialValue: defaultDomain(options) so the widest searchable + // domain is pre-highlighted. Regressing to options[0] (narrowest) or + // dropping initialValue would still return the mocked value and pass every + // other test — only this one catches it. + selectMock + .mockResolvedValueOnce('users') // table pick + .mockResolvedValueOnce('TextSearch') // email domain + .mockResolvedValueOnce('IntegerOrd') // age domain + multiselectMock.mockResolvedValueOnce(['email', 'age']) + + await selectTableColumns(tables) + + expect(selectMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ initialValue: 'TextSearch' }), + ) + expect(selectMock).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ initialValue: 'IntegerOrd' }), + ) + }) }) describe('v3 domain drift guard', () => { From 9bf22661612f2b49da822660244d308cb2c56af9 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 16:37:41 +1000 Subject: [PATCH 12/12] test(cli): cover buildSchemasFromDatabase failure/cancel + total codegen switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the out-of-scope gaps noted (not posted inline) in the PR #755 review: - generateClientFromSchemas now handles every Integration: prisma-next throws a clear error instead of silently returning undefined, so the switch is total and a future integration can't fall through to undefined. - test buildSchemasFromDatabase's connection/introspection-failure branch (returns undefined, doesn't propagate) and the 'another table?' cancel branch (aborts, distinct from declining). Internal robustness + coverage only; prisma-next is unreachable from schema/build.ts (which produces only supabase|postgresql), so no user-observable behaviour change — no changeset. --- .../init/__tests__/utils-codegen.test.ts | 9 +++++++++ .../__tests__/build-flow.integration.test.ts | 18 ++++++++++++++++++ packages/cli/src/commands/init/utils.ts | 11 +++++++++++ 3 files changed, 38 insertions(+) diff --git a/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts index ce34bb14..1221065d 100644 --- a/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts +++ b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts @@ -49,6 +49,15 @@ describe('generateClientFromSchemas', () => { expect(out).toContain("from '@cipherstash/stack/v3'") expect(out).not.toContain('@cipherstash/stack-drizzle') }) + + it('throws for prisma-next instead of returning an undefined client', () => { + // prisma-next is unreachable from schema/build.ts, but the switch must stay + // total: fail loudly rather than silently returning undefined (which would + // write a broken client file) if a caller ever routes it here. + expect(() => generateClientFromSchemas('prisma-next', schemas)).toThrow( + /does not generate a prisma-next client/, + ) + }) }) // Exhaustive round-trip over the closed V3Domain union: the sample fixtures diff --git a/packages/cli/src/commands/init/lib/__tests__/build-flow.integration.test.ts b/packages/cli/src/commands/init/lib/__tests__/build-flow.integration.test.ts index f2338816..6851bd06 100644 --- a/packages/cli/src/commands/init/lib/__tests__/build-flow.integration.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/build-flow.integration.test.ts @@ -86,4 +86,22 @@ describe('build flow (introspect → pick → codegen)', () => { queryMock.mockResolvedValueOnce({ rows: [] }) expect(await buildSchemasFromDatabase('postgresql://x')).toBeUndefined() }) + + it('returns undefined when the database connection/introspection fails', async () => { + // introspectDatabase throws → buildSchemasFromDatabase catches, stops the + // spinner, logs the error, and returns undefined rather than propagating. + queryMock.mockReset() + queryMock.mockRejectedValueOnce(new Error('ECONNREFUSED')) + expect(await buildSchemasFromDatabase('postgresql://x')).toBeUndefined() + }) + + it('returns undefined when the "another table?" prompt is cancelled', async () => { + // Distinct from declining (false): cancelling (Ctrl-C) mid-loop must abort + // the whole build, not return the tables gathered so far. + selectMock.mockResolvedValueOnce('users').mockResolvedValueOnce('TextEq') + multiselectMock.mockResolvedValueOnce(['email']) + confirmMock.mockResolvedValueOnce(CANCEL) // cancel the "another table?" prompt + + expect(await buildSchemasFromDatabase('postgresql://x')).toBeUndefined() + }) }) diff --git a/packages/cli/src/commands/init/utils.ts b/packages/cli/src/commands/init/utils.ts index 3a6c35d7..3045433d 100644 --- a/packages/cli/src/commands/init/utils.ts +++ b/packages/cli/src/commands/init/utils.ts @@ -338,6 +338,17 @@ export function generateClientFromSchemas( case 'supabase': case 'postgresql': return generateGenericFromSchemas(schemas) + case 'prisma-next': + // `schema build` doesn't scaffold a prisma-next client — that integration + // uses its own per-domain constructors (`cipherstash.TextSearch()` …) and + // a migration-based flow, not the `types.*` client this emits. It never + // reaches here (schema/build.ts only produces 'supabase' | 'postgresql'), + // but fail loudly rather than writing an `undefined` client if it ever + // does. Naming every case also keeps the switch exhaustive, so a new + // Integration can't silently fall through to `undefined` again. + throw new Error( + '`stash schema build` does not generate a prisma-next client; use `stash plan` → `stash impl` instead.', + ) } }