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
12 changes: 12 additions & 0 deletions .changeset/prisma-next-encrypt-backfill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'stash': patch
---

Make `stash encrypt` work in Prisma Next projects.

`stash encrypt backfill` could not run against a Prisma Next project for two independent reasons, both now fixed:

- **No encryption client file.** Prisma Next integrations deliberately have none — encrypted columns are declared in the PSL contract. Loading the encryption context hard-failed on the missing file. It now falls back (mirroring the existing Drizzle auto-derive) to detecting the project, locating the emitted `contract.json`, and deriving the v3 schemas with the adapter's own `deriveStackSchemasV3` + `Encryption`. Both `@cipherstash/stack-prisma` and `@cipherstash/stack` are resolved from the user's project, so the CLI's schema view always matches the application's.
- **`cipherstash.cs_migrations` never existed.** That schema is created by `stash eql install`, which the Prisma Next flow skips (EQL installs through the `prisma-next` migration graph, which doesn't carry the tracking schema). The first checkpoint write then failed with an opaque relation-does-not-exist error. `backfill` now bootstraps it via the existing idempotent `installMigrationsSchema` before any event is written.

`skills/stash-cli` documents both, including that the `client` config option is not required in Prisma Next projects.
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

// Prisma Next projects have no hand-authored encryption client file — the
// schema lives in the emitted contract.json. `loadEncryptionContext`
// must derive the v3 schemas from the contract (mirroring the runtime's
// `cipherstashFromStack`) instead of hard-failing on the missing file, and
// `backfillCommand` must create `cipherstash.cs_migrations` itself because the
// Prisma Next EQL install path never runs `stash eql install`.

const fsMocks = vi.hoisted(() => ({
existsSync: vi.fn((_p: string) => false),
readFileSync: vi.fn((_p: string) => '{}'),
}))
vi.mock('node:fs', () => ({ default: fsMocks }))

const detectPrismaNextMock = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/commands/db/detect.js', () => ({
detectPrismaNext: detectPrismaNextMock,
detectDrizzle: vi.fn(() => false),
}))

const requireUsableEncryptConfigMock = vi.hoisted(() =>
vi.fn((config: unknown) => config),
)
vi.mock('@/config/index.js', () => ({
loadStashConfig: vi.fn(async () => ({
databaseUrl: 'postgres://test',
client: './src/encryption/index.ts',
})),
requireUsableEncryptConfig: requireUsableEncryptConfigMock,
}))

vi.mock('@/commands/init/utils.js', () => ({
detectPackageManager: vi.fn(() => 'npm'),
runnerCommand: vi.fn((_pm: string, ref: string) => `npx ${ref}`),
}))

vi.mock('@clack/prompts', () => ({
intro: vi.fn(),
outro: vi.fn(),
confirm: vi.fn(async () => true),
isCancel: vi.fn(() => false),
log: {
info: vi.fn(),
success: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
step: vi.fn(),
},
note: vi.fn(),
}))

const encryptionClientStub = vi.hoisted(() => ({
getEncryptConfig: vi.fn(() => ({ v: 2, tables: {} })),
}))
const deriveStackSchemasV3Mock = vi.hoisted(() =>
vi.fn((_contract: unknown): unknown[] => []),
)
const encryptionMock = vi.hoisted(() => vi.fn(async () => encryptionClientStub))
const jitiImportMock = vi.hoisted(() =>
vi.fn(async (specifier: string) => {
if (specifier === '@cipherstash/stack-prisma/v3') {
return { deriveStackSchemasV3: deriveStackSchemasV3Mock }
}
if (specifier === '@cipherstash/stack/v3') {
return { Encryption: encryptionMock }
}
throw new Error(`unexpected jiti import: ${specifier}`)
}),
)
vi.mock('jiti', () => ({
createJiti: vi.fn(() => ({ import: jitiImportMock })),
}))

const queryMock = vi.hoisted(() =>
vi.fn(async (_sql: string) => ({ rows: [] as unknown[] })),
)
vi.mock('pg', () => ({
default: {
Pool: class {
connect = vi.fn(async () => ({ query: queryMock, release: vi.fn() }))
end = vi.fn(async () => {})
},
},
}))

// The workspace `@cipherstash/stack` package resolves via its built dist in
// CI; mock the `schema` subpath with a zod enum matching the shape
// `translateCastAs` needs so this test doesn't depend on a prior build.
vi.mock('@cipherstash/stack/schema', async () => {
const { z } = await import('zod')
return {
castAsEnum: z.enum(['string', 'text', 'number', 'bigint']).default('text'),
toEqlCastAs: vi.fn((v: string) => v),
}
})

