Skip to content

Commit 35e5e27

Browse files
committed
feat(prisma-next): add v3 baseline migration + expandNativeType domain hook
migration.json/end-contract.* are regenerated by the framework contract-emit CLI during e2e setup (Task 15); ops.json is generated here from the operations getter.
1 parent 1c84755 commit 35e5e27

5 files changed

Lines changed: 173 additions & 0 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env -S node
2+
/**
3+
* CipherStash EQL v3 baseline migration — install the vendored eql_v3 bundle.
4+
*
5+
* Mirrors the v2 baseline (`../20260601T0000_install_eql_bundle/migration.ts`)
6+
* but installs the **eql_v3** bundle: the `eql_v3` schema, the `eql_v3.text*`
7+
* domains (`text`, `text_eq`, `text_match`, `text_ord`), and the index-term
8+
* extractor functions. The bundle flows in byte-for-byte under the
9+
* `cipherstash:install-eql-v3-bundle-v1` invariant.
10+
*
11+
* Unlike v2, v3 emits NO `add_search_config` rows — the per-column domain type
12+
* (applied via the codec hook's `expandNativeType`) encodes the index capability.
13+
*
14+
* Authoring loop: hand-edited; re-emit `ops.json` / `migration.json` after edits
15+
* via `node migration.ts`.
16+
*/
17+
import { Migration, MigrationCLI, rawSql } from '@prisma-next/target-postgres/migration';
18+
import { CIPHERSTASH_INVARIANTS } from '../../src/extension-metadata/constants';
19+
import { EQL_V3_BUNDLE_SQL } from '../../src/migration/eql-v3-bundle';
20+
21+
const INSTALL_LABEL = 'Install EQL v3 bundle (eql_v3 schema, text domains, index-term extractors)';
22+
23+
export default class M extends Migration {
24+
override describe() {
25+
return {
26+
from: null,
27+
to: 'cipherstash:eql-v3-baseline',
28+
};
29+
}
30+
31+
override get operations() {
32+
return [
33+
rawSql({
34+
id: 'cipherstash.install-eql-v3-bundle',
35+
label: INSTALL_LABEL,
36+
operationClass: 'additive',
37+
invariantId: CIPHERSTASH_INVARIANTS.installBundleV3,
38+
target: { id: 'postgres' },
39+
precheck: [],
40+
execute: [{ description: INSTALL_LABEL, sql: EQL_V3_BUNDLE_SQL }],
41+
postcheck: [
42+
{
43+
description: 'verify "eql_v3" schema exists',
44+
sql: "SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'eql_v3')",
45+
},
46+
{
47+
description: 'verify "eql_v3.text_eq" domain exists',
48+
sql: "SELECT EXISTS (SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname = 'eql_v3' AND t.typname = 'text_eq')",
49+
},
50+
],
51+
}),
52+
];
53+
}
54+
}
55+
56+
MigrationCLI.run(import.meta.url, M);

packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/ops.json

