Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
5db1ffe
chore(extension-cipherstash): scaffold workspace package shell
wmadden May 8, 2026
2052b97
feat(extension-cipherstash): author contract space (T3.1, T3.2, T3.3)
wmadden May 8, 2026
b5cc7e4
docs(extension-contract-spaces): mark T3.1 + T3.2 + T3.3 completed (M…
wmadden May 8, 2026
1f85432
feat(extension-cipherstash): vendor real EQL bundle (eql-2.2.1)
wmadden May 8, 2026
efbd88f
feat(extension-cipherstash): wire cipherstash:string@1 codec lifecycl…
wmadden May 8, 2026
955bbe7
test(extension-cipherstash): pin databaseDependencies absence at thre…
wmadden May 8, 2026
c77df69
test(extension-cipherstash): pin AM11 + AM12 against the cipherstash …
wmadden May 8, 2026
8b21567
docs(extension-contract-spaces): mark T3.4 + T3.5 completed (M3 R2)
wmadden May 8, 2026
3e9add6
fix(extension-cipherstash): resolve structural-ops/EQL-bundle CREATE …
wmadden May 8, 2026
2562215
feat(migration-tools): include additive ops with invariantIds in deri…
wmadden May 8, 2026
6ef575e
feat(extension-cipherstash): add expandNativeType identity hook to st…
wmadden May 8, 2026
611b43a
test(extension-cipherstash): T3.6 Scenario A e2e against PGlite
wmadden May 8, 2026
a00dcc1
docs(extension-contract-spaces): mark T3.6 partial complete (M3 R3)
wmadden May 8, 2026
88083b1
fix(extension-cipherstash): sync EQL_V2 ORE composites + state enum v…
wmadden May 8, 2026
84abd79
feat(migration-tools): extract materialiseExtensionMigrationPackageIf…
wmadden May 8, 2026
d210afc
test(extension-cipherstash): T3.7 Scenario C bump-diff (pure-fixture,…
wmadden May 8, 2026
3bf05d2
docs(extension-contract-spaces): fold M3 framework-mechanism amendmen…
wmadden May 8, 2026
44b4846
docs(extension-contract-spaces): fold M3 cipherstash sub-spec amendme…
wmadden May 8, 2026
3f69dfd
docs(extension-contract-spaces): mark M3 R4 tasks complete in plan (T…
wmadden May 8, 2026
4c1460f
fix(rebase): adopt M2.5/M2.5b conventions on M3 (cipherstash)
wmadden May 9, 2026
9cac5e3
fix(rebase): drop stale extension-test-contract-space workspace deps
wmadden May 10, 2026
cc67477
chore(rebase): refresh lockfile after dropping stale workspace deps
wmadden May 10, 2026
4aa2789
fix(rebase): import canonical control types from framework-components…
wmadden May 10, 2026
a076733
fix(rebase): cipherstash test typecheck after M2.5 renames
wmadden May 10, 2026
7162854
test(cipherstash): migrate to M2.5 aggregate APIs (executeDbInit + he…
wmadden May 10, 2026
d874663
chore(cipherstash): declare Apache-2.0 license
wmadden May 10, 2026
ef034ba
test(integration): align F23 fixture with deriveProvidedInvariants on…
wmadden May 10, 2026
725650b
fix(migration): check isDirectory() to skip only existing dirs, not a…
wmadden May 10, 2026
3a05156
fix(migration): guard readInvariantId against prototype-inherited inv…
wmadden May 10, 2026
96a17d4
test(integration): also assert warn count is 0 in aggregate-schema ve…
wmadden May 10, 2026
c332917
fix(cipherstash): remove redundant main/module/types fields from pack…
wmadden May 10, 2026
3de8f7c
fix(cipherstash): use base tsconfig outDir (dist-tsc) instead of dist
wmadden May 10, 2026
e3417ad
fix(cipherstash): narrow createDomain/createOreComposite param types;…
wmadden May 10, 2026
2d8cd32
docs(cipherstash): remove transient projects/ links from README
wmadden May 10, 2026
7d4f338
refactor(cipherstash): remove transient spec refs; fail fast on impos…
wmadden May 10, 2026
c5b947b
docs(cipherstash): remove transient spec/milestone refs from control.ts
wmadden May 10, 2026
b3fc7dd
fix(cipherstash): align synthetic bundle schema with contract IR (id …
wmadden May 10, 2026
63bb076
docs(cipherstash): remove transient spec refs from migrations.ts docb…
wmadden May 10, 2026
4497e46
style(cipherstash): split long throw Error lines to satisfy biome for…
wmadden May 10, 2026
f43d2a7
docs(migration): remove @see projects/ links from deriveProvidedInvar…
wmadden May 10, 2026
ace08f6
docs(cipherstash): remove AM11/AM12 milestone labels from test header…
wmadden May 10, 2026
ade8910
docs(cipherstash): remove transient spec/milestone refs from scenario…
wmadden May 10, 2026
d5da911
docs(cipherstash): update README to reflect codec lifecycle hook is w…
wmadden May 10, 2026
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
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { stat } from 'node:fs/promises';
import { materialiseMigrationPackage } from '@prisma-next/migration-tools/io';
import { materialiseExtensionMigrationPackageIfMissing } from '@prisma-next/migration-tools/io';
import type { MigrationMetadata } from '@prisma-next/migration-tools/metadata';
import type { MigrationOps } from '@prisma-next/migration-tools/package';
import {
planAllSpaces,
type SpacePlanOutput,
spaceMigrationDirectory,
} from '@prisma-next/migration-tools/spaces';
import { join } from 'pathe';

/**
* In-memory authored migration package shipped by an extension descriptor.
* Mirrors `MigrationPackageContents` from `@prisma-next/migration-tools/io`
* Mirrors `MigrationPackage` from `@prisma-next/migration-tools/io`
* (the on-disk shape minus `dirPath`); redeclared structurally here so
* the CLI helper does not couple to the SQL family's `ExtensionMigrationPackage`
* type — any family that ships pre-built migration packages can pass them
Expand Down Expand Up @@ -109,26 +107,14 @@ export async function runContractSpaceExtensionMigrationsPass(
for (const space of planned) {
const spaceDir = spaceMigrationDirectory(inputs.migrationsDir, space.spaceId);
for (const pkg of space.migrationPackages) {
const pkgPath = join(spaceDir, pkg.dirName);
const exists = await pathExists(pkgPath);
if (exists) {
const { written } = await materialiseExtensionMigrationPackageIfMissing(spaceDir, pkg);
if (written) {
emitted.push({ spaceId: space.spaceId, dirName: pkg.dirName });
} else {
skipped.push({ spaceId: space.spaceId, dirName: pkg.dirName });
continue;
}
await materialiseMigrationPackage(spaceDir, pkg);
emitted.push({ spaceId: space.spaceId, dirName: pkg.dirName });
}
}

return { emitted, skipped };
}

async function pathExists(p: string): Promise<boolean> {
try {
await stat(p);
return true;
} catch (error) {
if ((error as { code?: string }).code === 'ENOENT') return false;
throw error;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {
copyFilesWithRename,
formatMigrationDirName,
materialiseExtensionMigrationPackageIfMissing,
materialiseMigrationPackage,
readMigrationPackage,
readMigrationsDir,
Expand Down
17 changes: 14 additions & 3 deletions packages/1-framework/3-tooling/migration/src/invariants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,19 @@ export function validateInvariantId(invariantId: string): boolean {
/**
* Walk a migration's operations and produce its `providedInvariants`
* aggregate: the sorted, deduplicated list of `invariantId`s declared
* by data-transform ops. Ops without `operationClass === 'data'` are
* skipped; data ops without an `invariantId` are skipped.
* by ops in the migration. Ops without an `invariantId` are skipped.
*
* Both `data`-class ops (data-transforms, e.g. backfills) and
* `additive`-class opaque DDL (e.g. cipherstash's vendored EQL bundle
* via `installEqlBundleOp`) may declare invariantIds: the
* `operationClass` axis classifies *policy gating* (which kinds of ops
* a `db init` / `db update` policy permits), while `invariantId`
* classifies *marker bookkeeping* (which named bundles of work a
* future regeneration knows to skip). The two concerns are
* intentionally orthogonal — an extension can ship additive
* non-IR-derivable DDL (the only way the planner can know the bundle
* is already applied is via the invariantId on the marker) without
* needing to mis-classify it as `data`-class.
*
* Throws `MIGRATION.INVALID_INVARIANT_ID` on a malformed id and
* `MIGRATION.DUPLICATE_INVARIANT_IN_EDGE` on duplicates.
Expand All @@ -39,7 +50,7 @@ export function deriveProvidedInvariants(ops: MigrationOps): readonly string[] {
}

function readInvariantId(op: MigrationPlanOperation): string | undefined {
if (op.operationClass !== 'data') return undefined;
if (!Object.hasOwn(op, 'invariantId')) return undefined;
const candidate = (op as { invariantId?: unknown }).invariantId;
return typeof candidate === 'string' ? candidate : undefined;
}
42 changes: 42 additions & 0 deletions packages/1-framework/3-tooling/migration/src/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,48 @@ export async function materialiseMigrationPackage(
});
}

/**
* Idempotent variant of {@link materialiseMigrationPackage}: writes the
* package only if `<targetDir>/<pkg.dirName>/` does not already exist on
* disk as a directory; returns `{ written: false }` when the package
* directory is present (no rewrite, no comparison — by-existence skip).
*
* Concretely:
* - existing directory → skip silently, return `{ written: false }`.
* - missing path → write three files via {@link materialiseMigrationPackage},
* return `{ written: true }`.
* - path exists but is not a directory (file/symlink) → treated as
* missing; {@link materialiseMigrationPackage} will attempt creation
* and fail with an appropriate OS error.
* - any other I/O error from `stat` → propagated unchanged.
*
* Used by the CLI's `runContractSpaceExtensionMigrationsPass` to
* materialise extension migration packages into a project's
* `migrations/<spaceId>/` directory, and by extension-package tests
* that mirror the same idempotent-rematerialise property locally
* without taking a CLI dependency.
*/
export async function materialiseExtensionMigrationPackageIfMissing(
targetDir: string,
pkg: MigrationPackage,
): Promise<{ readonly written: boolean }> {
const pkgDir = join(targetDir, pkg.dirName);
if (await directoryExists(pkgDir)) {
return { written: false };
}
await materialiseMigrationPackage(targetDir, pkg);
return { written: true };
}

async function directoryExists(p: string): Promise<boolean> {
try {
return (await stat(p)).isDirectory();
} catch (error) {
if (hasErrnoCode(error, 'ENOENT')) return false;
throw error;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

/**
* Copy a list of files into `destDir`, optionally renaming each one.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,22 @@ describe('deriveProvidedInvariants', () => {
expect(deriveProvidedInvariants([nonDataOp('add-table'), dataOp('cleanup')])).toEqual([]);
});

it('skips non-data ops', () => {
it('includes invariantIds from additive ops alongside data ops', () => {
// Both data-class and additive-class ops may declare invariantIds for
// marker bookkeeping. operationClass governs policy gating; invariantId
// governs replay tracking. Cipherstash's `installEqlBundle` and
// structural `create-*` ops are the canonical additive-with-invariantId
// case (sub-spec § 3 / cipherstash-migration.spec.md).
expect(
deriveProvidedInvariants([
nonDataOp('add-table'),
// additive ops with stray invariantId-like fields are ignored
{ ...nonDataOp('phantom'), invariantId: 'should-be-ignored' } as MigrationPlanOperation,
{ ...nonDataOp('install-bundle'), invariantId: 'ext:bundle-v1' } as MigrationPlanOperation,
dataOp('phone-backfill', 'phone-backfill'),
]),
).toEqual(['phone-backfill']);
).toEqual(['ext:bundle-v1', 'phone-backfill']);
});

it('returns sorted, deduplicated invariantIds across data ops', () => {
it('returns sorted, deduplicated invariantIds across all ops', () => {
expect(
deriveProvidedInvariants([dataOp('z', 'zebra'), dataOp('a', 'apple'), dataOp('m', 'mango')]),
).toEqual(['apple', 'mango', 'zebra']);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { materialiseExtensionMigrationPackageIfMissing } from '../src/io';
import { createTestMetadata, createTestOps } from './fixtures';

describe('materialiseExtensionMigrationPackageIfMissing', () => {
let tmpDir: string;

beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'materialise-if-missing-'));
});

afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});

it('writes the package and returns { written: true } on first run', async () => {
const ops = createTestOps();
const metadata = createTestMetadata({}, ops);
const pkg = { dirName: 'baseline', metadata, ops };

const result = await materialiseExtensionMigrationPackageIfMissing(tmpDir, pkg);

expect(result.written).toBe(true);
const manifest = await readFile(join(tmpDir, pkg.dirName, 'migration.json'), 'utf-8');
expect(manifest).toContain(metadata.migrationHash);
});

it('returns { written: false } when <targetDir>/<pkg.dirName>/ already exists', async () => {
const ops = createTestOps();
const metadata = createTestMetadata({}, ops);
const pkg = { dirName: 'baseline', metadata, ops };

await materialiseExtensionMigrationPackageIfMissing(tmpDir, pkg);
const second = await materialiseExtensionMigrationPackageIfMissing(tmpDir, pkg);

expect(second.written).toBe(false);
});

it('leaves on-disk content byte-identical when the dir already exists (AC-7 / AM12)', async () => {
const ops = createTestOps();
const metadata = createTestMetadata({}, ops);
const pkg = { dirName: 'baseline', metadata, ops };

await materialiseExtensionMigrationPackageIfMissing(tmpDir, pkg);

const before = await Promise.all(
['migration.json', 'ops.json', 'contract.json'].map((name) =>
readFile(join(tmpDir, pkg.dirName, name), 'utf-8'),
),
);

await materialiseExtensionMigrationPackageIfMissing(tmpDir, pkg);

const after = await Promise.all(
['migration.json', 'ops.json', 'contract.json'].map((name) =>
readFile(join(tmpDir, pkg.dirName, name), 'utf-8'),
),
);

expect(after).toEqual(before);
});

it('creates the parent target directory if it does not yet exist', async () => {
const nested = join(tmpDir, 'cipherstash');
const pkg = {
dirName: 'baseline',
metadata: createTestMetadata({}, []),
ops: [],
};

const result = await materialiseExtensionMigrationPackageIfMissing(nested, pkg);

expect(result.written).toBe(true);
const manifest = await readFile(join(nested, pkg.dirName, 'migration.json'), 'utf-8');
expect(manifest).toBeDefined();
});
});
61 changes: 61 additions & 0 deletions packages/3-extensions/cipherstash/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# @prisma-next/extension-cipherstash

[CipherStash](https://cipherstash.com) extension for Prisma Next: searchable
application-layer encryption for Postgres via the EQL bundle.

## Status

Work in progress. This package documents the current supported behavior only.

This package authors CipherStash's database scaffolding (the
`eql_v2_configuration` table, the `eql_v2_encrypted` / `ore_*` composite
types, the `eql_v2.bloom_filter` / `hmac_256` / `blake3` domains, and the
EQL bundle SQL) as a **contract space** so the Prisma Next framework can
plan, apply, and verify it the same way it manages an application's own
schema.

The `searchable: true` codec lifecycle hook (`add_search_config` /
`remove_search_config` ops) is wired up — see `src/exports/control.ts`. The
codec runtime (encoding/decoding `Encrypted<string>` payloads at query time)
is not yet implemented.

## What this package contributes

- `contractSpace.contractJson` — the typed objects EQL exposes that user
columns can name as `nativeType`. Per the IR vocabulary boundary,
this carries tables / enums / composite types / domains only; functions /
operators / casts / op classes live below the boundary as opaque DDL inside
the `installEqlBundle` migration op.
- `contractSpace.migrations` — the baseline migration that installs the
vendored EQL bundle SQL (one `cipherstash:install-eql-bundle-v1` op
carrying the bundle byte-for-byte) plus the structural ops that create
the typed objects above. Each op carries a `cipherstash:*` invariantId.
- `contractSpace.headRef` — `(hash, invariants)` describing the current
target state of the contract space. The framework consumes this at
`migrate` time to materialise pinned per-space artefacts under
`migrations/cipherstash/` in the user's repo.

## Usage (preview)

```ts
import { defineConfig } from 'prisma-next';
import cipherstash from '@prisma-next/extension-cipherstash/control';

export default defineConfig({
extensionPacks: [cipherstash],
});
```

After `prisma-next migrate`, the user's repo gains
`migrations/cipherstash/contract.json`,
`migrations/cipherstash/contract.d.ts`,
`migrations/cipherstash/refs/head.json`, and
`migrations/cipherstash/<name>/` migration directories. `db apply` then
runs CipherStash's migrations against the live database in the same
transaction as any application-space migration emitted in the same
`migrate` invocation.

## See also

- Reference fixture: [`packages/3-extensions/test-contract-space`](../test-contract-space)
- Reference shape: [`packages/3-extensions/pgvector`](../pgvector)
4 changes: 4 additions & 0 deletions packages/3-extensions/cipherstash/biome.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"extends": "//"
}
51 changes: 51 additions & 0 deletions packages/3-extensions/cipherstash/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"name": "@prisma-next/extension-cipherstash",
"version": "0.0.1",
"license": "Apache-2.0",
"type": "module",
"sideEffects": false,
"description": "CipherStash EQL extension for Prisma Next: contract-space authoring of the encrypted-column scaffolding (eql_v2_configuration table, eql_v2_encrypted/ore_* composite types, eql_v2 domains) plus a baseline migration that installs the vendored EQL bundle SQL byte-for-byte.",
"scripts": {
"build": "tsdown",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --project tsconfig.json --noEmit",
"lint": "biome check . --error-on-warnings",
"lint:fix": "biome check --write .",
"lint:fix:unsafe": "biome check --write --unsafe .",
"clean": "rm -rf dist dist-tsc dist-tsc-prod coverage .tmp-output"
},
"dependencies": {
"@prisma-next/contract": "workspace:*",
"@prisma-next/family-sql": "workspace:*",
"@prisma-next/framework-components": "workspace:*",
"@prisma-next/migration-tools": "workspace:*",
"@prisma-next/sql-contract": "workspace:*"
},
"devDependencies": {
"@prisma-next/adapter-postgres": "workspace:*",
"@prisma-next/cli": "workspace:*",
"@prisma-next/driver-postgres": "workspace:*",
"@prisma-next/sql-schema-ir": "workspace:*",
"@prisma-next/target-postgres": "workspace:*",
"@prisma-next/test-utils": "workspace:*",
"@prisma-next/tsconfig": "workspace:*",
"@prisma-next/tsdown": "workspace:*",
"tsdown": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"files": [
"dist",
"src"
],
"exports": {
"./control": "./dist/control.mjs",
"./package.json": "./package.json"
},
"repository": {
"type": "git",
"url": "https://github.com/prisma/prisma-next.git",
"directory": "packages/3-extensions/cipherstash"
}
}
Loading
Loading