Skip to content

Commit 2aeba94

Browse files
authored
Merge pull request #755 from cipherstash/worktree-schema-build-v3-picker
feat(cli): per-column EQL v3 domain picker in stash schema build
2 parents 6ce5381 + 9bf2266 commit 2aeba94

8 files changed

Lines changed: 647 additions & 84 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'stash': patch
3+
---
4+
5+
`stash schema build` now picks a concrete EQL v3 domain per column
6+
(`TextSearch`, `IntegerOrd`, `TextEq`, …) instead of the legacy v2
7+
"searchable capabilities" toggle. Boolean columns are assigned the
8+
storage-only `types.Boolean` domain automatically, while JSON columns are
9+
assigned the queryable `types.Json` domain, with encrypted containment and
10+
selector queries. Other columns default to the widest searchable domain,
11+
matching the previous behaviour. The internal `SearchOp` capability tuple
12+
and the `v3DomainFactory` translation shim are removed, unblocking EQL v2
13+
removal (#707, #751).
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, expect, it } from 'vitest'
2+
import type { SchemaDef } from '../types.js'
3+
import { generateClientFromSchemas } from '../utils.js'
4+
5+
const schemas: SchemaDef[] = [
6+
{
7+
tableName: 'users',
8+
columns: [
9+
{ name: 'email', domain: 'TextSearch' },
10+
{ name: 'age', domain: 'IntegerOrd' },
11+
{ name: 'verified', domain: 'Boolean' },
12+
],
13+
},
14+
]
15+
16+
describe('generateClientFromSchemas', () => {
17+
it('emits the chosen v3 domain factory per column (generic/postgresql)', () => {
18+
const out = generateClientFromSchemas('postgresql', schemas)
19+
expect(out).toContain("email: types.TextSearch('email'),")
20+
expect(out).toContain("age: types.IntegerOrd('age'),")
21+
expect(out).toContain("verified: types.Boolean('verified'),")
22+
expect(out).toContain("from '@cipherstash/stack/v3'")
23+
expect(out).toContain('EncryptionV3(')
24+
})
25+
26+
it('emits the chosen v3 domain factory per column (drizzle)', () => {
27+
const out = generateClientFromSchemas('drizzle', schemas)
28+
expect(out).toContain("email: types.TextSearch('email'),")
29+
expect(out).toContain("age: types.IntegerOrd('age'),")
30+
expect(out).toContain('extractEncryptionSchemaV3')
31+
expect(out).toContain("from '@cipherstash/stack-drizzle/v3'")
32+
})
33+
34+
it('carries no residual v2 capability vocabulary', () => {
35+
const generic = generateClientFromSchemas('postgresql', schemas)
36+
const drizzle = generateClientFromSchemas('drizzle', schemas)
37+
for (const out of [generic, drizzle]) {
38+
expect(out).not.toMatch(/searchOps/)
39+
expect(out).not.toMatch(/freeTextSearch|orderAndRange|\.equality\(/)
40+
}
41+
})
42+
43+
it('routes supabase through the generic generator, not drizzle', () => {
44+
// `supabase` and `postgresql` share generateGenericFromSchemas; a misroute
45+
// (dropping the `case 'supabase':` fallthrough, or routing it to drizzle)
46+
// is otherwise uncovered — the other cases only exercise postgresql/drizzle.
47+
const out = generateClientFromSchemas('supabase', schemas)
48+
expect(out).toContain("email: types.TextSearch('email'),")
49+
expect(out).toContain("from '@cipherstash/stack/v3'")
50+
expect(out).not.toContain('@cipherstash/stack-drizzle')
51+
})
52+
53+
it('throws for prisma-next instead of returning an undefined client', () => {
54+
// prisma-next is unreachable from schema/build.ts, but the switch must stay
55+
// total: fail loudly rather than silently returning undefined (which would
56+
// write a broken client file) if a caller ever routes it here.
57+
expect(() => generateClientFromSchemas('prisma-next', schemas)).toThrow(
58+
/does not generate a prisma-next client/,
59+
)
60+
})
61+
})
62+
63+
// Exhaustive round-trip over the closed V3Domain union: the sample fixtures
64+
// above only exercise 3 of 13 domains, so every domain is proven to emit
65+
// verbatim through both generators. `V3Domain` and `DataType` are finite closed
66+
// unions, so enumeration is complete — a fast-check property would sample the
67+
// same finite set and add nothing (and `packages/cli` has no fast-check dep).
68+
const ALL_DOMAINS: import('../types.js').V3Domain[] = [
69+
'Text',
70+
'TextEq',
71+
'TextOrd',
72+
'TextMatch',
73+
'TextSearch',
74+
'Integer',
75+
'IntegerEq',
76+
'IntegerOrd',
77+
'Date',
78+
'DateEq',
79+
'DateOrd',
80+
'Boolean',
81+
'Json',
82+
]
83+
84+
describe.each([
85+
'postgresql',
86+
'drizzle',
87+
] as const)('generateClientFromSchemas domain round-trip (%s)', (integration) => {
88+
it.each(ALL_DOMAINS)('emits types.%s verbatim', (domain) => {
89+
const s: SchemaDef[] = [
90+
{ tableName: 'x', columns: [{ name: 'c', domain }] },
91+
]
92+
expect(generateClientFromSchemas(integration, s)).toContain(
93+
`c: types.${domain}('c'),`,
94+
)
95+
})
96+
})
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
const selectMock = vi.fn()
4+
const multiselectMock = vi.fn()
5+
const confirmMock = vi.fn()
6+
const CANCEL = Symbol('clack.cancel')
7+
vi.mock('@clack/prompts', () => ({
8+
select: (...a: unknown[]) => selectMock(...a),
9+
multiselect: (...a: unknown[]) => multiselectMock(...a),
10+
confirm: (...a: unknown[]) => confirmMock(...a),
11+
isCancel: (v: unknown) => v === CANCEL,
12+
log: { info: vi.fn(), success: vi.fn(), error: vi.fn() },
13+
spinner: () => ({ start: vi.fn(), stop: vi.fn() }),
14+
}))
15+
16+
const queryMock = vi.fn()
17+
vi.mock('pg', () => ({
18+
default: {
19+
Client: vi.fn(() => ({
20+
connect: vi.fn(async () => {}),
21+
query: queryMock,
22+
end: vi.fn(async () => {}),
23+
})),
24+
},
25+
}))
26+
27+
const { buildSchemasFromDatabase } = await import('../introspect.js')
28+
const { generateClientFromSchemas } = await import('../../utils.js')
29+
30+
describe('build flow (introspect → pick → codegen)', () => {
31+
beforeEach(() => {
32+
selectMock.mockReset()
33+
multiselectMock.mockReset()
34+
confirmMock.mockReset()
35+
queryMock.mockResolvedValue({
36+
rows: [
37+
{
38+
table_name: 'users',
39+
column_name: 'email',
40+
data_type: 'text',
41+
udt_name: 'text',
42+
},
43+
{
44+
table_name: 'orders',
45+
column_name: 'total',
46+
data_type: 'integer',
47+
udt_name: 'int4',
48+
},
49+
],
50+
})
51+
})
52+
53+
it('walks two tables and emits a v3 client with the chosen domains', async () => {
54+
selectMock
55+
.mockResolvedValueOnce('users')
56+
.mockResolvedValueOnce('TextEq') // table 1 + email
57+
.mockResolvedValueOnce('orders')
58+
.mockResolvedValueOnce('IntegerOrd') // table 2 + total
59+
multiselectMock
60+
.mockResolvedValueOnce(['email'])
61+
.mockResolvedValueOnce(['total'])
62+
confirmMock.mockResolvedValueOnce(true) // "encrypt columns in another table?"
63+
64+
const schemas = await buildSchemasFromDatabase('postgresql://x')
65+
expect(schemas).toHaveLength(2)
66+
if (!schemas) throw new Error('expected schemas to be defined')
67+
68+
const out = generateClientFromSchemas('postgresql', schemas)
69+
expect(out).toContain("email: types.TextEq('email'),")
70+
expect(out).toContain("total: types.IntegerOrd('total'),")
71+
expect(out).not.toMatch(/searchOps|v3DomainFactory/)
72+
})
73+
74+
it('stops after the first table when the user declines another', async () => {
75+
selectMock.mockResolvedValueOnce('users').mockResolvedValueOnce('TextEq')
76+
multiselectMock.mockResolvedValueOnce(['email'])
77+
confirmMock.mockResolvedValueOnce(false) // decline "another table?"
78+
79+
const schemas = await buildSchemasFromDatabase('postgresql://x')
80+
expect(schemas).toEqual([
81+
{ tableName: 'users', columns: [{ name: 'email', domain: 'TextEq' }] },
82+
])
83+
})
84+
85+
it('returns undefined for an empty public schema', async () => {
86+
queryMock.mockResolvedValueOnce({ rows: [] })
87+
expect(await buildSchemasFromDatabase('postgresql://x')).toBeUndefined()
88+
})
89+
90+
it('returns undefined when the database connection/introspection fails', async () => {
91+
// introspectDatabase throws → buildSchemasFromDatabase catches, stops the
92+
// spinner, logs the error, and returns undefined rather than propagating.
93+
queryMock.mockReset()
94+
queryMock.mockRejectedValueOnce(new Error('ECONNREFUSED'))
95+
expect(await buildSchemasFromDatabase('postgresql://x')).toBeUndefined()
96+
})
97+
98+
it('returns undefined when the "another table?" prompt is cancelled', async () => {
99+
// Distinct from declining (false): cancelling (Ctrl-C) mid-loop must abort
100+
// the whole build, not return the tables gathered so far.
101+
selectMock.mockResolvedValueOnce('users').mockResolvedValueOnce('TextEq')
102+
multiselectMock.mockResolvedValueOnce(['email'])
103+
confirmMock.mockResolvedValueOnce(CANCEL) // cancel the "another table?" prompt
104+
105+
expect(await buildSchemasFromDatabase('postgresql://x')).toBeUndefined()
106+
})
107+
})

0 commit comments

Comments
 (0)