Skip to content
Open
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
17 changes: 17 additions & 0 deletions .changeset/supabase-eql-migration-file.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'stash': minor
---

Add `stash eql migration --supabase`, so an EQL v3 install survives `supabase db reset` (#613).

Supabase projects previously had only `stash eql install --supabase`, which applies the SQL directly to a running database. `supabase db reset` — the ordinary local development loop — drops that database and replays `supabase/migrations/`, so the install was wiped and the next query failed with `type "eql_v3_encrypted" does not exist`. There was no supported way to get EQL into the migrations directory.

`stash eql migration --supabase` now writes `supabase/migrations/<timestamp>_cipherstash_eql.sql`, carrying the EQL v3 bundle, the `anon` / `authenticated` / `service_role` grants, and the `cipherstash.cs_migrations` tracking schema — so one `supabase db reset` provisions everything `stash encrypt` needs. The file is timestamped at generation time, so it sorts after everything already applied and pushes without `--include-all`. A second run exits rather than adding a duplicate install; `--force` regenerates the existing one in place, and `--out <dir>` targets a non-default migrations directory.

`--supabase` keeps its existing meaning alongside `--drizzle` (append the role grants to the Drizzle migration); only a bare `--supabase` selects the new emitter.

`stash init --supabase` now generates that migration instead of installing directly, when the project has local `supabase/` scaffolding — a hosted project without it still installs directly. Re-running init over a project that already has an install migration reports it and moves on, rather than treating the duplicate refusal as a failed setup. Its next steps no longer tell you to run `eql install --supabase` and then `supabase db reset`, which was the exact sequence that destroyed the install.

Also corrects the remote apply command across the Supabase guidance: a bare `supabase migration up` targets the local database, so the instructions now say `supabase db push`.

Also corrects the `eql install --migration` removal message, which pointed every Supabase user at `--drizzle`.
2 changes: 1 addition & 1 deletion e2e/tests/package-managers.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe('CLI init providers — package-manager-aware Next Steps', () => {
label: 'supabase',
create: createSupabaseProvider,
firstStep: (r) =>
`Install EQL: ${r} stash eql install --supabase (prompts for migration vs direct)`,
`Install EQL: ${r} stash eql migration --supabase (writes it into supabase/migrations/)`,
},
]

Expand Down
22 changes: 20 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,11 @@ Reads `databaseUrl` from `stash.config.ts`.

---

## Drizzle migration mode
## Migration mode

Use `eql migration --drizzle` to add EQL v3 installation to Drizzle migration history instead of applying it directly.
Use `eql migration` to add the EQL v3 installation to your migration history instead of applying it directly. The install then ships to every environment through the same migrate step as the rest of your schema.

### Drizzle

```bash
npx stash eql migration --drizzle
Expand All @@ -306,6 +308,22 @@ npx drizzle-kit migrate

`drizzle-kit` must be installed in your project (`npm install -D drizzle-kit`). The `--out` directory must match your `drizzle.config.ts`.

Add `--supabase` on a Supabase-hosted Drizzle project to append the `anon` / `authenticated` / `service_role` grants.

### Supabase

```bash
npx stash eql migration --supabase
supabase db reset # local
supabase db push # remote/linked project
```

This writes `supabase/migrations/<timestamp>_cipherstash_eql.sql` containing the EQL v3 bundle, the Supabase role grants, and the `cipherstash.cs_migrations` tracking schema — so one reset provisions everything `stash encrypt` needs.

**Use this rather than `eql install --supabase` whenever the project has a local `supabase/` directory.** A direct install does not survive `supabase db reset`, which drops the database and replays the migrations directory.

The file is timestamped at generation time, so it sorts after everything already applied and pushes without `--include-all`. Pass `--out <dir>` if your migrations live elsewhere, and `--force` to regenerate an existing install migration in place.

---

### `npx stash eql repair --drizzle`
Expand Down
63 changes: 63 additions & 0 deletions packages/cli/src/__tests__/skill-supabase-apply.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { readdirSync, readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'

const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const REPO_ROOT = resolve(CLI_ROOT, '../..')
const SKILLS_ROOT = resolve(REPO_ROOT, 'skills')

/**
* `supabase migration up` applies to the **local** database. The remote forms
* are `supabase db push` and `supabase migration up --linked`.
*
* Skills ship inside the `stash` tarball and are copied into customer repos, so
* naming the local command as the remote one is not a typo — it means a user
* follows the instructions, believes production has EQL, and every encrypted
* query there fails at runtime. Nothing else checks these files, which is why
* this guard exists (same reasoning as the version-pin guard in
* `release-train.test.ts`).
*
* The rule: any `supabase migration up` in a shipped skill must either carry
* `--linked` or be immediately qualified as the local command ("… applies to
* the local database"). A nearby "locally" is not enough — the wording this
* guard exists to catch, "apply with `supabase migration up` (or `supabase db
* reset` locally)", has one, attached to the other command.
*/
const SKILL_FILES = readdirSync(SKILLS_ROOT, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => ({
skill: entry.name,
body: readFileSync(resolve(SKILLS_ROOT, entry.name, 'SKILL.md'), 'utf8'),
}))

describe('skills — Supabase apply commands', () => {
it('finds skills to check (a moved directory must not silently pass)', () => {
expect(SKILL_FILES.length).toBeGreaterThan(0)
})

it.each(
SKILL_FILES,
)('$skill never presents a bare `supabase migration up` as the remote apply', ({
body,
}) => {
// Collapse wrapping and drop markdown emphasis first: the qualifier
// routinely lands on the next source line or arrives as `**local**`, and
// either would fail the match on formatting rather than content.
const prose = body.replace(/\s+/g, ' ').replace(/[*`_]/g, '')

