Skip to content

Commit 01d9381

Browse files
committed
feat(cli): add stash eql migration --drizzle (v3, migration-first EQL install)
First of the three PRs in #690: make migration-first, ORM-agnostic EQL v3 install the front door, so every integration sources install SQL from one place (the CLI's bundled variants) instead of vendoring its own. This is the pattern that keeps Drizzle Supabase-safe; prisma-next's superuser-on-Supabase failure is fixed by the stacked follow-ups (`--prisma` emitter + removing prisma-next's baked baseline), which land on top of the prisma-next EQL v3 work (#655). `stash eql migration --drizzle [--supabase] [--name] [--out] [--dry-run]`: - Generates a Drizzle custom migration (via `drizzle-kit generate --custom`, then injects the SQL) carrying the bundled EQL **v3** install script + the `cs_migrations` tracking schema — one `drizzle-kit migrate` does everything `stash encrypt …` needs. - `--supabase` appends the v3 role grants (`eql_v3` + `eql_v3_internal` → anon/authenticated/service_role), matching `stash eql install --supabase`. - v3 only — no `--eql-version` (prisma-next never shipped v2). - `--prisma` is registered but fails with a pointer to #690 until the stacked PR. The SQL assembly (`buildEqlV3MigrationSql`) is a pure, unit-tested function; the drizzle-kit scaffold/inject orchestration reuses the proven helpers from the v2 `install --drizzle` path (`findGeneratedMigration`, `cleanupMigrationFile`, now exported). Registry + dispatch + help + `stash-cli`/`stash-drizzle` skills updated. Tests: 4 new unit (bundle present, grants gated on --supabase, tracking schema), 544 CLI unit green, manifest resolves the command, biome clean. Changeset: stash minor. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
1 parent 4923c0a commit 01d9381

9 files changed

Lines changed: 338 additions & 5 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'stash': minor
3+
---
4+
5+
Add `stash eql migration` — generate an EQL **v3** install migration for your ORM
6+
instead of running the SQL directly against the database (`stash eql install`).
7+
Migration-first is the preferred path: the install lands in your migration history
8+
and ships to every environment through the ORM's own migrate step.
9+
10+
```bash
11+
stash eql migration --drizzle # Drizzle custom migration
12+
stash eql migration --drizzle --supabase # also grants eql_v3 to anon/authenticated/service_role
13+
```
14+
15+
The migration carries the CLI's bundled v3 install SQL (one source of truth) plus
16+
the `cs_migrations` tracking schema, so a single `drizzle-kit migrate` covers
17+
everything `stash encrypt …` needs. `--supabase` appends the `eql_v3` +
18+
`eql_v3_internal` role grants for PostgREST/RLS access.
19+
20+
`--prisma` is registered but not available yet — it ships with prisma-next EQL v3
21+
support and fails with a pointer until then.

