Skip to content

Commit 01f1d15

Browse files
committed
fix(prisma-next): attest runtime EQL migration ops
1 parent 95d7f39 commit 01f1d15

6 files changed

Lines changed: 148 additions & 44 deletions

File tree

.changeset/prisma-next-eql-runtime-source.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ two must move together, so an EQL upgrade is a coordinated version bump, not a
1111
float. The v3 baseline migration no longer embeds the ~1.7 MB install bundle in
1212
its `ops.json`: the committed op carries a placeholder, and the extension
1313
descriptor injects `readInstallSql()` from the installed `@cipherstash/eql` when
14-
it is built.
14+
it is built, and recomputes the content-addressed migration hash from the
15+
injected operations before Prisma Next materialises the package.
1516

1617
The win over baking: bumping the pinned `@cipherstash/eql` no longer requires
1718
re-running the maintainer emit loop to regenerate a 1.7 MB `ops.json` — it is a

packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/migration.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
* The install SQL is NOT baked into `ops.json`. The committed op carries
66
* `RUNTIME_EQL_SQL_SENTINEL`; `src/exports/control.ts` injects
77
* `readInstallSql()` from the installed `@cipherstash/eql` at descriptor-build
8-
* time (see `../../src/migration/eql-bundle-v3.ts` `withRuntimeEqlSql`), so
9-
* bumping the pinned `@cipherstash/eql` needs no re-emit of this ~1.7 MB
10-
* `ops.json`. The bundle creates the 40 `public.eql_v3_*` storage domains, the
11-
* `eql_v3` operator-function schema (`eql_v3.eq`, `eql_v3.ord_term`, …), the
8+
* time and recomputes the migration hash from those runtime ops (see
9+
* `../../src/migration/eql-bundle-v3.ts` `withRuntimeEqlSqlPackage`), so bumping
10+
* the pinned `@cipherstash/eql` needs no re-emit of this ~1.7 MB `ops.json`.
11+
* The bundle creates the 40 `public.eql_v3_*` storage domains, the `eql_v3`
12+
* operator-function schema (`eql_v3.eq`, `eql_v3.ord_term`, …), the
1213
* `eql_v3.query_*` operand domains, and the `eql_v3_internal` helper schema.
1314
*
1415
* This is the SECOND migration in the cipherstash contract space, and

packages/prisma-next/src/exports/control.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,12 @@ import {
6666
cipherstashStringCodecHooks,
6767
} from '../migration/cipherstash-codec'
6868
import { cipherstashV3CodecControlHooks } from '../migration/cipherstash-codec-v3'
69-
import { withRuntimeEqlSql } from '../migration/eql-bundle-v3'
69+
import { withRuntimeEqlSqlPackage } from '../migration/eql-bundle-v3'
70+
71+
const v3BaselineRuntimePackage = withRuntimeEqlSqlPackage(
72+
v3BaselineMetadata,
73+
v3BaselineOps,
74+
)
7075

