|
| 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 | +} |
0 commit comments