Skip to content
Merged
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
27 changes: 27 additions & 0 deletions .changeset/prisma-next-eql-runtime-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@cipherstash/prisma-next': minor
---

Source the EQL v3 install SQL from `@cipherstash/eql` at runtime instead of
baking it into the baseline migration.

`@cipherstash/eql` is now a runtime dependency, pinned exact (`3.0.0`) to match
the release `@cipherstash/stack` encodes its v3 domain **types** against — the
two must move together, so an EQL upgrade is a coordinated version bump, not a
float. The v3 baseline migration no longer embeds the ~1.7 MB install bundle in
its `ops.json`: the committed op carries a placeholder, and the extension
descriptor injects `readInstallSql()` from the installed `@cipherstash/eql` when
it is built, and recomputes the content-addressed migration hash from the
injected operations before Prisma Next materialises the package.

The win over baking: bumping the pinned `@cipherstash/eql` no longer requires
re-running the maintainer emit loop to regenerate a 1.7 MB `ops.json` — it is a
one-line version bump plus a rebuild. This mirrors how the `stash` CLI already
sources the v3 SQL.

No change to user-facing behaviour: EQL still installs as part of
`prisma-next migration apply`. Safe because the v3 baseline is an
invariant-only self-edge — the install SQL never contributes to the
contract-space hash. Injection matches the placeholder by value and fails loudly
if it is absent, so a drift between the emit source and the injector can never
silently ship an empty install.
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
"cipherstash:install-eql-v3-bundle-v1"
],
"createdAt": "2026-07-14T20:10:24.325Z",
"migrationHash": "sha256:d1ca5a987ab10eb85c2fe05700134b31f17f694d2d5b3df3969e902610b1d9cf"
"migrationHash": "sha256:55f261c28b9fe8feee21270a652d758a5c90ccbe7aac9cc79ac50662b956160b"
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
/**
* CipherStash v3 baseline migration — install the EQL v3 bundle.
*
* The install SQL is sourced at EMIT time from `@cipherstash/eql/sql`
* (see `../../src/migration/eql-bundle-v3.ts` — the same source the
* stack's `installEqlV3IfNeeded` uses) and baked into `ops.json`
* byte-for-byte. The bundle creates the 40 `public.eql_v3_*` storage
* domains, the `eql_v3` operator-function schema (`eql_v3.eq`,
* `eql_v3.ord_term`, …), the `eql_v3.query_*` operand domains, and the
* `eql_v3_internal` helper schema.
* The install SQL is NOT baked into `ops.json`. The committed op carries
* `RUNTIME_EQL_SQL_SENTINEL`; `src/exports/control.ts` injects
* `readInstallSql()` from the installed `@cipherstash/eql` at descriptor-build
* time and recomputes the migration hash from those runtime ops (see
* `../../src/migration/eql-bundle-v3.ts` `withRuntimeEqlSqlPackage`), so bumping
* the pinned `@cipherstash/eql` needs no re-emit of this ~1.7 MB `ops.json`.
* The bundle creates the 40 `public.eql_v3_*` storage domains, the `eql_v3`
* operator-function schema (`eql_v3.eq`, `eql_v3.ord_term`, …), the
* `eql_v3.query_*` operand domains, and the `eql_v3_internal` helper schema.
*
* This is the SECOND migration in the cipherstash contract space, and
* it is an **invariant-only edge**: the v3 bundle declares no
Expand All @@ -32,7 +34,7 @@ import {
} from '@prisma-next/target-postgres/migration'
import { CIPHERSTASH_V3_INVARIANTS } from '../../src/extension-metadata/constants-v3'
import {
readInstallSql,
RUNTIME_EQL_SQL_SENTINEL,
releaseManifest,
} from '../../src/migration/eql-bundle-v3'

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

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/prisma-next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"clean": "rm -rf dist coverage .tmp-output"
},
"dependencies": {
"@cipherstash/eql": "3.0.0",
"@cipherstash/stack": "workspace:*",
"@prisma-next/contract": "0.14.0",
"@prisma-next/family-sql": "0.14.0",
Expand All @@ -99,7 +100,6 @@
"arktype": "^2.2.3"
},
"devDependencies": {
"@cipherstash/eql": "3.0.0",
"@cipherstash/test-kit": "workspace:*",
"@prisma-next/adapter-postgres": "0.14.0",
"@prisma-next/cli": "0.14.0",
Expand Down
15 changes: 13 additions & 2 deletions packages/prisma-next/src/exports/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ import {
cipherstashStringCodecHooks,
} from '../migration/cipherstash-codec'
import { cipherstashV3CodecControlHooks } from '../migration/cipherstash-codec-v3'
import { withRuntimeEqlSqlPackage } from '../migration/eql-bundle-v3'

const v3BaselineRuntimePackage = withRuntimeEqlSqlPackage(
v3BaselineMetadata,
v3BaselineOps,
)

const cipherstashContractSpace = contractSpaceFromJson<Contract<SqlStorage>>({
contractJson,
Expand All @@ -85,8 +91,13 @@ const cipherstashContractSpace = contractSpaceFromJson<Contract<SqlStorage>>({
// `remove_search_config` ops.
{
dirName: CIPHERSTASH_V3_BASELINE_MIGRATION_NAME,
metadata: v3BaselineMetadata,
ops: v3BaselineOps,
metadata: v3BaselineRuntimePackage.metadata,
// The committed `ops.json` carries a placeholder in place of the ~1.7 MB
// install SQL; inject it here from the installed `@cipherstash/eql` so
// bumping the pinned EQL version needs no re-emit of the migration. The
// helper also recomputes the content-addressed migration hash from the
// injected ops so the descriptor can be materialised and verified.
ops: v3BaselineRuntimePackage.ops,
},
],
headRef,
Expand Down
183 changes: 166 additions & 17 deletions packages/prisma-next/src/migration/eql-bundle-v3.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,170 @@
/**
* CipherStash EQL v3 install SQL, sourced from `@cipherstash/eql/sql` — the
* same source the stack install script (`packages/stack/scripts/install-eql-v3.ts`
* / `installEqlV3IfNeeded`) uses. `readInstallSql()` returns the full bundle
* that creates the `public.eql_v3_*` domains and the `eql_v3.*` operator
* functions; `releaseManifest.eqlVersion` identifies the pinned release.
* CipherStash EQL v3 install SQL, sourced from `@cipherstash/eql/sql` at
* RUNTIME — the same source the stack install script (`installEqlV3IfNeeded`)
* and the CLI installer (`readV3InstallSql`) use. `readInstallSql()` returns
* the full bundle that creates the `public.eql_v3_*` domains and the `eql_v3.*`
* operator functions; `releaseManifest.eqlVersion` identifies the pinned
* release.
*
* The SQL is read at migration EMIT time and baked into the v3 baseline
* migration's `ops.json`, so `@cipherstash/eql` stays a devDependency of this
* package (not a runtime dependency of the published artefact). Bumping the
* pinned `@cipherstash/eql` version requires re-running the maintainer emit
* loop for `migrations/20260601T0100_install_eql_v3_bundle/` (see that
* migration's header comment) and re-running `test/descriptor.test.ts` /
* `test/v3/migration-v3.test.ts`.
* ## Runtime-sourced, NOT baked
*
* Hash impact: like the v2 bundle (see `./eql-bundle.ts`), the SQL lives in
* the migration op's `execute[]`, NOT in `contract.json` — the v3 bundle
* declares no contract-space storage, so the edge is invariant-only and
* `headRef.hash` does not move.
* The v3 baseline migration does **not** embed the ~1.7 MB install SQL in its
* `ops.json`. The committed op carries {@link RUNTIME_EQL_SQL_SENTINEL}, and
* `src/exports/control.ts` swaps in `readInstallSql()` from the installed
* `@cipherstash/eql` when it builds the descriptor (see {@link withRuntimeEqlSql}).
* `@cipherstash/eql` is therefore a runtime dependency, pinned exact (`3.0.0`)
* to match the version `@cipherstash/stack` encodes its v3 domain TYPES against
* — the two must move together, so this is a coordinated bump, not a float.
*
* The win over baking: upgrading the pinned `@cipherstash/eql` no longer means
* re-emitting the 1.7 MB `ops.json` (which coupled every EQL bump to a manual
* re-emit loop) — it is a one-line version bump plus a rebuild.
*
* Why this is sound: the v3 baseline is an INVARIANT-ONLY self-edge
* (`from === to`; the bundle declares no contract-space storage — unlike the v2
* bundle's `eql_v2_configuration`). The install SQL therefore never contributes
* to the contract-space hash, and `contractSpaceFromJson` passes the ops through
* with no integrity check on their contents (`verifyMigrationHash` runs only on
* the disk read path for the user's own repo, never on the in-memory extension
* descriptor). Swapping the SQL at descriptor-construction time is invisible to
* the planner, which routes purely on the `cipherstash:install-eql-v3-bundle-v1`
* invariant.
*/
import { readInstallSql, releaseManifest } from '@cipherstash/eql/sql'
import type {
MigrationOperationClass,
MigrationPlanOperation,
} from '@prisma-next/framework-components/control'
import { computeMigrationHash } from '@prisma-next/migration-tools/hash'
import type { MigrationMetadata } from '@prisma-next/migration-tools/metadata'

