Skip to content

Commit 3d81993

Browse files
committed
feat(migrate): detect a column's EQL version (v2 vs v3)
First slice of EQL v3 support (#648): detectColumnEqlVersion() inspects an encrypted column's Postgres domain type — public.eql_v2_encrypted -> v2, a concrete eql_v3_* domain -> v3, anything else (plaintext / not found) -> null. This is the keystone the version-aware lifecycle branches on. Unit-tested with a mocked pg client; no behaviour change to existing v2 paths. Refs #648 Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
1 parent bab1307 commit 3d81993

3 files changed

Lines changed: 121 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { ClientBase, QueryConfig, QueryResult, QueryResultRow } from 'pg'
2+
import { describe, expect, it } from 'vitest'
3+
import { detectColumnEqlVersion } from '../version.js'
4+
5+
interface RecordedQuery {
6+
text: string
7+
values: unknown[]
8+
}
9+
10+
function createMockClient(rows: Array<Record<string, unknown>>): {
11+
client: ClientBase
12+
queries: RecordedQuery[]
13+
} {
14+
const queries: RecordedQuery[] = []
15+
const client = {
16+
query(config: string | QueryConfig, values?: unknown[]) {
17+
const text = typeof config === 'string' ? config : config.text
18+
queries.push({ text, values: values ?? [] })
19+
return Promise.resolve({
20+
rows,
21+
rowCount: rows.length,
22+
command: '',
23+
oid: 0,
24+
fields: [],
25+
} as unknown as QueryResult<QueryResultRow>)
26+
},
27+
} as unknown as ClientBase
28+
return { client, queries }
29+
}
30+
31+
describe('detectColumnEqlVersion', () => {
32+
it('maps the eql_v2_encrypted domain to v2', async () => {
33+
const { client } = createMockClient([{ domain_name: 'eql_v2_encrypted' }])
34+
expect(
35+
await detectColumnEqlVersion(client, 'users', 'email_encrypted'),
36+
).toBe('v2')
37+
})
38+
39+
it('maps any concrete eql_v3_* domain to v3', async () => {
40+
for (const domain of [
41+
'eql_v3_text_search',
42+
'eql_v3_text_match',
43+
'eql_v3_int8_ord',
44+
'eql_v3_encrypted',
45+
]) {
46+
const { client } = createMockClient([{ domain_name: domain }])
47+
expect(
48+
await detectColumnEqlVersion(client, 'users', 'email_encrypted'),
49+
).toBe('v3')
50+
}
51+
})
52+
53+
it('returns null for a plaintext column (base type, not a domain)', async () => {
54+
const { client } = createMockClient([{ domain_name: 'text' }])
55+
expect(await detectColumnEqlVersion(client, 'users', 'email')).toBeNull()
56+
})
57+
58+
it('returns null when the column/table is not found', async () => {
59+
const { client } = createMockClient([])
60+
expect(await detectColumnEqlVersion(client, 'nope', 'missing')).toBeNull()
61+
})
62+
63+
it('passes the qualified table name and column as bind params (to_regclass)', async () => {
64+
const { client, queries } = createMockClient([
65+
{ domain_name: 'eql_v3_text_search' },
66+
])
67+
await detectColumnEqlVersion(client, 'app.users', 'email_encrypted')
68+
expect(queries).toHaveLength(1)
69+
expect(queries[0].text).toContain('to_regclass($1)')
70+
expect(queries[0].values).toEqual(['app.users', 'email_encrypted'])
71+
})
72+
})

packages/migrate/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,4 @@ export {
6767
type MigrationStateRow,
6868
progress,
6969
} from './state.js'
70+
export { detectColumnEqlVersion, type EqlVersion } from './version.js'

packages/migrate/src/version.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { ClientBase } from 'pg'
2+
3+
/**
4+
* Which EQL generation an encrypted column belongs to. The migration lifecycle
5+
* differs between them: v2 is driven by the `eql_v2_configuration` state machine
6+
* (see {@link import('./eql.js')}), while v3 is domain-native — configuration
7+
* lives in the column's own type and there is no configuration table, so its
8+
* lifecycle is backfill-then-drop with no cut-over rename.
9+
*/
10+
export type EqlVersion = 'v2' | 'v3'
11+
12+
/**
13+
* Detect the EQL version of a column by inspecting its Postgres type.
14+
*
15+
* - v2 encrypted columns are the `public.eql_v2_encrypted` domain.
16+
* - v3 encrypted columns are a concrete `eql_v3_*` domain (e.g.
17+
* `eql_v3_text_search`, `eql_v3_int8_ord`).
18+
* - Anything else — a plaintext column, or a column/table that doesn't exist —
19+
* returns `null`.
20+
*
21+
* Pass the **encrypted target** column (e.g. `email_encrypted`), not the
22+
* plaintext source: it's the encrypted column whose domain type carries the EQL
23+
* generation. `tableName` may be schema-qualified (`"schema.table"`);
24+
* resolution honours the connection's `search_path` via `to_regclass`.
25+
*/
26+
export async function detectColumnEqlVersion(
27+
client: ClientBase,
28+
tableName: string,
29+
columnName: string,
30+
): Promise<EqlVersion | null> {
31+
// `a.atttypid` on a domain-typed column is the DOMAIN's oid, so `t.typname`
32+
// is the domain name (e.g. `eql_v2_encrypted`), not the underlying `jsonb`.
33+
// `to_regclass` returns NULL for an unknown table → no rows → null.
34+
const result = await client.query<{ domain_name: string }>(
35+
`SELECT t.typname AS domain_name
36+
FROM pg_attribute a
37+
JOIN pg_type t ON t.oid = a.atttypid
38+
WHERE a.attrelid = to_regclass($1)
39+
AND a.attname = $2
40+
AND NOT a.attisdropped`,
41+
[tableName, columnName],
42+
)
43+
const domain = result.rows[0]?.domain_name
44+
if (domain === undefined) return null
45+
if (domain === 'eql_v2_encrypted') return 'v2'
46+
if (domain.startsWith('eql_v3')) return 'v3'
47+
return null
48+
}

0 commit comments

Comments
 (0)