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
43 changes: 43 additions & 0 deletions .changeset/eql-baked-digest-verified.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@cipherstash/prisma-next': minor
---

Bake the EQL v3 install SQL into the migration artefacts at emit time,
digest-verified against the `@cipherstash/eql` release manifest — replacing
runtime injection, whose environment-dependent migration hashes broke consumer
repos on every EQL version bump.

**Why.** Prisma Next's migration framework treats migration packages as
immutable, content-addressed artefacts: the CLI seed phase copies an
extension's packages into the app's `migrations/cipherstash/` only when
missing, and `db init` / `db update` apply from those on-disk files without
loading the extension descriptor. The previous design injected the installed
`@cipherstash/eql`'s SQL at descriptor build and recomputed the migration hash
from it, so the migration's identity changed with the installed EQL version —
every bump orphaned the vendored copies (`PN-MIG-5002`), and the hash could
never detect SQL tampering because it was recomputed from whatever content was
present.

**Now.** Each migration's self-emit embeds the SQL read from the pinned
`@cipherstash/eql`, refusing to emit bytes whose sha256 does not match the
release manifest's `installSqlSha256`. One content-addressed identity flows
from this repo's git history through the npm tarball into the consumer's
vendored copy and the database ledger; `verifyMigrationHash` re-checks the
bytes on every read, so editing the vendored SQL fails loudly before anything
executes. A repo test pins the committed artefact's SQL digest to the installed
manifest, so an EQL bump without a matching new upgrade migration fails CI.
EQL upgrades ship as new append-only migration directories carrying fresh
invariants (the existing `upgrade_eql_v3_3_0_2` pattern); published directories
are never rewritten.

**One-time exception.** This PR itself re-emits the baseline migration's bytes
and hash (`35fc9000…` → `2c873907…`) as it moves off the runtime-injection
scheme. That is a deliberate, one-time transition — the "never rewrite a
published directory" invariant it introduces holds strictly for every release
after this one, enforced by the frozen-hash CI pins.

**Action required (rc consumers only).** If your project's
`migrations/cipherstash/` was generated by an earlier rc, delete that directory
and re-run `prisma-next migration plan` (or `migrate`) — the seed phase
regenerates it byte-identical to the shipped artefacts. Databases keep their
markers; already-applied invariants are not re-run.
38 changes: 38 additions & 0 deletions .changeset/prisma-next-0-16.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@cipherstash/prisma-next': minor
'stash': patch
---

Upgrade the Prisma Next integration to Prisma Next 0.16.

All `@prisma-next/*` dependencies move from `0.14.0` to `0.16.0`, in lockstep. The
CipherStash encryption surface is unchanged — column types, envelopes, the `eql*`
operators, `cipherstashFromStack`, and every subpath export behave exactly as before.

**Action required in your `prisma-next.config.ts`:** Prisma Next 0.15 stopped
materialising a placeholder namespace, so authoring a SQL contract now requires the
target's namespace factory. Add `createNamespace` to your `prismaContract(...)` call:

```typescript
import { postgresCreateNamespace } from '@prisma-next/target-postgres/types'

contract: prismaContract('./prisma/schema.prisma', {
output: 'src/prisma/contract.json',
target: postgresPack,
createNamespace: postgresCreateNamespace,
}),
```

Without it, `prisma-next contract emit` fails at runtime with `createNamespace is
not a function`. The bundled `stash-prisma-next` skill documents this too.

The bundled EQL v3 baseline migration is re-emitted so its label and hash reflect
the pinned `@cipherstash/eql` 3.0.2 (the committed artifact still said 3.0.0).

Re-run `prisma-next contract emit` after upgrading. The regenerated
`contract.{json,d.ts}` picks up the 0.15/0.16 shape changes — the namespace
discriminator becomes the target-specific `'postgres-schema'` (was
`'sql-namespace'`), emit adds the `StorageColumnTypes` / `StorageColumnInputTypes`
maps and the `scalarList` capability marker, and foreign keys and their backing
indexes become discrete contract entities. Your contract's `storageHash` is
unaffected by the upgrade itself.
14 changes: 14 additions & 0 deletions .changeset/prisma-next-migrate-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'stash': patch
'@cipherstash/prisma-next': patch
---

Fix the wrong `prisma-next migration apply` command name in the Prisma Next
guidance. Prisma Next has no `migration apply` subcommand — the apply verb is the
top-level `prisma-next migrate` (`migration` only has `plan`/`new`/`show`/
`status`/`log`/`list`/`graph`/`check`). The stale name appeared in the
`stash-prisma-next` and `stash-cli` skills, the `@cipherstash/prisma-next`
README, and — user-visibly — in `stash init --prisma-next`'s printed next-steps,
the `stash init` flag help, and the `stash eql install` Prisma-Next refusal
message, all of which now say `prisma-next migrate`. Surfaced by the rc.4
skilltester run (found independently at Prisma Next 0.14.0, confirmed at 0.16.0).
31 changes: 31 additions & 0 deletions .changeset/v3-payload-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@cipherstash/prisma-next': minor
---

