Skip to content

Commit 1209425

Browse files
committed
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
1 parent e566f93 commit 1209425

7 files changed

Lines changed: 115 additions & 50 deletions

File tree

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

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,22 @@
55
Source the EQL v3 install SQL from `@cipherstash/eql` at runtime instead of
66
baking it into the baseline migration.
77

8-
`@cipherstash/eql` is now a runtime dependency (`^3.0.0`). The v3 baseline
9-
migration no longer embeds the ~1.7 MB install bundle in its `ops.json`; the
10-
committed op carries a placeholder, and the extension descriptor injects
11-
`readInstallSql()` from the installed `@cipherstash/eql` when it is built. As a
12-
result, a `@cipherstash/eql` patch or minor release flows through to
13-
`prisma-next migration apply` via normal dependency resolution — it no longer
14-
requires re-emitting the migration and cutting a new `@cipherstash/prisma-next`
15-
release. This mirrors how the `stash` CLI already sources the v3 SQL.
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.
15+
16+
The win over baking: bumping the pinned `@cipherstash/eql` no longer requires
17+
re-running the maintainer emit loop to regenerate a 1.7 MB `ops.json` — it is a
18+
one-line version bump plus a rebuild. This mirrors how the `stash` CLI already
19+
sources the v3 SQL.
1620

