Skip to content
Draft
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
72 changes: 72 additions & 0 deletions packages/migrate/src/__tests__/version.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>): {
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<QueryResultRow>)
},
} 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'])
})
})
1 change: 1 addition & 0 deletions packages/migrate/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,4 @@ export {
type MigrationStateRow,
progress,
} from './state.js'
export { detectColumnEqlVersion, type EqlVersion } from './version.js'
48 changes: 48 additions & 0 deletions packages/migrate/src/version.ts
Original file line number Diff line number Diff line change
@@ -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<EqlVersion | null> {
// `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
}
Loading