Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/schema-build-v3-domain-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'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 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).
96 changes: 96 additions & 0 deletions packages/cli/src/commands/init/__tests__/utils-codegen.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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\(/)
}
})

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')
})

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
// 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'),`,
)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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)
if (!schemas) throw new Error('expected schemas to be defined')

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()
})

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()
})
})
Loading
Loading