packages/cli/src/bin/main.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,20 @@ async function runEqlCommand(
232232
case 'install':
233233
await runInstall(flags, values)
234234
break
235+
case 'migration': {
236+
const { eqlMigrationCommand } = await import(
237+
'../commands/eql/migration.js'
238+
)
239+
await eqlMigrationCommand({
240+
drizzle: flags.drizzle,
241+
prisma: flags.prisma,
242+
supabase: flags.supabase,
243+
name: values.name,
244+
out: values.out,
245+
dryRun: flags['dry-run'],
246+
})
247+
break
248+
}
235249
case 'upgrade':
236250
await runUpgrade(flags, values)
237251
break

packages/cli/src/cli/registry.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,45 @@ export const registry: CommandGroup[] = [
377377
DATABASE_URL_FLAG,
378378
],
379379
},
380+
{
381+
name: 'eql migration',
382+
summary:
383+
'Generate an EQL v3 install migration for your ORM (Drizzle now; Prisma Next soon)',
384+
examples: [
385+
'eql migration --drizzle',
386+
'eql migration --drizzle --supabase',
387+
],
388+
flags: [
389+
{
390+
name: '--drizzle',
391+
description:
392+
'Emit a Drizzle custom migration containing the EQL v3 install SQL.',
393+
},
394+
{
395+
name: '--prisma',
396+
description:
397+
'Emit a Prisma Next migration (ships with prisma-next EQL v3 support; not available yet).',
398+
},
399+
{
400+
name: '--supabase',
401+
description:
402+
'Append the Supabase role grants (eql_v3 + eql_v3_internal for anon/authenticated/service_role).',
403+
},
404+
{
405+
name: '--name',
406+
value: '<name>',
407+
description:
408+
'Name for the generated migration (Drizzle). Defaults to `install-eql`.',
409+
},
410+
{
411+
name: '--out',
412+
value: '<path>',
413+
description:
414+
'Directory to write the generated migration into (Drizzle). Defaults to `drizzle`.',
415+
},
416+
DRY_RUN_FLAG,
417+
],
418+
},
380419
{
381420
name: 'eql upgrade',
382421
summary: 'Upgrade EQL extensions to the latest version',

packages/cli/src/commands/db/install.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -887,7 +887,7 @@ async function writeSupabaseMigrationFile(
887887
* Find the most recently generated migration file matching the given name.
888888
* Drizzle-kit generates flat SQL files like `0000_install-eql.sql`.
889889
*/
890-
async function findGeneratedMigration(
890+
export async function findGeneratedMigration(
891891
outDir: string,
892892
migrationName: string,
893893
): Promise<string> {
@@ -915,7 +915,7 @@ async function findGeneratedMigration(
915915
/**
916916
* Attempt to clean up a generated migration file on failure.
917917
*/
918-
function cleanupMigrationFile(filePath: string | undefined): void {
918+
export function cleanupMigrationFile(filePath: string | undefined): void {
919919
if (!filePath) return
920920

921921
try {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { buildEqlV3MigrationSql } from '../migration.js'
3+
4+
/**
5+
* `buildEqlV3MigrationSql` is the pure core of `stash eql migration --drizzle`:
6+
* it assembles the migration contents from the CLI's bundled v3 install SQL,
7+
* the optional Supabase grants, and the `cs_migrations` tracking schema. The
8+
* file-writing orchestration around it (drizzle-kit scaffold + inject) is thin
9+
* and I/O-bound, so the assembly is where the contract lives.
10+
*/
11+
describe('buildEqlV3MigrationSql', () => {
12+
it('emits the EQL v3 install bundle and the cs_migrations tracking schema', () => {
13+
const sql = buildEqlV3MigrationSql({ supabase: false })
14+
// v3 bundle, not v2 — the whole point of the command.
15+
expect(sql).toContain('EQL v3 schema creation')
16+
expect(sql).toContain('eql_v3')
17+
// Bundled tracking schema so one migration run is enough for `stash encrypt`.
18+
expect(sql).toContain('cs_migrations')
19+
})
20+
21+
it('omits the Supabase role grants without --supabase', () => {
22+
const sql = buildEqlV3MigrationSql({ supabase: false })
23+
expect(sql).not.toContain('TO anon, authenticated, service_role')
24+
expect(sql).not.toContain('-- Supabase role grants')
25+
})
26+
27+
it('appends the eql_v3 + eql_v3_internal grants with --supabase', () => {
28+
const sql = buildEqlV3MigrationSql({ supabase: true })
29+
expect(sql).toContain('-- Supabase role grants')
30+
expect(sql).toContain(
31+
'GRANT USAGE ON SCHEMA eql_v3 TO anon, authenticated, service_role',
32+
)
33+
expect(sql).toContain(
34+
'GRANT USAGE ON SCHEMA eql_v3_internal TO anon, authenticated, service_role',
35+
)
36+
})
37+
38+
it('is a superset of the non-supabase content when --supabase is set', () => {
39+
const base = buildEqlV3MigrationSql({ supabase: false })
40+
const supa = buildEqlV3MigrationSql({ supabase: true })
41+
// The grants are additive: everything in the base still appears.
42+
expect(supa.length).toBeGreaterThan(base.length)
43+
expect(supa).toContain('EQL v3 schema creation')
44+
expect(supa).toContain('cs_migrations')
45+
})
46+
})
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { execSync } from 'node:child_process'
2+
import { writeFileSync } from 'node:fs'
3+
import { resolve } from 'node:path'
4+
import { MIGRATIONS_SCHEMA_SQL } from '@cipherstash/migrate'
5+
import * as p from '@clack/prompts'
6+
import {
7+
cleanupMigrationFile,
8+
findGeneratedMigration,
9+
} from '@/commands/db/install.js'
10+
import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
11+
import { loadBundledEqlSql, supabaseGrantsFor } from '@/installer/index.js'
12+
import { messages } from '@/messages.js'
13+
14+
const DEFAULT_MIGRATION_NAME = 'install-eql'
15+
const DEFAULT_DRIZZLE_OUT = 'drizzle'
16+
17+
export interface EqlMigrationOptions {
18+
/** Emit a Drizzle custom migration. */
19+
drizzle?: boolean
20+
/** Emit a Prisma Next migration (not yet available — see issue #690). */
21+
prisma?: boolean
22+
/** Append the Supabase role grants (`eql_v3` + `eql_v3_internal`). */
23+
supabase?: boolean
24+
/** Migration name (Drizzle). Defaults to `install-eql`. */
25+
name?: string
26+
/** Output directory (Drizzle). Defaults to `drizzle`. */
27+
out?: string
28+
/** Describe what would happen without writing anything. */
29+
dryRun?: boolean
30+
}
31+
32+
/**
33+
* Assemble the EQL **v3** install SQL for a generated migration.
34+
*
35+
* One source of truth: the SQL is the CLI's bundled v3 install script
36+
* (`loadBundledEqlSql({ eqlVersion: 3 })`) — the same bundle `stash eql install`
37+
* applies directly. On `--supabase` the v3 role grants are appended
38+
* (`supabaseGrantsFor(3)` → USAGE/EXECUTE on `eql_v3` + `eql_v3_internal` for
39+
* `anon`/`authenticated`/`service_role`), matching `stash eql install --supabase`.
40+
* Apps that connect directly as `postgres` don't need the grants, but they're
41+
* idempotent and harmless, and required when the same tables are reached via
42+
* PostgREST/RLS.
43+
*
44+
* The `cs_migrations` tracking schema is bundled in so a single migration run
45+
* installs everything `stash encrypt …` needs — no out-of-band `stash eql
46+
* install`.
47+
*/
48+
export function buildEqlV3MigrationSql(opts: { supabase: boolean }): string {
49+
const eqlSql = loadBundledEqlSql({ eqlVersion: 3 })
50+
const grants = opts.supabase
51+
? `\n\n-- Supabase role grants: let anon/authenticated/service_role use the\n-- eql_v3 + eql_v3_internal schemas (required when tables are reached via\n-- PostgREST/RLS; harmless otherwise).\n${supabaseGrantsFor(3).trim()}`
52+
: ''
53+
return `${eqlSql.trim()}${grants}\n\n-- CipherStash encryption-migration tracking schema.\n-- Tracks per-column phase + backfill progress for \`stash encrypt\`.\n${MIGRATIONS_SCHEMA_SQL.trim()}\n`
54+
}
55+
56+
/**
57+
* `stash eql migration` — generate an EQL v3 install migration for the target
58+
* ORM, rather than running SQL directly against the database (that's `stash eql
59+
* install`). Migration-first is the preferred path: the install lands in the
60+
* project's migration history and ships to every environment through the ORM's
61+
* own migrate step.
62+
*
63+
* v3 only — there is no `--eql-version` here. prisma-next never shipped v2, and
64+
* the Drizzle v3 surface is the documented one.
65+
*/
66+
export async function eqlMigrationCommand(
67+
options: EqlMigrationOptions,
68+
): Promise<void> {
69+
const targets = [
70+
options.drizzle && 'drizzle',
71+
options.prisma && 'prisma',
72+
].filter(Boolean)
73+
if (targets.length === 0) {
74+
p.log.error(messages.eql.migrationNeedsTarget)
75+
process.exit(1)
76+
}
77+
if (targets.length > 1) {
78+
p.log.error(messages.eql.migrationOneTarget)
79+
process.exit(1)
80+
}
81+
82+
if (options.prisma) {
83+
// The prisma-next emitter ships stacked on the prisma-next EQL v3 work
84+
// (PR #655); it can't install a v3 schema that doesn't exist on that
85+
// surface yet. Fail loudly with a pointer rather than emit a broken file.
86+
p.log.error(messages.eql.migrationPrismaUnavailable)
87+
process.exit(1)
88+
}
89+
90+
await generateDrizzleEqlMigration(options)
91+
}
92+
93+
async function generateDrizzleEqlMigration(
94+
options: EqlMigrationOptions,
95+
): Promise<void> {
96+
const migrationName = options.name ?? DEFAULT_MIGRATION_NAME
97+
const outDir = resolve(options.out ?? DEFAULT_DRIZZLE_OUT)
98+
const runner = runnerCommand(detectPackageManager(), '').trim()
99+
const drizzleCmd = `${runner} drizzle-kit generate --custom --name=${migrationName}`
100+
101+
const sql = buildEqlV3MigrationSql({ supabase: options.supabase ?? false })
102+
103+
if (options.dryRun) {
104+
p.note(
105+
`Would run: ${drizzleCmd}\nWould write the EQL v3 install SQL${options.supabase ? ' (with Supabase grants)' : ''} into the generated migration in ${outDir}`,
106+
'Dry Run',
107+
)
108+
p.outro('Dry run complete.')
109+
return
110+
}
111+
112+
const s = p.spinner()
113+
114+
// Step 1 — scaffold an empty custom migration (drizzle-kit owns the journal
115+
// + sequence numbering; hand-rolling that is fragile).
116+
s.start('Generating custom Drizzle migration...')
117+
try {
118+
execSync(drizzleCmd, { stdio: 'pipe', encoding: 'utf-8' })
119+
s.stop('Custom Drizzle migration generated.')
120+
} catch (error) {
121+
s.stop('Failed to generate migration.')
122+
const stderr =
123+
error !== null &&
124+
typeof error === 'object' &&
125+
'stderr' in error &&
126+
typeof error.stderr === 'string'
127+
? error.stderr.trim()
128+
: undefined
129+
p.log.error(
130+
stderr || (error instanceof Error ? error.message : 'Unknown error.'),
131+
)
132+
p.log.info('Make sure drizzle-kit is installed: npm install -D drizzle-kit')
133+
p.outro('Migration aborted.')
134+
process.exit(1)
135+
}
136+
137+
// Step 2 — locate the file drizzle-kit just wrote.
138+
let migrationPath: string
139+
s.start('Locating generated migration file...')
140+
try {
141+
migrationPath = await findGeneratedMigration(outDir, migrationName)
142+
s.stop(`Found migration: ${migrationPath}`)
143+
} catch (error) {
144+
s.stop('Failed to locate migration file.')
145+
p.log.error(error instanceof Error ? error.message : String(error))
146+
p.outro('Migration aborted.')
147+
process.exit(1)
148+
}
149+
150+
// Step 3 — write the EQL v3 install SQL into it.
151+
s.start('Writing EQL v3 install SQL into migration file...')
152+
try {
153+
writeFileSync(migrationPath, sql, 'utf-8')
154+
s.stop('EQL v3 install SQL written.')
155+
} catch (error) {
156+
s.stop('Failed to write migration file.')
157+
p.log.error(error instanceof Error ? error.message : String(error))
158+
cleanupMigrationFile(migrationPath)
159+
p.outro('Migration aborted.')
160+
process.exit(1)
161+
}
162+
163+
p.log.success(`Migration created: ${migrationPath}`)
164+
p.note(
165+
`Run your Drizzle migrations to install EQL v3:\n\n ${runner} drizzle-kit migrate`,
166+
'Next Steps',
167+
)
168+
p.outro('Done!')
169+
}

packages/cli/src/messages.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,20 @@ export const messages = {
6666
* actionable command + `--force` note are appended at the call site.
6767
*/
6868
prismaNextDetected: 'This looks like a Prisma Next project',
69+
/** `stash eql migration` with no `--drizzle`/`--prisma` target. */
70+
migrationNeedsTarget:
71+
'Specify a target: `stash eql migration --drizzle` (or `--prisma`).',
72+
/** More than one target passed to `stash eql migration`. */
73+
migrationOneTarget:
74+
'Pass exactly one target: `--drizzle` or `--prisma`, not both.',
75+
/**
76+
* `--prisma` isn't wired yet — Prisma Next installs EQL v3 through its own
77+
* migration system (`prisma-next migration apply`), so the CLI emitter is
78+
* not needed there today. Points at the tracking issue so the failure is
79+
* actionable.
80+
*/
81+
migrationPrismaUnavailable:
82+
'`stash eql migration --prisma` is not available yet — Prisma Next installs EQL v3 via its own migration system (`prisma-next migration apply`, see cipherstash/stack#690). Use `--drizzle` today.',
6983
},
7084
db: {
7185
unknownSubcommand: 'Unknown db subcommand',

skills/stash-cli/SKILL.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ Flags below are the decision-relevant ones. Run `stash <command> --help` for the
334334

335335
```bash
336336
stash eql install
337+
stash eql migration --drizzle
337338
stash eql upgrade
338339
stash eql status
339340
```
@@ -359,14 +360,34 @@ Gets a project from zero to installed EQL. It loads an existing `stash.config.ts
359360

360361
`--migration`, `--direct`, and `--migrations-dir` require an explicit `--supabase`; they never auto-enable it.
361362

362-
**EQL v3 installs via the direct path only.** Passing `--eql-version 3` with an explicit `--drizzle`, `--migration`, `--migrations-dir`, or `--latest` is an error. When Drizzle or a Supabase migrations directory is merely *auto-detected*, v3 falls back to a direct install and prints a notice.
363+
**`eql install` for EQL v3 runs the direct path only.** Passing `--eql-version 3` with an explicit `--drizzle`, `--migration`, `--migrations-dir`, or `--latest` is an error. When Drizzle or a Supabase migrations directory is merely *auto-detected*, v3 falls back to a direct install and prints a notice. To get a **v3 install as a migration** (preferred for real projects), use `eql migration` (below) instead of `eql install --drizzle`.
363364

364365
**`--database-url` is a one-shot.** It installs against that database and leaves the project untouched — no config is loaded, and none is scaffolded, nor is an encryption client. This lets `npx stash eql install --database-url postgres://...` run in a bare project with no CipherStash dependencies. It also means the flag always wins: loading a config could pick up a parent-directory `databaseUrl` literal and install against the wrong database.
365366

366367
**`eql install --supabase --migration`** writes `supabase/migrations/00000000000000_cipherstash_eql.sql`. The all-zero timestamp guarantees it runs before any user migration referencing `eql_v2_encrypted`. Apply with `supabase db reset` (local) or `supabase migration up` (remote).
367368

368369
Direct installs (`--supabase --direct`) do **not** survive `supabase db reset` — the reset drops the database and replays only files in `supabase/migrations/`. Use `--migration` if you reset.
369370

371+
#### `eql migration`
372+
373+
Generates an **EQL v3 install migration** for your ORM, instead of running SQL directly against the database (`eql install`). Migration-first is the preferred path: the install lands in your migration history and ships to every environment through the ORM's own migrate step. v3 only — there is no `--eql-version` here.
374+
375+
```bash
376+
stash eql migration --drizzle # Drizzle custom migration in drizzle/
377+
stash eql migration --drizzle --supabase # also grant eql_v3 to anon/authenticated/service_role
378+
```
379+
380+
| Flag | Description |
381+
|---|---|
382+
| `--drizzle` | Emit a Drizzle custom migration (via `drizzle-kit generate --custom`, then inject the SQL). Requires `drizzle-kit`. |
383+
| `--prisma` | Emit a Prisma Next migration. **Not available yet** — ships with prisma-next EQL v3 support; fails with a pointer until then. |
384+
| `--supabase` | Append the Supabase role grants (`eql_v3` + `eql_v3_internal``anon`, `authenticated`, `service_role`). Harmless when you connect directly as `postgres`; needed when the same tables are reached via PostgREST/RLS. |
385+
| `--name <name>` | Migration name (Drizzle). Default `install-eql`. |
386+
| `--out <path>` | Output directory (Drizzle). Default `drizzle`. |
387+
| `--dry-run` | Show what would happen without writing anything. |
388+
389+
Pass exactly one of `--drizzle` / `--prisma`. The generated migration also installs the `cs_migrations` tracking schema, so one `drizzle-kit migrate` covers everything `stash encrypt …` needs.
390+
370391
#### `eql upgrade`
371392

372393
The install SQL is idempotent. `upgrade` checks the current version, re-runs it, and reports the new one. If EQL isn't installed it points you at `eql install`. Same `--supabase`, `--exclude-operator-family`, `--eql-version`, `--latest`, `--dry-run`, `--database-url` flags.

0 commit comments

Comments
 (0)