for (const match of prose.matchAll(/supabase migration up/g)) {
// Only what immediately follows the command counts. A window wide
// enough to find a "local database" elsewhere in the sentence accepts
// the very wording this guard rejects — "apply with supabase migration
// up (or supabase db reset locally, once the local database exists)"
// qualifies the other command, not this one.
const following = prose.slice(match.index + match[0].length)

expect(
following.slice(0, 60),
'`supabase migration up` applies to the LOCAL database — add `--linked`, say "applies to the local database", or use `supabase db push` for remote',
).toMatch(/^(?: --linked\b| applies to the local database\b)/i)
}
})
})
3 changes: 2 additions & 1 deletion packages/cli/src/bin/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Commands:
telemetry <sub> Manage anonymous usage analytics (status, enable, disable)

eql install Scaffold stash.config.ts (if missing) and install EQL extensions
eql migration Generate an EQL v3 install migration for your ORM (Drizzle)
eql migration Generate an EQL v3 install migration (Drizzle, or supabase/migrations/)
eql repair Repair migrations with an un-runnable ALTER COLUMN to an encrypted type
eql upgrade Upgrade EQL extensions to the latest version
eql status Show EQL installation status
Expand Down Expand Up @@ -261,6 +261,7 @@ async function runEqlCommand(
supabase: flags.supabase,
name: values.name,
out: values.out,
force: flags.force,
dryRun: flags['dry-run'],
})
break
Expand Down
20 changes: 17 additions & 3 deletions packages/cli/src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,10 +325,19 @@ export const registry: CommandGroup[] = [
{
name: 'eql migration',
summary:
'Generate an EQL v3 install migration for your ORM (Drizzle; Prisma Next installs EQL through its own migrations)',
'Generate an EQL v3 install migration (Drizzle, or supabase/migrations/; Prisma Next installs EQL through its own migrations)',
long: [
'Migration-first is the preferred way to install EQL: it lands in your',
'migration history and ships to every environment through the same',
'migrate step as the rest of your schema. On Supabase it is the only',
'durable way — `supabase db reset` replays the migrations directory, so',
'a direct `eql install` is wiped by the next reset.',
].join('\n'),
examples: [
'eql migration --drizzle',
'eql migration --drizzle --supabase',
'eql migration --supabase',
'eql migration --supabase --out db/migrations --force',
],
flags: [
{
Expand All @@ -344,7 +353,7 @@ export const registry: CommandGroup[] = [
{
name: '--supabase',
description:
'Append the Supabase role grants (eql_v3 + eql_v3_internal for anon/authenticated/service_role).',
'On its own, write the install into supabase/migrations/ so it survives `supabase db reset`. With --drizzle, instead append the Supabase role grants (eql_v3 + eql_v3_internal for anon/authenticated/service_role) to the Drizzle migration.',
},
{
name: '--name',
Expand All @@ -356,7 +365,12 @@ export const registry: CommandGroup[] = [
name: '--out',
value: '<path>',
description:
'Directory drizzle-kit writes the migration into (passed to `drizzle-kit generate --out`). Defaults to `drizzle`; set it to match your drizzle.config.ts.',
'Where the migration is written. Drizzle: passed to `drizzle-kit generate --out`, defaults to `drizzle` — set it to match your drizzle.config.ts. Supabase: the migrations directory, defaults to `supabase/migrations`.',
},
{
name: '--force',
description:
'Write a Supabase install migration even though one already exists. Not needed for --drizzle (drizzle-kit numbers each generated migration).',
},
DRY_RUN_FLAG,
],
Expand Down
30 changes: 19 additions & 11 deletions packages/cli/src/commands/db/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,33 +33,41 @@ export function detectSupabase(databaseUrl: string | undefined): boolean {
*/
export interface SupabaseProjectInfo {
/**
* Whether the migrations directory exists AND is a directory. Used to pick
* the migration-vs-direct default in the `eql install --supabase` prompt.
* Whether the migrations directory exists AND is a directory. Together with
* {@link hasConfigToml} this is what `stash init --supabase` reads to decide
* whether the project has somewhere local to write an install migration —
* a hosted Supabase project with no CLI scaffolding does not, and falls back
* to a direct `eql install`.
*/
hasMigrationsDir: boolean
/**
* Whether `supabase/config.toml` exists. Informational only — it doesn't
* influence the prompt default but is useful for diagnostics.
* Whether `supabase/config.toml` exists. The stronger of the two signals: a
* project that has run `supabase init` but never written a migration has the
* config and no migrations directory. Also gates the `supabase status`
* fallback in the database-URL resolver.
*/
hasConfigToml: boolean
/**
* Absolute path to the migrations directory we'd write into. Defaults to
* Absolute path to the migrations directory to write into. Defaults to
* `<cwd>/supabase/migrations`, or `override` (resolved against `cwd` when
* relative) when supplied via `--migrations-dir`.
* relative) when supplied via `eql migration --supabase --out`.
*/
migrationsDir: string
}

/**
* Inspect the working directory for Supabase CLI scaffolding.
*
* IMPORTANT: this is a hint for choosing the install-mode prompt default —
* it does NOT enable `--supabase`. The user must pass `--supabase` explicitly
* for any of the migration-file flow to activate.
* IMPORTANT: this is a hint — it does NOT enable `--supabase`. The user must
* pass `--supabase` explicitly for the migration-file flow to activate
* (`stash init --supabase` counts as that explicit choice).
*
* `migrationsDir` is returned whether or not it exists, because it is also the
* path `eql migration --supabase` creates.
*
* @param cwd - Project root to inspect.
* @param override - Optional `--migrations-dir` override. Absolute paths are
* used as-is; relative paths are resolved against `cwd`.
* @param override - Optional `--out` override. Absolute paths are used as-is;
* relative paths are resolved against `cwd`.
*/
export function detectSupabaseProject(
cwd: string,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/db/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function validateInstallFlags(
return '`eql install --drizzle` has been removed. Generate an EQL v3 Drizzle migration with `stash eql migration --drizzle` (and pass --name/--out there).'
}
if (options.migration === true || options.migrationsDir !== undefined) {
return '`eql install --migration` has been removed. Use `stash eql migration --drizzle` to keep the EQL v3 install in migration history, adding `--supabase` when needed.'
return '`eql install --migration` has been removed. Use `stash eql migration` to keep the EQL v3 install in migration history: `--supabase` writes into supabase/migrations/, `--drizzle` emits a Drizzle migration (add `--supabase` there for the role grants). Pass the target directory as `--out`.'
}
if (options.direct === true) {
return '`--direct` has been removed because `stash eql install` is now always a direct EQL v3 install.'
Expand Down
Loading
Loading