// Re-exported for the live-test helpers, which read the same install SQL to set
// up their databases (`test/live/helpers/eql-v3.ts`, `migration-apply-live-pg`).
export { readInstallSql, releaseManifest }

/**
* Placeholder the committed v3 baseline op carries in `execute[].sql` in place
* of the baked install SQL. {@link withRuntimeEqlSql} swaps it for the real
* bundle at descriptor-build time; if it ever reached a database directly it is
* an inert SQL comment. It is ALSO the join key the injector matches on — the
* exact string the committed `ops.json` carries — so injection targets exactly
* the placeholder and is immune to op-id / label drift.
*/
export const RUNTIME_EQL_SQL_SENTINEL =
'-- EQL v3 install SQL is injected at runtime from @cipherstash/eql — see packages/prisma-next/src/migration/eql-bundle-v3.ts'

/**
* Read the EQL v3 install SQL from the installed `@cipherstash/eql`, turning a
* missing/broken package into an actionable error instead of a raw
* `readFileSync` failure at descriptor-import time (this runs whenever any
* control-plane consumer imports the descriptor). Mirrors the CLI's
* `readV3InstallSql`.
*/
function readV3InstallSql(): string {
try {
return readInstallSql()
} catch (cause) {
throw new Error(
'Failed to read the EQL v3 install SQL from `@cipherstash/eql`. Reinstall dependencies — the package ships the bundle in `dist/sql/`.',
{ cause },
)
}
}