Route v3 decoding from the EQL payload's own identifier, so encrypted columns
decode on paths that carry no column context.

Every EQL v3 payload carries a required `i` identifier (`{"t": "<table>", "c":
"<column>"}`), and ZeroKMS commits the cell's key to it — a payload relocated to
a different column cannot decrypt. That makes the identifier the authoritative
routing source, and it travels with the value rather than with the query.

The v3 cell codec previously took its `(table, column)` routing key only from the
SQL runtime's projected-column context, which meant two paths failed even though
the value knew exactly where it came from:

- **Relation `include()`** — the ORM decodes cells nested in a `json_agg` /
`json_build_object` document through `decodeJson`, whose framework signature
passes only the JSON value, with no column ref. This previously threw
`decodeJson is not supported; envelopes do not round-trip through JSON`.
Included encrypted columns now decode into ordinary envelopes that
`decryptAll` batches alongside top-level ones.
- **Aggregates and computed projections** — the runtime resolves no column ref
and deliberately passes `column: undefined`, so `decode` threw. It now routes
from the payload.

`decode` reads the payload identifier first and falls back to the projected-column
context for a value carrying no usable identifier (a non-v3 or malformed
document), so existing well-routed reads are unaffected. `encodeJson` is
unchanged: it still renders the opaque `$encrypted*` marker, and is deliberately
not the inverse of `decodeJson` — the two serve different planes.

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"from": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7",
"to": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7",
"from": null,
"to": "sha256:efd408cf8924b4d1805bf5acced8898114aa03cd46b465720179c82a4431d51e",
"providedInvariants": [
"cipherstash:install-eql-v3-bundle-v1"
],
"createdAt": "2026-07-14T20:10:24.325Z",
"migrationHash": "sha256:c33efc31bb927fa63361a7587052cdeff63fd133ab6f1af9a76ebdd7b7705e06"
"migrationHash": "sha256:2c8739076699b81bcf515f1f8ff23501ff1f2582b933cfd80c5fb5bcc3de9e12"
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"from": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7",
"to": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7",
"from": "sha256:efd408cf8924b4d1805bf5acced8898114aa03cd46b465720179c82a4431d51e",
"to": "sha256:efd408cf8924b4d1805bf5acced8898114aa03cd46b465720179c82a4431d51e",
"providedInvariants": [
"cipherstash:upgrade-eql-v3-bundle-3.0.2-v1"
],
"createdAt": "2026-07-20T11:40:17.876Z",
"migrationHash": "sha256:4a7582a2a6fbd6d83b2809599f34d894b1b79036af22fa8616d775d24591a79b"
"migrationHash": "sha256:7bb960435f9cdb7d7c25e4ff70b02fa050a1b8e695541facc47dd87ec3cc634e"
}
2 changes: 1 addition & 1 deletion examples/prisma/migrations/cipherstash/contract.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"_generated":{"message":"This file is automatically generated by \"prisma-next contract emit\".","regenerate":"To regenerate, run: prisma-next contract emit","warning":"⚠️ GENERATED FILE - DO NOT EDIT"},"capabilities":{"postgres":{"distinctOn":true,"jsonAgg":true,"lateral":true,"limit":true,"orderBy":true,"returning":true},"sql":{"defaultInInsert":true,"enums":true,"lateral":true,"returning":true}},"domain":{"namespaces":{"public":{"models":{"EqlV2Configuration":{"fields":{"data":{"nullable":false,"type":{"codecId":"pg/jsonb@1","kind":"scalar"}},"id":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"state":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}}},"relations":{},"storage":{"fields":{"data":{"column":"data"},"id":{"column":"id"},"state":{"column":"state"}},"namespaceId":"public","table":"eql_v2_configuration"}}}}}},"extensionPacks":{},"meta":{},"profileHash":"sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb","roots":{"eql_v2_configuration":{"model":"EqlV2Configuration","namespace":"public"}},"schemaVersion":"1","storage":{"namespaces":{"public":{"entries":{"table":{"eql_v2_configuration":{"columns":{"data":{"codecId":"pg/jsonb@1","nativeType":"jsonb","nullable":false},"id":{"codecId":"pg/text@1","nativeType":"text","nullable":false},"state":{"codecId":"pg/text@1","nativeType":"text","nullable":false}},"foreignKeys":[],"indexes":[],"primaryKey":{"columns":["id"]},"uniques":[]}}},"id":"public","kind":"postgres-schema"}},"storageHash":"sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7"},"target":"postgres","targetFamily":"sql"}
{"_generated":{"message":"This file is automatically generated by \"prisma-next contract emit\".","regenerate":"To regenerate, run: prisma-next contract emit","warning":"⚠️ GENERATED FILE - DO NOT EDIT"},"capabilities":{"postgres":{"distinctOn":true,"jsonAgg":true,"lateral":true,"limit":true,"orderBy":true,"returning":true},"sql":{"defaultInInsert":true,"enums":true,"lateral":true,"returning":true,"scalarList":true}},"domain":{"namespaces":{"public":{"models":{}}}},"extensionPacks":{},"meta":{},"profileHash":"sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb","roots":{},"schemaVersion":"1","storage":{"namespaces":{"public":{"entries":{"table":{}},"id":"public","kind":"postgres-schema"}},"storageHash":"sha256:efd408cf8924b4d1805bf5acced8898114aa03cd46b465720179c82a4431d51e"},"target":"postgres","targetFamily":"sql"}
2 changes: 1 addition & 1 deletion examples/prisma/migrations/cipherstash/refs/head.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"hash":"sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7","invariants":["cipherstash:install-eql-bundle-v1","cipherstash:install-eql-v3-bundle-v1","cipherstash:upgrade-eql-v3-bundle-3.0.2-v1"]}
{"hash":"sha256:efd408cf8924b4d1805bf5acced8898114aa03cd46b465720179c82a4431d51e","invariants":["cipherstash:install-eql-v3-bundle-v1","cipherstash:upgrade-eql-v3-bundle-3.0.2-v1"]}
24 changes: 12 additions & 12 deletions examples/prisma/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,21 @@
"dependencies": {
"@cipherstash/prisma-next": "workspace:*",
"@cipherstash/stack": "workspace:*",
"@prisma-next/adapter-postgres": "0.14.0",
"@prisma-next/contract": "0.14.0",
"@prisma-next/driver-postgres": "0.14.0",
"@prisma-next/family-sql": "0.14.0",
"@prisma-next/framework-components": "0.14.0",
"@prisma-next/postgres": "0.14.0",
"@prisma-next/sql-contract": "0.14.0",
"@prisma-next/sql-contract-psl": "0.14.0",
"@prisma-next/sql-orm-client": "0.14.0",
"@prisma-next/sql-runtime": "0.14.0",
"@prisma-next/target-postgres": "0.14.0",
"@prisma-next/adapter-postgres": "0.16.0",
"@prisma-next/contract": "0.16.0",
"@prisma-next/driver-postgres": "0.16.0",
"@prisma-next/family-sql": "0.16.0",
"@prisma-next/framework-components": "0.16.0",
"@prisma-next/postgres": "0.16.0",
"@prisma-next/sql-contract": "0.16.0",
"@prisma-next/sql-contract-psl": "0.16.0",
"@prisma-next/sql-orm-client": "0.16.0",
"@prisma-next/sql-runtime": "0.16.0",
"@prisma-next/target-postgres": "0.16.0",
"dotenv": "^17.4.2"
},
"devDependencies": {
"@prisma-next/cli": "0.14.0",
"@prisma-next/cli": "0.16.0",
"@types/node": "^22.20.1",
"pathe": "^2.0.3",
"tsx": "catalog:repo",
Expand Down
8 changes: 6 additions & 2 deletions examples/prisma/prisma-next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import sql from '@prisma-next/family-sql/control'
import { prismaContract } from '@prisma-next/sql-contract-psl/provider'
import postgres from '@prisma-next/target-postgres/control'
import postgresPack from '@prisma-next/target-postgres/pack'
import { postgresCreateNamespace } from '@prisma-next/target-postgres/types'

const databaseUrl = process.env['DATABASE_URL']

Expand All @@ -21,14 +22,17 @@ export default defineConfig({
// Since 0.14 `prismaContract` takes the target PACK ref (carrying
// `defaultNamespaceId`), not the control descriptor.
target: postgresPack,
// Since 0.15 the SQL family no longer materialises a placeholder
// namespace, so the target's namespace factory is required.
createNamespace: postgresCreateNamespace,
}),
migrations: {
dir: 'migrations',
},
// `contract emit` does not need a database connection; only
// `migration apply` does. We pass `connection` through when
// `prisma-next migrate` does. We pass `connection` through when
// `DATABASE_URL` is set so the same config supports every CLI
// subcommand, and let `migration apply` error explicitly if the
// subcommand, and let `prisma-next migrate` error explicitly if the
// connection is missing.
...(databaseUrl ? { db: { connection: databaseUrl } } : {}),
})
Loading
Loading