From af92d80ce7e666b5425e63a9520e19bbba721ab2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 14 Jul 2026 16:12:28 +1000 Subject: [PATCH] feat(migrate): detect a column's EQL version (v2 vs v3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../migrate/src/__tests__/version.test.ts | 72 +++++++++++++++++++ packages/migrate/src/index.ts | 1 + packages/migrate/src/version.ts | 48 +++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 packages/migrate/src/__tests__/version.test.ts create mode 100644 packages/migrate/src/version.ts diff --git a/packages/migrate/src/__tests__/version.test.ts b/packages/migrate/src/__tests__/version.test.ts new file mode 100644 index 000000000..e95036cec --- /dev/null +++ b/packages/migrate/src/__tests__/version.test.ts @@ -0,0 +1,72 @@ +import type { ClientBase, QueryConfig, QueryResult, QueryResultRow } from 'pg' +import { describe, expect, it } from 'vitest' +import { detectColumnEqlVersion } from '../version.js' + +interface RecordedQuery { + text: string + values: unknown[] +} + +function createMockClient(rows: Array>): { + client: ClientBase + queries: RecordedQuery[] +} { + const queries: RecordedQuery[] = [] + const client = { + query(config: string | QueryConfig, values?: unknown[]) { + const text = typeof config === 'string' ? config : config.text + queries.push({ text, values: values ?? [] }) + return Promise.resolve({ + rows, + rowCount: rows.length, + command: '', + oid: 0, + fields: [], + } as unknown as QueryResult) + }, + } as unknown as ClientBase + return { client, queries } +} + +describe('detectColumnEqlVersion', () => { + it('maps the eql_v2_encrypted domain to v2', async () => { + const { client } = createMockClient([{ domain_name: 'eql_v2_encrypted' }]) + expect( + await detectColumnEqlVersion(client, 'users', 'email_encrypted'), + ).toBe('v2') + }) + + it('maps any concrete eql_v3_* domain to v3', async () => { + for (const domain of [ + 'eql_v3_text_search', + 'eql_v3_text_match', + 'eql_v3_int8_ord', + 'eql_v3_encrypted', + ]) { + const { client } = createMockClient([{ domain_name: domain }]) + expect( + await detectColumnEqlVersion(client, 'users', 'email_encrypted'), + ).toBe('v3') + } + }) + + it('returns null for a plaintext column (base type, not a domain)', async () => { + const { client } = createMockClient([{ domain_name: 'text' }]) + expect(await detectColumnEqlVersion(client, 'users', 'email')).toBeNull() + }) + + it('returns null when the column/table is not found', async () => { + const { client } = createMockClient([]) + expect(await detectColumnEqlVersion(client, 'nope', 'missing')).toBeNull() + }) + + it('passes the qualified table name and column as bind params (to_regclass)', async () => { + const { client, queries } = createMockClient([ + { domain_name: 'eql_v3_text_search' }, + ]) + await detectColumnEqlVersion(client, 'app.users', 'email_encrypted') + expect(queries).toHaveLength(1) + expect(queries[0].text).toContain('to_regclass($1)') + expect(queries[0].values).toEqual(['app.users', 'email_encrypted']) + }) +}) diff --git a/packages/migrate/src/index.ts b/packages/migrate/src/index.ts index 5c3620d8f..dfaec9e65 100644 --- a/packages/migrate/src/index.ts +++ b/packages/migrate/src/index.ts @@ -67,3 +67,4 @@ export { type MigrationStateRow, progress, } from './state.js' +export { detectColumnEqlVersion, type EqlVersion } from './version.js' diff --git a/packages/migrate/src/version.ts b/packages/migrate/src/version.ts new file mode 100644 index 000000000..16ad7fd58 --- /dev/null +++ b/packages/migrate/src/version.ts @@ -0,0 +1,48 @@ +import type { ClientBase } from 'pg' + +/** + * Which EQL generation an encrypted column belongs to. The migration lifecycle + * differs between them: v2 is driven by the `eql_v2_configuration` state machine + * (see {@link import('./eql.js')}), while v3 is domain-native — configuration + * lives in the column's own type and there is no configuration table, so its + * lifecycle is backfill-then-drop with no cut-over rename. + */ +export type EqlVersion = 'v2' | 'v3' + +/** + * Detect the EQL version of a column by inspecting its Postgres type. + * + * - v2 encrypted columns are the `public.eql_v2_encrypted` domain. + * - v3 encrypted columns are a concrete `eql_v3_*` domain (e.g. + * `eql_v3_text_search`, `eql_v3_int8_ord`). + * - Anything else — a plaintext column, or a column/table that doesn't exist — + * returns `null`. + * + * Pass the **encrypted target** column (e.g. `email_encrypted`), not the + * plaintext source: it's the encrypted column whose domain type carries the EQL + * generation. `tableName` may be schema-qualified (`"schema.table"`); + * resolution honours the connection's `search_path` via `to_regclass`. + */ +export async function detectColumnEqlVersion( + client: ClientBase, + tableName: string, + columnName: string, +): Promise { + // `a.atttypid` on a domain-typed column is the DOMAIN's oid, so `t.typname` + // is the domain name (e.g. `eql_v2_encrypted`), not the underlying `jsonb`. + // `to_regclass` returns NULL for an unknown table → no rows → null. + const result = await client.query<{ domain_name: string }>( + `SELECT t.typname AS domain_name + FROM pg_attribute a + JOIN pg_type t ON t.oid = a.atttypid + WHERE a.attrelid = to_regclass($1) + AND a.attname = $2 + AND NOT a.attisdropped`, + [tableName, columnName], + ) + const domain = result.rows[0]?.domain_name + if (domain === undefined) return null + if (domain === 'eql_v2_encrypted') return 'v2' + if (domain.startsWith('eql_v3')) return 'v3' + return null +}