type ExecuteStep = { readonly sql?: unknown }
type OpLike = { readonly execute?: ReadonlyArray<ExecuteStep> }
type HashableOpLike = OpLike & {
readonly id: string
readonly label: string
readonly operationClass: string
readonly invariantId?: string
}
type RuntimeMigrationMetadata<TMetadata extends MigrationMetadata> = Omit<
TMetadata,
'migrationHash'
> & { readonly migrationHash: string }

function isMigrationOperationClass(
value: string,
): value is MigrationOperationClass {
return (
value === 'additive' ||
value === 'widening' ||
value === 'destructive' ||
value === 'data'
)
}

/**
* Return `ops` with every placeholder install-SQL step ({@link
* RUNTIME_EQL_SQL_SENTINEL}) replaced by the install SQL from the installed
* `@cipherstash/eql`. Non-placeholder steps and every other op field are
* preserved as-is (only the matched step's `sql` is rewritten), so the swap is
* non-lossy. Called by the descriptor in `control.ts` so the applied SQL always
* matches the resolved `@cipherstash/eql` version, not a baked snapshot.
*
* Throws if NO placeholder is present: the committed v3 baseline op MUST carry
* the sentinel, so a missing match means the emit source and this injector have
* diverged (e.g. real SQL was baked back in, or the sentinel string changed) —
* fail loudly at descriptor build rather than silently apply the inert comment
* as the "install" and leave the database with no EQL.
*/
export function withRuntimeEqlSql<T extends OpLike>(ops: readonly T[]): T[] {
const hasPlaceholder = ops.some((op) =>
op.execute?.some((step) => step.sql === RUNTIME_EQL_SQL_SENTINEL),
)
if (!hasPlaceholder) {
throw new Error(
'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.',
)
}
const sql = readV3InstallSql()
return ops.map((op) =>
op.execute?.some((step) => step.sql === RUNTIME_EQL_SQL_SENTINEL)
? {
...op,
execute: op.execute.map((step) =>
step.sql === RUNTIME_EQL_SQL_SENTINEL ? { ...step, sql } : step,
),
}
: op,
)
}

/**
* Build the complete runtime migration package payload atomically. Replacing
* the sentinel changes the content-addressed identity of the migration, so the
* metadata hash MUST be recomputed from the injected operations before the
* descriptor can materialise the package into a user's migration directory.
* Keeping both values behind one helper prevents callers from updating the ops
* while accidentally retaining the sentinel-derived hash.
*/
export { readInstallSql, releaseManifest } from '@cipherstash/eql/sql'
export function withRuntimeEqlSqlPackage<
TMetadata extends MigrationMetadata,
TOp extends HashableOpLike,
>(
metadata: TMetadata,
ops: readonly TOp[],
): {
readonly metadata: RuntimeMigrationMetadata<TMetadata>
readonly ops: TOp[]
} {
const runtimeOps = withRuntimeEqlSql(ops)
const hashOps = runtimeOps.map((op): MigrationPlanOperation => {
if (!isMigrationOperationClass(op.operationClass)) {
throw new Error(
`withRuntimeEqlSqlPackage: invalid migration operation class ${JSON.stringify(op.operationClass)}`,
)
}
// Preserve the target-specific fields consumed by canonical JSON hashing
// while narrowing the emitted JSON's widened operationClass string.
return { ...op, operationClass: op.operationClass }
})
return {
metadata: {
...metadata,
migrationHash: computeMigrationHash(metadata, hashOps),
},
ops: runtimeOps,
}
}
Loading
Loading