Skip to content

Commit a2f80ea

Browse files
authored
feat(prisma-next): source EQL v3 install SQL from @cipherstash/eql at runtime (#694)
* feat(prisma-next): source EQL v3 install SQL from @cipherstash/eql at runtime Baking the ~1.7 MB install bundle into the v3 baseline migration's ops.json coupled every @cipherstash/eql upgrade to a prisma-next release: a patch or minor bump meant re-emitting the migration and cutting a new adapter version. Now @cipherstash/eql is a runtime dependency (^3.0.0). The committed v3 baseline op carries a sentinel placeholder; the descriptor (control.ts) injects readInstallSql() from the installed @cipherstash/eql at build time via withRuntimeEqlSql(). A patch/minor now flows through npm resolution with no re-emit and no adapter release — mirroring how the stash CLI already sources v3 SQL (#692). Safe because the v3 baseline is an invariant-only self-edge (from === to): the install SQL never contributes to the contract-space hash, and contractSpaceFromJson passes ops through with no integrity check on their contents (verifyMigrationHash runs only on the disk read path for the user's repo, never on the in-memory extension descriptor). The apply planner routes on the cipherstash:install-eql-v3-bundle-v1 invariant, not SQL content. ops.json drops from 1,717,297 → 1,219 bytes. No user-facing change — EQL still installs via `prisma-next migration apply`. 600 tests green; build + biome clean. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w * fix(prisma-next): pin @cipherstash/eql exact, sentinel-match + fail-fast injection Code-review follow-ups on the runtime EQL-SQL sourcing: - Pin @cipherstash/eql exact (3.0.0), not ^3.0.0. prisma-next's v3 domain TYPES come from @cipherstash/stack (which encodes them against a specific EQL release); floating the install SQL via a caret would let the applied schema drift from the pinned type surface, and diverge from the exact pins in @cipherstash/stack and the stash CLI (both install into the same DB). Exact keeps types and SQL coherent and still removes the 1.7 MB re-emit coupling — the actual win. Reframed the "flows through" wording to "no re-emit" throughout. - withRuntimeEqlSql now matches the placeholder by its SENTINEL value (not an op id) and THROWS if no placeholder is present. Matching an op id required three hardcoded copies to agree; a drift silently applied the inert sentinel comment as the "install" (empty EQL, caught only by an indirect postcheck, or not at all when postchecks are disabled). Sentinel-match is immune to op-id drift and fails loudly at descriptor build. Dropped the V3_INSTALL_OP_ID const. - The SQL read is wrapped (readV3InstallSql) with an actionable error, since it now runs at control.ts import for every control-plane consumer (was a raw readFileSync throw). Mirrors the CLI's guard. - Non-lossy swap: only the sentinel step's `sql` is rewritten; sibling execute steps and extra fields are preserved. Added a drift-guard + non-lossy test. 601 tests green; build + biome + frozen-lockfile clean. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w * fix(prisma-next): restore readInstallSql re-export + guard contractSpace (typecheck) The prior commit removed `readInstallSql` from eql-bundle-v3's re-export, but the live-test helpers (`test/live/helpers/eql-v3.ts`, `migration-apply-live-pg.test.ts`) import it from there — a TS2459 the vitest run misses (live suites are skipped) but the `typecheck` CI step catches. Re-export it. Also non-null-guard `contractSpace` in the new descriptor-injection test (`contractSpace!`), matching descriptor.test.ts. typecheck exits 0; 601 tests green. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w * fix(prisma-next): attest runtime EQL migration ops
1 parent d20e48a commit a2f80ea

10 files changed