1721
No change to user-facing behaviour: EQL still installs as part of
1822
`prisma-next migration apply`. Safe because the v3 baseline is an
1923
invariant-only self-edge — the install SQL never contributes to the
20-
contract-space hash.
24+
contract-space hash. Injection matches the placeholder by value and fails loudly
25+
if it is absent, so a drift between the emit source and the injector can never
26+
silently ship an empty install.

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
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 an
9-
* EQL patch/minor flows through npm with no re-emit or re-release. The bundle
10-
* creates the 40 `public.eql_v3_*` storage domains, the `eql_v3`
11-
* operator-function schema (`eql_v3.eq`, `eql_v3.ord_term`, …), the
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
1212
* `eql_v3.query_*` operand domains, and the `eql_v3_internal` helper schema.
1313
*
1414
* This is the SECOND migration in the cipherstash contract space, and
@@ -71,8 +71,8 @@ export default class M extends Migration {
7171
// Placeholder only — the real install SQL is injected at descriptor
7272
// build time from the installed `@cipherstash/eql` (see
7373
// `../../src/migration/eql-bundle-v3.ts` `withRuntimeEqlSql`), so the
74-
// ~1.7 MB bundle is NOT baked into `ops.json` and an EQL patch/minor
75-
// needs no re-emit or re-release. Safe because this is an
74+
// ~1.7 MB bundle is NOT baked into `ops.json` and bumping the pinned
75+
// `@cipherstash/eql` needs no re-emit. Safe because this is an
7676
// invariant-only self-edge: the SQL never moves the contract hash.
7777
execute: [
7878
{ description: INSTALL_LABEL, sql: RUNTIME_EQL_SQL_SENTINEL },

packages/prisma-next/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@
8585
"clean": "rm -rf dist coverage .tmp-output"
8686
},
8787
"dependencies": {
88-
"@cipherstash/eql": "^3.0.0",
88+
"@cipherstash/eql": "3.0.0",
8989
"@cipherstash/stack": "workspace:*",
9090
"@prisma-next/contract": "0.14.0",
9191
"@prisma-next/family-sql": "0.14.0",

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ const cipherstashContractSpace = contractSpaceFromJson<Contract<SqlStorage>>({
8888
dirName: CIPHERSTASH_V3_BASELINE_MIGRATION_NAME,
8989
metadata: v3BaselineMetadata,
9090
// The committed `ops.json` carries a placeholder in place of the ~1.7 MB
91-
// install SQL; inject it here from the installed `@cipherstash/eql` so an
92-
// EQL patch/minor flows through without re-emitting the migration. Safe
91+
// 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
9393
// because this is an invariant-only self-edge — the SQL never touches the
9494
// contract hash and `contractSpaceFromJson` does not integrity-check ops.
9595
ops: withRuntimeEqlSql(v3BaselineOps),

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

Lines changed: 57 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,15 @@
1010
*
1111
* The v3 baseline migration does **not** embed the ~1.7 MB install SQL in its
1212
* `ops.json`. The committed op carries {@link RUNTIME_EQL_SQL_SENTINEL}, and
13-
* `src/exports/control.ts` swaps in `readInstallSql()` from the INSTALLED
13+
* `src/exports/control.ts` swaps in `readInstallSql()` from the installed
1414
* `@cipherstash/eql` when it builds the descriptor (see {@link withRuntimeEqlSql}).
15-
* So `@cipherstash/eql` is a runtime dependency, and a patch/minor bump flows
16-
* through npm resolution — no re-emit of the migration, no re-release of this
17-
* package. (Baking it in coupled every EQL upgrade to a prisma-next release.)
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.
1822
*
1923
* Why this is sound: the v3 baseline is an INVARIANT-ONLY self-edge
2024
* (`from === to`; the bundle declares no contract-space storage — unlike the v2
@@ -28,45 +32,71 @@
2832
*/
2933
import { readInstallSql, releaseManifest } from '@cipherstash/eql/sql'
3034

31-
export { readInstallSql, releaseManifest }
32-
33-
/** The install op's `id` in the v3 baseline `ops.json`. */
34-
export const V3_INSTALL_OP_ID = 'cipherstash.install-eql-v3-bundle'
35+
export { releaseManifest }
3536

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

44-
type OpLike = {
45-
readonly id?: unknown
46-
readonly execute?: ReadonlyArray<{
47-
readonly description?: unknown
48-
readonly sql?: unknown
49-
}>
48+
/**
49+
* Read the EQL v3 install SQL from the installed `@cipherstash/eql`, turning a
50+
* missing/broken package into an actionable error instead of a raw
51+
* `readFileSync` failure at descriptor-import time (this runs whenever any
52+
* control-plane consumer imports the descriptor). Mirrors the CLI's
53+
* `readV3InstallSql`.
54+
*/
55+
function readV3InstallSql(): string {
56+
try {
57+
return readInstallSql()
58+
} catch (cause) {
59+
throw new Error(
60+
'Failed to read the EQL v3 install SQL from `@cipherstash/eql`. Reinstall dependencies — the package ships the bundle in `dist/sql/`.',
61+
{ cause },
62+
)
63+
}
5064
}
5165

66+
type ExecuteStep = { readonly sql?: unknown }
67+
type OpLike = { readonly execute?: ReadonlyArray<ExecuteStep> }
68+
5269
/**
53-
* Return `ops` with the v3 install op's placeholder SQL replaced by the install
54-
* SQL from the installed `@cipherstash/eql`. Every other op passes through
55-
* untouched. Called by the descriptor in `control.ts` so the applied SQL always
70+
* Return `ops` with every placeholder install-SQL step ({@link
71+
* RUNTIME_EQL_SQL_SENTINEL}) replaced by the install SQL from the installed
72+
* `@cipherstash/eql`. Non-placeholder steps and every other op field are
73+
* preserved as-is (only the matched step's `sql` is rewritten), so the swap is
74+
* non-lossy. Called by the descriptor in `control.ts` so the applied SQL always
5675
* matches the resolved `@cipherstash/eql` version, not a baked snapshot.
76+
*
77+
* Throws if NO placeholder is present: the committed v3 baseline op MUST carry
78+
* the sentinel, so a missing match means the emit source and this injector have
79+
* diverged (e.g. real SQL was baked back in, or the sentinel string changed) —
80+
* fail loudly at descriptor build rather than silently apply the inert comment
81+
* as the "install" and leave the database with no EQL.
5782
*/
5883
export function withRuntimeEqlSql<T extends OpLike>(ops: readonly T[]): T[] {
59-
const sql = readInstallSql()
84+
const hasPlaceholder = ops.some((op) =>
85+
op.execute?.some((step) => step.sql === RUNTIME_EQL_SQL_SENTINEL),
86+
)
87+
if (!hasPlaceholder) {
88+
throw new Error(
89+
'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.',
90+
)
91+
}
92+
const sql = readV3InstallSql()
6093
return ops.map((op) =>
61-
op.id === V3_INSTALL_OP_ID
94+
op.execute?.some((step) => step.sql === RUNTIME_EQL_SQL_SENTINEL)
6295
? {
6396
...op,
64-
execute: [
65-
{
66-
description: String(op.execute?.[0]?.description ?? ''),
67-
sql,
68-
},
69-
],
97+
execute: op.execute.map((step) =>
98+
step.sql === RUNTIME_EQL_SQL_SENTINEL ? { ...step, sql } : step,
99+
),
70100
}
71101
: op,
72102
)

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

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ import {
2626
CIPHERSTASH_V3_BASELINE_MIGRATION_NAME,
2727
CIPHERSTASH_V3_INVARIANTS,
2828
} from '../../src/extension-metadata/constants-v3'
29-
import { RUNTIME_EQL_SQL_SENTINEL } from '../../src/migration/eql-bundle-v3'
29+
import {
30+
RUNTIME_EQL_SQL_SENTINEL,
31+
withRuntimeEqlSql,
32+
} from '../../src/migration/eql-bundle-v3'
3033

3134
describe('v3 baseline migration (20260601T0100_install_eql_v3_bundle)', () => {
3235
it('installs under the v3 invariant with a single data-class rawSql op', () => {
@@ -47,16 +50,18 @@ describe('v3 baseline migration (20260601T0100_install_eql_v3_bundle)', () => {
4750
readonly execute?: ReadonlyArray<{ readonly sql: string }>
4851
}>
4952
)[0]!
50-
// The ~1.7 MB bundle must not be committed here — an EQL patch/minor should
51-
// not require re-emitting this file. The op carries the sentinel instead.
53+
// The ~1.7 MB bundle must not be committed here — bumping @cipherstash/eql
54+
// should not require re-emitting this file. The op carries the sentinel.
5255
expect(op.execute?.[0]?.sql).toBe(RUNTIME_EQL_SQL_SENTINEL)
5356
expect(op.execute?.[0]?.sql).not.toContain('CREATE')
5457
expect(JSON.stringify(v3Ops).length).toBeLessThan(5_000)
58+
// @cipherstash/eql is pinned exact (matching @cipherstash/stack, which
59+
// encodes the v3 domain types against this same release).
5560
expect(releaseManifest.eqlVersion).toBe('3.0.0')
5661
})
5762

5863
it('injects readInstallSql() from @cipherstash/eql into the descriptor at build time', () => {
59-
// control.ts swaps the placeholder for the install SQL of the INSTALLED
64+
// control.ts swaps the placeholder for the install SQL of the pinned
6065
// @cipherstash/eql, so the applied SQL always matches the resolved version.
6166
const v3Baseline = cipherstashDescriptor.contractSpace.migrations[1]!
6267
const op = (
@@ -69,6 +74,30 @@ describe('v3 baseline migration (20260601T0100_install_eql_v3_bundle)', () => {
6974
expect(op.execute?.[0]?.sql).toContain('EQL v3 schema creation')
7075
})
7176

77+
it('withRuntimeEqlSql throws if no op carries the sentinel (drift guard)', () => {
78+
// Matching on the sentinel string (not an op id) makes injection immune to
79+
// op-id/label drift; a missing sentinel means the emit source and injector
80+
// diverged, so fail loudly rather than apply the inert comment as install.
81+
expect(() =>
82+
withRuntimeEqlSql([{ execute: [{ sql: 'SELECT 1' }] }]),
83+
).toThrow(/RUNTIME_EQL_SQL_SENTINEL/)
84+
// A non-lossy swap: only the sentinel step's `sql` changes; sibling steps
85+
// and extra fields on the matched op are preserved.
86+
const [op] = withRuntimeEqlSql([
87+
{
88+
id: 'cipherstash.install-eql-v3-bundle',
89+
execute: [
90+
{ description: 'keep me', sql: RUNTIME_EQL_SQL_SENTINEL },
91+
{ description: 'sibling', sql: 'SELECT 2' },
92+
],
93+
},
94+
])
95+
expect(op.id).toBe('cipherstash.install-eql-v3-bundle')
96+
expect(op.execute[0].description).toBe('keep me')
97+
expect(op.execute[0].sql).toBe(readInstallSql())
98+
expect(op.execute[1]).toEqual({ description: 'sibling', sql: 'SELECT 2' })
99+
})
100+
72101
it('emits no add_search_config / remove_search_config ops', () => {
73102
const json = JSON.stringify(v3Ops)
74103
expect(json).not.toContain('add_search_config')

pnpm-lock.yaml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)