const migrateMocks = vi.hoisted(() => ({
appendEvent: vi.fn(async () => {}),
detectColumnEqlVersion: vi.fn(async () => 3),
installMigrationsSchema: vi.fn(async () => {}),
progress: vi.fn(async () => ({ phase: 'dual-writing' })),
runBackfill: vi.fn(async () => ({ rowsProcessed: 0, rowsTotal: 0 })),
upsertManifestColumn: vi.fn(async () => {}),
}))
vi.mock('@cipherstash/migrate', () => migrateMocks)

import { loadEncryptionContext } from '../context.js'

const FAKE_TABLE = {
tableName: 'transaction',
build: () => ({
tableName: 'transaction',
columns: { email_encrypted: { cast_as: 'text' } },
}),
}

/** process.exit that throws, so exit paths terminate the code under test. */
function spyExit() {
return vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code}`)
}) as never)
}

function spyConsoleError() {
return vi.spyOn(console, 'error').mockImplementation(() => {})
}

beforeEach(() => {
vi.clearAllMocks()
fsMocks.existsSync.mockImplementation(() => false)
fsMocks.readFileSync.mockImplementation(() => '{}')
detectPrismaNextMock.mockReturnValue(false)
deriveStackSchemasV3Mock.mockReturnValue([])
})

describe('loadEncryptionContext — Prisma Next contract derivation', () => {
it('still hard-fails on a missing client file outside Prisma Next projects', async () => {
const exit = spyExit()
const consoleError = spyConsoleError()

await expect(loadEncryptionContext()).rejects.toThrow('process.exit:1')
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('Encrypt client file not found'),
)
expect(jitiImportMock).not.toHaveBeenCalled()

exit.mockRestore()
consoleError.mockRestore()
})

it('derives tables and client from contract.json in a Prisma Next project', async () => {
detectPrismaNextMock.mockReturnValue(true)
fsMocks.existsSync.mockImplementation((p: string) =>
p.endsWith('src/prisma/contract.json'),
)
fsMocks.readFileSync.mockReturnValue('{"storage":{}}')
deriveStackSchemasV3Mock.mockReturnValue([FAKE_TABLE])

const ctx = await loadEncryptionContext()

expect(deriveStackSchemasV3Mock).toHaveBeenCalledWith({ storage: {} })
expect(encryptionMock).toHaveBeenCalledWith({ schemas: [FAKE_TABLE] })
expect(requireUsableEncryptConfigMock).toHaveBeenCalled()
expect(ctx.client).toBe(encryptionClientStub)
expect(ctx.tables.get('transaction')).toBe(FAKE_TABLE)
})

it('errors with `contract emit` guidance when no contract.json exists', async () => {
detectPrismaNextMock.mockReturnValue(true)
const exit = spyExit()
const consoleError = spyConsoleError()

await expect(loadEncryptionContext()).rejects.toThrow('process.exit:1')
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('prisma-next contract emit'),
)

exit.mockRestore()
consoleError.mockRestore()
})

// `deriveStackSchemasV3` comes from a jiti import, so its array return type
// is an assertion. A version skew that reshaped the export must not reach the
// `.length` read as a bare TypeError (#819 review).
it('errors when deriveStackSchemasV3 returns a non-array', async () => {
detectPrismaNextMock.mockReturnValue(true)
fsMocks.existsSync.mockImplementation((p: string) =>
p.endsWith('src/prisma/contract.json'),
)
deriveStackSchemasV3Mock.mockReturnValue(undefined as unknown as unknown[])
const exit = spyExit()
const consoleError = spyConsoleError()

await expect(loadEncryptionContext()).rejects.toThrow('process.exit:1')
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('not an array of tables'),
)

exit.mockRestore()
consoleError.mockRestore()
})

// `loadEncryptionContext` runs before `backfillCommand`'s own try/catch, so
// a throw from `Encryption()` — invalid credentials, a bad keyset — would
// escape as a raw stack trace rather than this function's guided message
// (#819 review).
it('errors with a credentials hint when Encryption() throws', async () => {
detectPrismaNextMock.mockReturnValue(true)
fsMocks.existsSync.mockImplementation((p: string) =>
p.endsWith('src/prisma/contract.json'),
)
deriveStackSchemasV3Mock.mockReturnValue([FAKE_TABLE])
encryptionMock.mockRejectedValueOnce(new Error('invalid keyset id'))
const exit = spyExit()
const consoleError = spyConsoleError()

await expect(loadEncryptionContext()).rejects.toThrow('process.exit:1')
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('Failed to initialize the encryption client'),
)
expect(requireUsableEncryptConfigMock).not.toHaveBeenCalled()

exit.mockRestore()
consoleError.mockRestore()
})

it('errors when the contract has no cipherstash columns', async () => {
detectPrismaNextMock.mockReturnValue(true)
fsMocks.existsSync.mockImplementation((p: string) =>
p.endsWith('prisma/contract.json'),
)
deriveStackSchemasV3Mock.mockReturnValue([])
const exit = spyExit()
const consoleError = spyConsoleError()

await expect(loadEncryptionContext()).rejects.toThrow('process.exit:1')
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('No cipherstash-encrypted columns'),
)

exit.mockRestore()
consoleError.mockRestore()
})
})

describe('backfillCommand — cs_migrations bootstrap', () => {
it('installs the migrations schema before running the backfill', async () => {
detectPrismaNextMock.mockReturnValue(true)
fsMocks.existsSync.mockImplementation((p: string) =>
p.endsWith('src/prisma/contract.json'),
)
deriveStackSchemasV3Mock.mockReturnValue([FAKE_TABLE])

const { backfillCommand } = await import('../backfill.js')
await backfillCommand({
table: 'transaction',
column: 'email',
pkColumn: 'id',
confirmDualWritesDeployed: true,
})

expect(migrateMocks.installMigrationsSchema).toHaveBeenCalledTimes(1)
expect(migrateMocks.runBackfill).toHaveBeenCalledTimes(1)
const installOrder =
migrateMocks.installMigrationsSchema.mock.invocationCallOrder[0]!
const backfillOrder = migrateMocks.runBackfill.mock.invocationCallOrder[0]!
expect(installOrder).toBeLessThan(backfillOrder)
})

// Without the BackfillConfigError wrapper this lands in the generic handler,
// which deliberately prints only "Backfill failed (…)" — the opaque message
// the bootstrap exists to remove (#819 review).
it('explains a bootstrap failure instead of the generic "Backfill failed"', async () => {
detectPrismaNextMock.mockReturnValue(true)
fsMocks.existsSync.mockImplementation((p: string) =>
p.endsWith('src/prisma/contract.json'),
)
deriveStackSchemasV3Mock.mockReturnValue([FAKE_TABLE])
migrateMocks.installMigrationsSchema.mockRejectedValueOnce(
new Error('permission denied for database app'),
)
const exit = spyExit()
const p = await import('@clack/prompts')

const { backfillCommand } = await import('../backfill.js')
await expect(
backfillCommand({
table: 'transaction',
column: 'email',
pkColumn: 'id',
confirmDualWritesDeployed: true,
}),
).rejects.toThrow('process.exit:1')

expect(migrateMocks.runBackfill).not.toHaveBeenCalled()
const logged = vi
.mocked(p.log.error)
.mock.calls.map(([m]) => m)
.join('\n')
expect(logged).toContain('cs_migrations')
expect(logged).toContain('permission denied for database app')
expect(logged).not.toContain('Backfill failed')

exit.mockRestore()
})
})
22 changes: 22 additions & 0 deletions packages/cli/src/commands/encrypt/backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
appendEvent,
columnExists,
detectColumnEqlVersion,
installMigrationsSchema,
type ManifestColumn,
progress,
runBackfill,
Expand Down Expand Up @@ -131,6 +132,27 @@ export async function backfillCommand(options: BackfillCommandOptions) {
process.on('SIGINT', onSignal)
process.on('SIGTERM', onSignal)
db = await pool.connect()

// `stash eql install` normally creates `cipherstash.cs_migrations`, but
// not every integration runs it — Prisma Next installs EQL through its
// own migration graph, which doesn't carry the tracking schema. The DDL
// is CREATE IF NOT EXISTS throughout, so this is a no-op everywhere else.
//
// Rethrown as a config error: this is a known prerequisite with a known
// fix (usually CREATE privileges), and the generic handler below would
// reduce it to "Backfill failed" — the opaque message this whole pass
// exists to remove (#819 review). The DDL touches no row data, so its
// message is safe to surface verbatim.
try {
await installMigrationsSchema(db)
} catch (error) {
throw new BackfillConfigError(
'Could not create the `cipherstash.cs_migrations` tracking schema, which backfill needs to checkpoint progress.\n\n' +
`Cause: ${error instanceof Error ? error.message : String(error)}\n\n` +
'This database user needs CREATE on the database (to create the `cipherstash` schema) and on that schema. Grant it, or have someone run `stash eql install` once, then re-run.',
)
}

const pkColumn =
options.pkColumn ?? (await detectPkColumn(db, options.table))

Expand Down
Loading
Loading