Lines changed: 350 additions & 72 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@cipherstash/prisma-next': minor
3+
---
4+
5+
Source the EQL v3 install SQL from `@cipherstash/eql` at runtime instead of
6+
baking it into the baseline migration.
7+
8+
`@cipherstash/eql` is now a runtime dependency, pinned exact (`3.0.0`) to match
9+
the release `@cipherstash/stack` encodes its v3 domain **types** against — the
10+
two must move together, so an EQL upgrade is a coordinated version bump, not a
11+
float. The v3 baseline migration no longer embeds the ~1.7 MB install bundle in
12+
its `ops.json`: the committed op carries a placeholder, and the extension
13+
descriptor injects `readInstallSql()` from the installed `@cipherstash/eql` when
14+
it is built, and recomputes the content-addressed migration hash from the
15+
injected operations before Prisma Next materialises the package.
16+
17+
The win over baking: bumping the pinned `@cipherstash/eql` no longer requires
18+
re-running the maintainer emit loop to regenerate a 1.7 MB `ops.json` — it is a
19+
one-line version bump plus a rebuild. This mirrors how the `stash` CLI already
20+
sources the v3 SQL.
21+
22+
No change to user-facing behaviour: EQL still installs as part of
23+
`prisma-next migration apply`. Safe because the v3 baseline is an
24+
invariant-only self-edge — the install SQL never contributes to the
25+
contract-space hash. Injection matches the placeholder by value and fails loudly
26+
if it is absent, so a drift between the emit source and the injector can never
27+
silently ship an empty install.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@
55
"cipherstash:install-eql-v3-bundle-v1"
66
],
77
"createdAt": "2026-07-14T20:10:24.325Z",
8-
"migrationHash": "sha256:d1ca5a987ab10eb85c2fe05700134b31f17f694d2d5b3df3969e902610b1d9cf"
8+
"migrationHash": "sha256:55f261c28b9fe8feee21270a652d758a5c90ccbe7aac9cc79ac50662b956160b"
99
}

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

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22
/**
33
* CipherStash v3 baseline migration — install the EQL v3 bundle.
44
*
5-
* The install SQL is sourced at EMIT time from `@cipherstash/eql/sql`
6-
* (see `../../src/migration/eql-bundle-v3.ts` — the same source the
7-
* stack's `installEqlV3IfNeeded` uses) and baked into `ops.json`
8-
* byte-for-byte. The bundle creates the 40 `public.eql_v3_*` storage
9-
* domains, the `eql_v3` operator-function schema (`eql_v3.eq`,
10-
* `eql_v3.ord_term`, …), the `eql_v3.query_*` operand domains, and the
11-
* `eql_v3_internal` helper schema.
5+
* The install SQL is NOT baked into `ops.json`. The committed op carries
6+
* `RUNTIME_EQL_SQL_SENTINEL`; `src/exports/control.ts` injects
7+
* `readInstallSql()` from the installed `@cipherstash/eql` at descriptor-build
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
13+
* `eql_v3.query_*` operand domains, and the `eql_v3_internal` helper schema.
1214
*
1315
* This is the SECOND migration in the cipherstash contract space, and
1416
* it is an **invariant-only edge**: the v3 bundle declares no
@@ -32,7 +34,7 @@ import {
3234
} from '@prisma-next/target-postgres/migration'
3335
import { CIPHERSTASH_V3_INVARIANTS } from '../../src/extension-metadata/constants-v3'
3436
import {
35-
readInstallSql,
37+
RUNTIME_EQL_SQL_SENTINEL,
3638
releaseManifest,
3739
} from '../../src/migration/eql-bundle-v3'
3840

@@ -67,7 +69,15 @@ export default class M extends Migration {
6769
invariantId: CIPHERSTASH_V3_INVARIANTS.installBundle,
6870
target: { id: 'postgres' },
6971
precheck: [],
70-
execute: [{ description: INSTALL_LABEL, sql: readInstallSql() }],
72+
// Placeholder only — the real install SQL is injected at descriptor
73+
// build time from the installed `@cipherstash/eql` (see
74+
// `../../src/migration/eql-bundle-v3.ts` `withRuntimeEqlSql`), so the
75+
// ~1.7 MB bundle is NOT baked into `ops.json` and bumping the pinned
76+
// `@cipherstash/eql` needs no re-emit. Safe because this is an
77+
// invariant-only self-edge: the SQL never moves the contract hash.
78+
execute: [
79+
{ description: INSTALL_LABEL, sql: RUNTIME_EQL_SQL_SENTINEL },
80+
],
7181
postcheck: [
7282
{
7383
description: 'verify the "eql_v3" operator schema exists',

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

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

packages/prisma-next/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
"clean": "rm -rf dist coverage .tmp-output"
8686
},
8787
"dependencies": {
88+
"@cipherstash/eql": "3.0.0",
8889
"@cipherstash/stack": "workspace:*",
8990
"@prisma-next/contract": "0.14.0",
9091
"@prisma-next/family-sql": "0.14.0",
@@ -99,7 +100,6 @@
99100
"arktype": "^2.2.3"
100101
},
101102
"devDependencies": {
102-
"@cipherstash/eql": "3.0.0",
103103
"@cipherstash/test-kit": "workspace:*",
104104
"@prisma-next/adapter-postgres": "0.14.0",
105105
"@prisma-next/cli": "0.14.0",

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

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

7076
const cipherstashContractSpace = contractSpaceFromJson<Contract<SqlStorage>>({
7177
contractJson,
@@ -85,8 +91,13 @@ const cipherstashContractSpace = contractSpaceFromJson<Contract<SqlStorage>>({
8591
// `remove_search_config` ops.
8692
{
8793
dirName: CIPHERSTASH_V3_BASELINE_MIGRATION_NAME,
88-
metadata: v3BaselineMetadata,
89-
ops: v3BaselineOps,
94+
metadata: v3BaselineRuntimePackage.metadata,
95+
// The committed `ops.json` carries a placeholder in place of the ~1.7 MB
96+
// install SQL; inject it here from the installed `@cipherstash/eql` so
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,
90101
},
91102
],
92103
headRef,
Lines changed: 166 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,170 @@
11
/**
2-
* CipherStash EQL v3 install SQL, sourced from `@cipherstash/eql/sql` — the
3-
* same source the stack install script (`packages/stack/scripts/install-eql-v3.ts`
4-
* / `installEqlV3IfNeeded`) uses. `readInstallSql()` returns the full bundle
5-
* that creates the `public.eql_v3_*` domains and the `eql_v3.*` operator
6-
* functions; `releaseManifest.eqlVersion` identifies the pinned release.
2+
* CipherStash EQL v3 install SQL, sourced from `@cipherstash/eql/sql` at
3+
* RUNTIME — the same source the stack install script (`installEqlV3IfNeeded`)
4+
* and the CLI installer (`readV3InstallSql`) use. `readInstallSql()` returns
5+
* the full bundle that creates the `public.eql_v3_*` domains and the `eql_v3.*`
6+
* operator functions; `releaseManifest.eqlVersion` identifies the pinned
7+
* release.
78
*
8-
* The SQL is read at migration EMIT time and baked into the v3 baseline
9-
* migration's `ops.json`, so `@cipherstash/eql` stays a devDependency of this
10-
* package (not a runtime dependency of the published artefact). Bumping the
11-
* pinned `@cipherstash/eql` version requires re-running the maintainer emit
12-
* loop for `migrations/20260601T0100_install_eql_v3_bundle/` (see that
13-
* migration's header comment) and re-running `test/descriptor.test.ts` /
14-
* `test/v3/migration-v3.test.ts`.
9+
* ## Runtime-sourced, NOT baked
1510
*
16-
* Hash impact: like the v2 bundle (see `./eql-bundle.ts`), the SQL lives in
17-
* the migration op's `execute[]`, NOT in `contract.json` — the v3 bundle
18-
* declares no contract-space storage, so the edge is invariant-only and
19-
* `headRef.hash` does not move.
11+
* The v3 baseline migration does **not** embed the ~1.7 MB install SQL in its
12+
* `ops.json`. The committed op carries {@link RUNTIME_EQL_SQL_SENTINEL}, and
13+
* `src/exports/control.ts` swaps in `readInstallSql()` from the installed
14+
* `@cipherstash/eql` when it builds the descriptor (see {@link withRuntimeEqlSql}).
15+
* `@cipherstash/eql` is therefore a runtime dependency, pinned exact (`3.0.0`)
16+
* to match the version `@cipherstash/stack` encodes its v3 domain TYPES against
17+
* — the two must move together, so this is a coordinated bump, not a float.
18+
*
19+
* The win over baking: upgrading the pinned `@cipherstash/eql` no longer means
20+
* re-emitting the 1.7 MB `ops.json` (which coupled every EQL bump to a manual
21+
* re-emit loop) — it is a one-line version bump plus a rebuild.
22+
*
23+
* Why this is sound: the v3 baseline is an INVARIANT-ONLY self-edge
24+
* (`from === to`; the bundle declares no contract-space storage — unlike the v2
25+
* bundle's `eql_v2_configuration`). The install SQL therefore never contributes
26+
* to the contract-space hash, and `contractSpaceFromJson` passes the ops through
27+
* with no integrity check on their contents (`verifyMigrationHash` runs only on
28+
* the disk read path for the user's own repo, never on the in-memory extension
29+
* descriptor). Swapping the SQL at descriptor-construction time is invisible to
30+
* the planner, which routes purely on the `cipherstash:install-eql-v3-bundle-v1`
31+
* invariant.
32+
*/
33+
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'
40+
41+
// Re-exported for the live-test helpers, which read the same install SQL to set
42+
// up their databases (`test/live/helpers/eql-v3.ts`, `migration-apply-live-pg`).
43+
export { readInstallSql, releaseManifest }
44+
45+
/**
46+
* Placeholder the committed v3 baseline op carries in `execute[].sql` in place
47+
* of the baked install SQL. {@link withRuntimeEqlSql} swaps it for the real
48+
* bundle at descriptor-build time; if it ever reached a database directly it is
49+
* an inert SQL comment. It is ALSO the join key the injector matches on — the
50+
* exact string the committed `ops.json` carries — so injection targets exactly
51+
* the placeholder and is immune to op-id / label drift.
52+
*/
53+
export const RUNTIME_EQL_SQL_SENTINEL =
54+
'-- EQL v3 install SQL is injected at runtime from @cipherstash/eql — see packages/prisma-next/src/migration/eql-bundle-v3.ts'
55+
56+
/**
57+
* Read the EQL v3 install SQL from the installed `@cipherstash/eql`, turning a
58+
* missing/broken package into an actionable error instead of a raw
59+
* `readFileSync` failure at descriptor-import time (this runs whenever any
60+
* control-plane consumer imports the descriptor). Mirrors the CLI's
61+
* `readV3InstallSql`.
62+
*/
63+
function readV3InstallSql(): string {
64+
try {
65+
return readInstallSql()
66+
} catch (cause) {
67+
throw new Error(
68+
'Failed to read the EQL v3 install SQL from `@cipherstash/eql`. Reinstall dependencies — the package ships the bundle in `dist/sql/`.',
69+
{ cause },
70+
)
71+
}
72+
}
73+
74+
type ExecuteStep = { readonly sql?: unknown }
75+
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+
}
97+
98+
/**
99+
* Return `ops` with every placeholder install-SQL step ({@link
100+
* RUNTIME_EQL_SQL_SENTINEL}) replaced by the install SQL from the installed
101+
* `@cipherstash/eql`. Non-placeholder steps and every other op field are
102+
* preserved as-is (only the matched step's `sql` is rewritten), so the swap is
103+
* non-lossy. Called by the descriptor in `control.ts` so the applied SQL always
104+
* matches the resolved `@cipherstash/eql` version, not a baked snapshot.
105+
*
106+
* Throws if NO placeholder is present: the committed v3 baseline op MUST carry
107+
* the sentinel, so a missing match means the emit source and this injector have
108+
* diverged (e.g. real SQL was baked back in, or the sentinel string changed) —
109+
* fail loudly at descriptor build rather than silently apply the inert comment
110+
* as the "install" and leave the database with no EQL.
111+
*/
112+
export function withRuntimeEqlSql<T extends OpLike>(ops: readonly T[]): T[] {
113+
const hasPlaceholder = ops.some((op) =>
114+
op.execute?.some((step) => step.sql === RUNTIME_EQL_SQL_SENTINEL),
115+
)
116+
if (!hasPlaceholder) {
117+
throw new Error(
118+
'withRuntimeEqlSql: no op carried RUNTIME_EQL_SQL_SENTINEL — the v3 baseline ops.json must carry the placeholder for runtime EQL-SQL injection. The emit source and this injector have diverged.',
119+
)
120+
}
121+
const sql = readV3InstallSql()
122+
return ops.map((op) =>
123+
op.execute?.some((step) => step.sql === RUNTIME_EQL_SQL_SENTINEL)
124+
? {
125+
...op,
126+
execute: op.execute.map((step) =>
127+
step.sql === RUNTIME_EQL_SQL_SENTINEL ? { ...step, sql } : step,
128+
),
129+
}
130+
: op,
131+
)
132+
}
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.
20141
*/
21-
export { readInstallSql, releaseManifest } from '@cipherstash/eql/sql'
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+
}

0 commit comments

Comments
 (0)