Lines changed: 28 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* `CodecControlHooks` for the EQL v3 string codec.
3+
*
4+
* v3 diverges from the v2 hooks factory in two ways:
5+
*
6+
* - **`expandNativeType` is NOT identity.** A v3 column's DDL type is the
7+
* per-index domain (`eql_v3.text_eq` / `text_match` / `text_ord`), derived
8+
* from `typeParams.index`. The base `eql_v3.text` is narrowed here at plan
9+
* time — this is what makes the column's domain CHECK enforce the chosen
10+
* index capability.
11+
* - **`onFieldEvent` emits NO `add_search_config`.** In v2 the search capability
12+
* is wired by `eql_v2.add_search_config` rows; in v3 the domain type itself
13+
* encodes the capability (applied via `expandNativeType`), so no config rows
14+
* are emitted on add/drop/alter.
15+
*/
16+
17+
import type { CodecControlHooks } from '@prisma-next/family-sql/control'
18+
import { type V3Index, eqlV3Domain } from '../v3/domain-map'
19+
20+
const V3_INDEX_VALUES = ['equality', 'freeTextSearch', 'orderAndRange'] as const
21+
22+
function isV3Index(value: unknown): value is V3Index {
23+
return typeof value === 'string' && (V3_INDEX_VALUES as readonly string[]).includes(value)
24+
}
25+
26+
const expandNativeType: NonNullable<CodecControlHooks['expandNativeType']> = ({ typeParams }) => {
27+
const index = typeParams?.['index']
28+
// Per-index domain when the column declares one (it always does); fall back to
29+
// the base storage domain otherwise so the hook never throws on a degenerate ctx.
30+
return eqlV3Domain('text', isV3Index(index) ? index : undefined)
31+
}
32+
33+
export const cipherstashStringV3CodecHooks: CodecControlHooks = {
34+
// v3 capability is encoded by the domain type (expandNativeType), not by
35+
// add_search_config rows — so no field-event ops are emitted.
36+
onFieldEvent: () => [],
37+
expandNativeType,
38+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { cipherstashStringV3CodecHooks } from '../../src/migration/codec-hooks-v3'
3+
4+
describe('cipherstashStringV3CodecHooks.expandNativeType', () => {
5+
const expand = cipherstashStringV3CodecHooks.expandNativeType!
6+
7+
it('returns the per-index domain for the column', () => {
8+
expect(expand({ nativeType: 'eql_v3.text', typeParams: { index: 'equality' } })).toBe('eql_v3.text_eq')
9+
expect(expand({ nativeType: 'eql_v3.text', typeParams: { index: 'freeTextSearch' } })).toBe('eql_v3.text_match')
10+
expect(expand({ nativeType: 'eql_v3.text', typeParams: { index: 'orderAndRange' } })).toBe('eql_v3.text_ord')
11+
})
12+
13+
it('falls back to the base storage domain when no index is present', () => {
14+
expect(expand({ nativeType: 'eql_v3.text' })).toBe('eql_v3.text')
15+
})
16+
})
17+
18+
describe('cipherstashStringV3CodecHooks.onFieldEvent', () => {
19+
it('emits NO ops on field add (the domain encodes the capability — no add_search_config)', () => {
20+
const ops = cipherstashStringV3CodecHooks.onFieldEvent!('added', {
21+
tableName: 'user_v3',
22+
fieldName: 'email',
23+
newField: { typeParams: { index: 'equality' } },
24+
} as never)
25+
expect(ops).toEqual([])
26+
})
27+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from 'vitest'
2+
import migrationV3 from '../../migrations/20260601T0100_install_eql_v3_bundle/migration'
3+
import { CIPHERSTASH_INVARIANTS } from '../../src/extension-metadata/constants'
4+
import { EQL_V3_BUNDLE_SQL } from '../../src/migration/eql-v3-bundle'
5+
6+
describe('v3 baseline migration', () => {
7+
it('installs the v3 bundle byte-for-byte under the v3 invariant', () => {
8+
const ops = new (migrationV3 as unknown as new () => { operations: ReadonlyArray<Record<string, unknown>> })().operations
9+
const op = ops[0] as {
10+
invariantId: string
11+
execute: ReadonlyArray<{ sql: string }>
12+
}
13+
expect(op.invariantId).toBe(CIPHERSTASH_INVARIANTS.installBundleV3)
14+
expect(op.execute[0]!.sql).toBe(EQL_V3_BUNDLE_SQL)
15+
})
16+
17+
it('postchecks the eql_v3 schema + text_eq domain', () => {
18+
const ops = new (migrationV3 as unknown as new () => { operations: ReadonlyArray<Record<string, unknown>> })().operations
19+
const op = ops[0] as { postcheck: ReadonlyArray<{ sql: string }> }
20+
const sqls = op.postcheck.map((p) => p.sql).join('\n')
21+
expect(sqls).toContain('eql_v3')
22+
expect(sqls).toContain('text_eq')
23+
})
24+
})

0 commit comments

Comments
 (0)