Skip to content

Commit 0811330

Browse files
authored
feat(cli): add stash eql migration --drizzle (v3, migration-first EQL install) (#691)
* 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 * fix(cli): harden `stash eql migration` per review (#691) Addresses the review on #691 — correctness, security, telemetry, and coverage: - Run the PROJECT-LOCAL drizzle-kit (`pnpm exec` / `npx --no-install`), not the `pnpm dlx`/`yarn dlx` download-and-run form, so it resolves the project's own drizzle.config.ts + schema. New shared `execCommand`/`execArgv` in init/utils (setup-prompt's private copy folded in). - Invoke via `spawnSync` with an argv array instead of `execSync` on a shell string — a `--name` with spaces or shell metacharacters is now one inert token (no word-splitting, no command injection). Plus a `[\w-]+` name guard. - Pass `--out` through to `drizzle-kit generate` (always) so the flag actually steers where the migration is written, and our lookup can't miss it. - Validation exits and every abort throw `CliExit(1)` instead of `process.exit`, so the telemetry `finally` runs and the branches are unit-testable. - Guard `loadBundledEqlSql` (corrupt bundle → clean error, not a fatal trace), add the missing `p.intro`, and call `printNextSteps()` so the now-preferred install path isn't less helpful than `eql install`. - HELP banner lists `eql migration`. Tests: 14 migration unit (target selection incl. `--supabase`-alone, name guard, dry-run, argv threading, non-zero drizzle-kit, write-failure cleanup, SQL order), 3 `findGeneratedMigration` unit (now public API), e2e smoke + `eql migration --help`. 557 unit / 65 e2e green, code:check clean. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w * fix(cli): bun exec must not surprise-download; drop type-erased test cast CodeRabbit review on #691: - `execArgv`/`execCommand` bun case emitted `bun x`, which auto-installs a missing binary from npm — contradicting the "run a project-local binary, never surprise-download" contract these helpers exist to honour (the npm case already passes `--no-install`). Bun supports `--no-install`; pass it for both the argv and shell forms. Updated the setup-prompt assertion to match. - migration.test.ts: replaced the type-erasing `undefined as unknown as writeFileSync` hoisted-mock placeholder with a throwing placeholder cast to the specific type — fails loud if the mock factory never runs, and drops the `as unknown` (test files are plugin-exempt, but this is cleaner). CLI build + 566 unit tests green; biome clean. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w * docs(cli): correct stale `--prisma` copy now that prisma-next v3 has shipped Dan's review on #691: the `stash eql migration --prisma` copy said the flag "ships with prisma-next EQL v3 support" / "not available yet" — but that support has shipped (#655/#683/#685), and Prisma Next installs EQL v3 through its OWN migration system (`prisma-next migration apply`), not through this command. Reframed all five spots to current reality (not "coming soon", but "use prisma-next migration apply"): the user-facing message + its doc comment, the `eqlMigrationCommand` code comment, the `--prisma` flag help in the registry, the changeset, and the stash-cli skill (which ships to customers). No behaviour change — `--prisma` still fails with a pointer. Build + 566 tests green. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w * docs(cli): correct `--prisma` copy to match #690 (planned emitter, not "use prisma-next") My previous pass framed `--prisma` as "not supported — use `prisma-next migration apply`", which contradicts #690: the `--prisma` emitter IS a planned follow-up (PR3) that writes the install migration in the framework `Migration` shape and lets prisma-next DROP its baked install baseline. Pointing users at `prisma-next migration apply` is the very approach #690 replaces. Reframe all five spots to "not available yet — the Prisma Next emitter is a follow-up tracked in #690; use `--drizzle` today": the message + its doc comment, the `eqlMigrationCommand` code comment, the `--prisma` flag help, the changeset, and the stash-cli skill. (The `stash eql install` guard + stash-prisma-next skill keep their `prisma-next migration apply` wording — that correctly describes the current baked-baseline install, which is a different thing from this CLI emitter.) No behaviour change. Build + 566 tests green. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
1 parent 4923c0a commit 0811330

15 files changed

Lines changed: 705 additions & 29 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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 — the Prisma Next migration
21+
emitter is a follow-up (tracked in cipherstash/stack#690) that will let
22+
prisma-next drop its baked install baseline. It fails with a pointer for now.

packages/cli/src/bin/main.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ Commands:
108108
telemetry <sub> Manage anonymous usage analytics (status, enable, disable)
109109
110110
eql install Scaffold stash.config.ts (if missing) and install EQL extensions
111+
eql migration Generate an EQL v3 install migration for your ORM (Drizzle)
111112
eql upgrade Upgrade EQL extensions to the latest version
112113
eql status Show EQL installation status
113114
@@ -232,6 +233,20 @@ async function runEqlCommand(
232233
case 'install':
233234
await runInstall(flags, values)
234235
break
236+
case 'migration': {
237+
const { eqlMigrationCommand } = await import(
238+
'../commands/eql/migration.js'
239+
)
240+
await eqlMigrationCommand({
241+
drizzle: flags.drizzle,
242+
prisma: flags.prisma,
243+
supabase: flags.supabase,
244+
name: values.name,
245+
out: values.out,
246+
dryRun: flags['dry-run'],
247+
})
248+
break
249+
}
235250
case 'upgrade':
236251
await runUpgrade(flags, values)
237252
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 — not available yet; the emitter is a follow-up tracked in cipherstash/stack#690.',
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). Letters, numbers, dashes, underscores only. Defaults to `install-eql`.',
409+
},
410+
{
411+
name: '--out',
412+
value: '<path>',
413+
description:
414+
'Directory drizzle-kit writes the migration into (passed to `drizzle-kit generate --out`). Defaults to `drizzle`; set it to match your drizzle.config.ts.',
415+
},
416+
DRY_RUN_FLAG,
417+
],
418+
},
380419
{
381420
name: 'eql upgrade',
382421
summary: 'Upgrade EQL extensions to the latest version',
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5+
import { findGeneratedMigration } from '../install.js'
6+
7+
/**
8+
* `findGeneratedMigration` was promoted to public API by the `eql migration`
9+
* command and now has two consumers (`db install --drizzle` and
10+
* `eql migration --drizzle`), so its branches are pinned directly — a change for
11+
* one consumer must not silently break the other.
12+
*/
13+
describe('findGeneratedMigration', () => {
14+
let dir: string
15+
beforeEach(() => {
16+
dir = mkdtempSync(join(tmpdir(), 'stash-find-migration-'))
17+
})
18+
afterEach(() => {
19+
rmSync(dir, { recursive: true, force: true })
20+
})
21+
22+
it('throws when the out directory does not exist', async () => {
23+
await expect(
24+
findGeneratedMigration(join(dir, 'nope'), 'install-eql'),
25+
).rejects.toThrow(/output directory not found/)
26+
})
27+
28+
it('throws when no .sql file matches the migration name', async () => {
29+
writeFileSync(join(dir, '0000_other.sql'), '')
30+
await expect(findGeneratedMigration(dir, 'install-eql')).rejects.toThrow(
31+
/Could not find a migration matching "install-eql"/,
32+
)
33+
})
34+
35+
it('returns the highest-numbered match, ignoring non-.sql and non-matching entries', async () => {
36+
for (const f of [
37+
'0000_install-eql.sql',
38+
'0010_install-eql.sql',
39+
'0011_install-eql.txt', // not .sql
40+
'0001_users.sql', // doesn't match the name
41+
]) {
42+
writeFileSync(join(dir, f), '')
43+
}
44+
// Relies on drizzle-kit's zero-padded 4-digit prefix for lexical == numeric.
45+
expect(await findGeneratedMigration(dir, 'install-eql')).toBe(
46+
join(dir, '0010_install-eql.sql'),
47+
)
48+
})
49+
})

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ function resolveProviderOptions(
413413
return { supabase, drizzle, excludeOperatorFamily }
414414
}
415415

416-
function printNextSteps(): void {
416+
export function printNextSteps(): void {
417417
p.note(
418418
[
419419
'Your project is set up. To encrypt your first column, pick the path',
@@ -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 {

0 commit comments

Comments
 (0)