7176
const cipherstashContractSpace = contractSpaceFromJson<Contract<SqlStorage>>({
7277
contractJson,
@@ -86,13 +91,13 @@ const cipherstashContractSpace = contractSpaceFromJson<Contract<SqlStorage>>({
8691
// `remove_search_config` ops.
8792
{
8893
dirName: CIPHERSTASH_V3_BASELINE_MIGRATION_NAME,
89-
metadata: v3BaselineMetadata,
94+
metadata: v3BaselineRuntimePackage.metadata,
9095
// The committed `ops.json` carries a placeholder in place of the ~1.7 MB
9196
// install SQL; inject it here from the installed `@cipherstash/eql` so
92-
// bumping the pinned EQL version needs no re-emit of the migration. Safe
93-
// because this is an invariant-only self-edge — the SQL never touches the
94-
// contract hash and `contractSpaceFromJson` does not integrity-check ops.
95-
ops: withRuntimeEqlSql(v3BaselineOps),
97+
// bumping the pinned EQL version needs no re-emit of the migration. The
98+
// helper also recomputes the content-addressed migration hash from the
99+
// injected ops so the descriptor can be materialised and verified.
100+
ops: v3BaselineRuntimePackage.ops,
96101
},
97102
],
98103
headRef,

packages/prisma-next/src/migration/eql-bundle-v3.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@
3131
* invariant.
3232
*/
3333
import { readInstallSql, releaseManifest } from '@cipherstash/eql/sql'
34+
import type {
35+
MigrationOperationClass,
36+
MigrationPlanOperation,
37+
} from '@prisma-next/framework-components/control'
38+
import { computeMigrationHash } from '@prisma-next/migration-tools/hash'
39+
import type { MigrationMetadata } from '@prisma-next/migration-tools/metadata'
3440

3541
// Re-exported for the live-test helpers, which read the same install SQL to set
3642
// up their databases (`test/live/helpers/eql-v3.ts`, `migration-apply-live-pg`).
@@ -67,6 +73,27 @@ function readV3InstallSql(): string {
6773

6874
type ExecuteStep = { readonly sql?: unknown }
6975
type OpLike = { readonly execute?: ReadonlyArray<ExecuteStep> }
76+
type HashableOpLike = OpLike & {
77+
readonly id: string
78+
readonly label: string
79+
readonly operationClass: string
80+
readonly invariantId?: string
81+
}
82+
type RuntimeMigrationMetadata<TMetadata extends MigrationMetadata> = Omit<
83+
TMetadata,
84+
'migrationHash'
85+
> & { readonly migrationHash: string }
86+
87+
function isMigrationOperationClass(
88+
value: string,
89+
): value is MigrationOperationClass {
90+
return (
91+
value === 'additive' ||
92+
value === 'widening' ||
93+
value === 'destructive' ||
94+
value === 'data'
95+
)
96+
}
7097

7198
/**
7299
* Return `ops` with every placeholder install-SQL step ({@link
@@ -103,3 +130,41 @@ export function withRuntimeEqlSql<T extends OpLike>(ops: readonly T[]): T[] {
103130
: op,
104131
)
105132
}
133+
134+
/**
135+
* Build the complete runtime migration package payload atomically. Replacing
136+
* the sentinel changes the content-addressed identity of the migration, so the
137+
* metadata hash MUST be recomputed from the injected operations before the
138+
* descriptor can materialise the package into a user's migration directory.
139+
* Keeping both values behind one helper prevents callers from updating the ops
140+
* while accidentally retaining the sentinel-derived hash.
141+
*/
142+
export function withRuntimeEqlSqlPackage<
143+
TMetadata extends MigrationMetadata,
144+
TOp extends HashableOpLike,
145+
>(
146+
metadata: TMetadata,
147+
ops: readonly TOp[],
148+
): {
149+
readonly metadata: RuntimeMigrationMetadata<TMetadata>
150+
readonly ops: TOp[]
151+
} {
152+
const runtimeOps = withRuntimeEqlSql(ops)
153+
const hashOps = runtimeOps.map((op): MigrationPlanOperation => {
154+
if (!isMigrationOperationClass(op.operationClass)) {
155+
throw new Error(
156+
`withRuntimeEqlSqlPackage: invalid migration operation class ${JSON.stringify(op.operationClass)}`,
157+
)
158+
}
159+
// Preserve the target-specific fields consumed by canonical JSON hashing
160+
// while narrowing the emitted JSON's widened operationClass string.
161+
return { ...op, operationClass: op.operationClass }
162+
})
163+
return {
164+
metadata: {
165+
...metadata,
166+
migrationHash: computeMigrationHash(metadata, hashOps),
167+
},
168+
ops: runtimeOps,
169+
}
170+
}

packages/prisma-next/test/live/migration-apply-live-pg.test.ts

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,24 @@
11
/**
22
* Live-PG apply of the v3 baseline migration bundle.
33
*
4-
* `installEqlV3IfNeeded` applies `readInstallSql()` — and this suite
5-
* pins that those are byte-for-byte the SQL baked into the shipped
6-
* migration's `ops.json` (`migrations/20260601T0100_install_eql_v3_bundle`),
7-
* so a green run here IS a green apply of the customer-facing
4+
* `installEqlV3IfNeeded` applies `readInstallSql()` — and this suite pins that
5+
* those are byte-for-byte the SQL injected into the shipped control descriptor
6+
* at runtime, so a green run here IS a green apply of the customer-facing
87
* migration op. After install it verifies the observable contract: the
9-
* `public.eql_v3_*` storage domains and the `eql_v3` operator schema
10-
* exist, the op carries the `cipherstash:install-eql-v3-bundle-v1`
11-
* invariant, the op's own postchecks hold against the live database,
12-
* and no v2-style `add_search_config` was executed (v3 needs no
13-
* per-column search configuration).
8+
* `public.eql_v3_*` storage domains and the `eql_v3` operator schema exist, the
9+
* op carries the `cipherstash:install-eql-v3-bundle-v1` invariant, the op's own
10+
* postchecks hold against the live database, and no v2-style
11+
* `add_search_config` was executed (v3 needs no per-column search configuration).
1412
*/
1513

1614
import 'dotenv/config'
17-
import { readFileSync } from 'node:fs'
18-
import { fileURLToPath } from 'node:url'
19-
import { dirname, join } from 'pathe'
2015
import type postgres from 'postgres'
2116
import { afterAll, beforeAll, expect, it } from 'vitest'
22-
import { CIPHERSTASH_V3_INVARIANTS } from '../../src/extension-metadata/constants-v3'
17+
import cipherstashDescriptor from '../../src/exports/control'
18+
import {
19+
CIPHERSTASH_V3_BASELINE_MIGRATION_NAME,
20+
CIPHERSTASH_V3_INVARIANTS,
21+
} from '../../src/extension-metadata/constants-v3'
2322
import {
2423
readInstallSql,
2524
releaseManifest,
@@ -28,12 +27,6 @@ import { installEqlV3IfNeeded, uninstallEqlV3 } from './helpers/eql-v3'
2827
import { liveConnection } from './helpers/harness'
2928
import { describeLivePg } from './helpers/live-gate'
3029

31-
const MIGRATION_DIR = join(
32-
dirname(dirname(dirname(fileURLToPath(import.meta.url)))),
33-
'migrations',
34-
'20260601T0100_install_eql_v3_bundle',
35-
)
36-
3730
interface MigrationOp {
3831
readonly id: string
3932
readonly invariantId: string
@@ -48,14 +41,14 @@ interface MigrationOp {
4841
}>
4942
}
5043

51-
function readOpsFixture(): MigrationOp {
52-
const ops = JSON.parse(
53-
readFileSync(join(MIGRATION_DIR, 'ops.json'), 'utf8'),
54-
) as MigrationOp[]
55-
const op = ops[0]
56-
if (ops.length !== 1 || !op) {
44+
function readRuntimeDescriptorOp(): MigrationOp {
45+
const migration = cipherstashDescriptor.contractSpace?.migrations.find(
46+
({ dirName }) => dirName === CIPHERSTASH_V3_BASELINE_MIGRATION_NAME,
47+
)
48+
const op = migration?.ops[0] as MigrationOp | undefined
49+
if (migration?.ops.length !== 1 || !op) {
5750
throw new Error(
58-
`expected exactly one op in the v3 baseline migration, got ${ops.length}`,
51+
`expected exactly one op in the v3 baseline migration, got ${migration?.ops.length ?? 0}`,
5952
)
6053
}
6154
return op
@@ -79,14 +72,14 @@ describeLivePg('v3 baseline migration bundle against live Postgres', () => {
7972
if (sql) await sql.end()
8073
})
8174

82-
it('the applied SQL is byte-for-byte the shipped migration op, carrying the v3 invariant', () => {
83-
const op = readOpsFixture()
75+
it('the applied SQL is byte-for-byte the runtime descriptor op, carrying the v3 invariant', () => {
76+
const op = readRuntimeDescriptorOp()
8477
expect(op.id).toBe('cipherstash.install-eql-v3-bundle')
8578
expect(op.invariantId).toBe(CIPHERSTASH_V3_INVARIANTS.installBundle)
8679
expect(op.operationClass).toBe('data')
8780
expect(op.execute).toHaveLength(1)
88-
// Byte identity: what installEqlV3IfNeeded just applied IS the
89-
// migration bundle a customer's `prisma-next migrate` applies.
81+
// Byte identity: what installEqlV3IfNeeded just applied IS the migration
82+
// bundle the control descriptor gives to `prisma-next migrate`.
9083
expect(op.execute[0]?.sql).toBe(readInstallSql())
9184
})
9285

@@ -117,7 +110,7 @@ describeLivePg('v3 baseline migration bundle against live Postgres', () => {
117110
}, 60_000)
118111

119112
it("the migration op's own postchecks hold against the live database", async () => {
120-
const op = readOpsFixture()
113+
const op = readRuntimeDescriptorOp()
121114
expect(op.postcheck.length).toBeGreaterThan(0)
122115
for (const check of op.postcheck) {
123116
const rows = (await sql.unsafe(check.sql)) as unknown as Array<

packages/prisma-next/test/v3/migration-v3.test.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@
88
* invariant-only: the v3 bundle creates `public.eql_v3_*` domains + `eql_v3.*`
99
* functions but no contract-space storage, so `from === to === <v2 baseline head>`.
1010
*/
11+
import { mkdtemp, rm } from 'node:fs/promises'
12+
import { tmpdir } from 'node:os'
13+
import { join } from 'node:path'
1114
import { readInstallSql, releaseManifest } from '@cipherstash/eql/sql'
15+
import {
16+
materialiseMigrationPackage,
17+
readMigrationPackage,
18+
} from '@prisma-next/migration-tools/io'
1219
import { describe, expect, it } from 'vitest'
1320
import v2Metadata from '../../migrations/20260601T0000_install_eql_bundle/migration.json' with {
1421
type: 'json',
@@ -31,6 +38,16 @@ import {
3138
withRuntimeEqlSql,
3239
} from '../../src/migration/eql-bundle-v3'
3340

41+
function runtimeV3Baseline() {
42+
const migration = cipherstashDescriptor.contractSpace?.migrations.find(
43+
({ dirName }) => dirName === CIPHERSTASH_V3_BASELINE_MIGRATION_NAME,
44+
)
45+
if (!migration) {
46+
throw new Error('runtime descriptor is missing the EQL v3 baseline')
47+
}
48+
return migration
49+
}
50+
3451
describe('v3 baseline migration (20260601T0100_install_eql_v3_bundle)', () => {
3552
it('installs under the v3 invariant with a single data-class rawSql op', () => {
3653
expect(v3Ops).toHaveLength(1)
@@ -63,17 +80,39 @@ describe('v3 baseline migration (20260601T0100_install_eql_v3_bundle)', () => {
6380
it('injects readInstallSql() from @cipherstash/eql into the descriptor at build time', () => {
6481
// control.ts swaps the placeholder for the install SQL of the pinned
6582
// @cipherstash/eql, so the applied SQL always matches the resolved version.
66-
const v3Baseline = cipherstashDescriptor.contractSpace!.migrations[1]!
83+
const v3Baseline = runtimeV3Baseline()
6784
const op = (
6885
v3Baseline.ops as ReadonlyArray<{
6986
readonly id: string
7087
readonly execute?: ReadonlyArray<{ readonly sql: string }>
7188
}>
72-
).find((o) => o.id === 'cipherstash.install-eql-v3-bundle')!
89+
).find((o) => o.id === 'cipherstash.install-eql-v3-bundle')
90+
if (!op) throw new Error('runtime descriptor is missing the EQL v3 op')
7391
expect(op.execute?.[0]?.sql).toBe(readInstallSql())
7492
expect(op.execute?.[0]?.sql).toContain('EQL v3 schema creation')
7593
})
7694

95+
it('materialises the runtime descriptor package and verifies it on read', async () => {
96+
// Round-trip property: the exact package Prisma Next receives from the
97+
// descriptor must survive its canonical disk writer + integrity-checking
98+
// reader. This pins the migration hash to the injected SQL, not the
99+
// sentinel committed in the maintainer artefact.
100+
const v3Baseline = runtimeV3Baseline()
101+
expect(v3Baseline.metadata.migrationHash).not.toBe(v3Metadata.migrationHash)
102+
103+
const root = await mkdtemp(join(tmpdir(), 'prisma-next-eql-v3-'))
104+
try {
105+
await materialiseMigrationPackage(root, v3Baseline)
106+
const reloaded = await readMigrationPackage(
107+
join(root, v3Baseline.dirName),
108+
)
109+
expect(reloaded.metadata).toEqual(v3Baseline.metadata)
110+
expect(reloaded.ops).toEqual(v3Baseline.ops)
111+
} finally {
112+
await rm(root, { recursive: true, force: true })
113+
}
114+
})
115+
77116
it('withRuntimeEqlSql throws if no op carries the sentinel (drift guard)', () => {
78117
// Matching on the sentinel string (not an op id) makes injection immune to
79118
// op-id/label drift; a missing sentinel means the emit source and injector

0 commit comments

Comments
 (0)