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
10 changes: 10 additions & 0 deletions .changeset/stash-cli-eql-v3-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"stash": minor
---

Default EQL to v3 and stop the CLI recommending `stash db push` (#585).

- **EQL v3 is now the default.** `stash eql install` and `stash eql upgrade` target v3 (the native `eql_v3.*` domain schema) without `--eql-version 3`. The v2-only paths — `--drizzle`, `--migration`, `--migrations-dir`, and `--latest` — now require an explicit `--eql-version 2` and error with clear guidance otherwise (v3 installs via the direct path only). `stash init` pins v2 automatically when it drives the Drizzle migration flow. **Note:** for a Supabase project, `stash init` now runs a v3 direct install rather than offering the v2 migration-file flow; run `stash eql install --supabase --migration --eql-version 2` if you want a checked-in migration file.
- **`stash db push` is no longer recommended in CLI output.** `db push` writes the `public.eql_v2_configuration` table, which is a v2 + CipherStash Proxy artifact — EQL v3 has no configuration table (config lives in each column's `eql_v3.*` type) and nothing in the v3 stack reads it. The push recommendations are removed from `eql status`, the help banner, and the init/plan/cutover guidance. `db push` (and `db activate`) remain available for EQL v2 + Proxy users; they're now labelled as such.
- **`eql status` is v3-aware.** On a v3-only database it reports that encrypt config lives in the column types instead of hitting a "table not found" dead-end that told users to run `db push` (which neither creates that table nor applies to v3).
- **`stash db push` guards a v3-only database** with a clear "not needed under EQL v3" message instead of a raw `relation "public.eql_v2_configuration" does not exist` error.
8 changes: 4 additions & 4 deletions packages/cli/src/__tests__/installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ describe('EQLInstaller', () => {
databaseUrl: 'postgresql://localhost:5432/test',
})

const result = await installer.isInstalled()
const result = await installer.isInstalled({ eqlVersion: 2 })
expect(result).toBe(true)
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), [['eql_v2']])
})
Expand All @@ -127,7 +127,7 @@ describe('EQLInstaller', () => {
databaseUrl: 'postgresql://localhost:5432/test',
})

await installer.install()
await installer.install({ eqlVersion: 2 })

// Should NOT call fetch — uses bundled SQL
expect(fetchSpy).not.toHaveBeenCalled()
Expand Down Expand Up @@ -160,7 +160,7 @@ describe('EQLInstaller', () => {
databaseUrl: 'postgresql://localhost:5432/test',
})

await installer.install({ latest: true })
await installer.install({ eqlVersion: 2, latest: true })

expect(fetchSpy).toHaveBeenCalledWith(
expect.stringContaining('cipherstash-encrypt.sql'),
Expand All @@ -182,7 +182,7 @@ describe('EQLInstaller', () => {
databaseUrl: 'postgresql://localhost:5432/test',
})

await installer.install({ supabase: true })
await installer.install({ eqlVersion: 2, supabase: true })

// Capture every query string that isn't a transaction control verb.
const otherCalls = mockQuery.mock.calls
Expand Down
69 changes: 64 additions & 5 deletions packages/cli/src/__tests__/supabase-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import {
SUPABASE_EQL_MIGRATION_FILENAME,
writeSupabaseEqlMigration,
} from '../commands/db/supabase-migration.js'
import { SUPABASE_PERMISSIONS_SQL } from '../installer/index.js'
import {
DEFAULT_EQL_VERSION,
resolveEqlVersion,
SUPABASE_PERMISSIONS_SQL,
} from '../installer/index.js'

/**
* Generate the migration header for testing purposes.
Expand Down Expand Up @@ -140,8 +144,13 @@ describe('writeSupabaseEqlMigration', () => {
/-- CipherStash EQL — installed by `(npx|bunx|pnpm dlx|yarn dlx) stash eql install --supabase --migration`/,
)
expect(contents).toContain('CipherStash')
// EQL SQL body — the bundled supabase variant defines eql_v2.
expect(contents).toContain('eql_v2')
// EQL SQL body — the bundled supabase variant defines eql_v2. The
// migration-file flow is v2-only, so the body must be the v2 generation:
// assert the v2 composite type is present and no v3 schema leaks in. (A
// bare `toContain('eql_v2')` isn't enough — the header + permissions
// mention eql_v2 even if the body were v3.)
expect(contents).toContain('eql_v2_encrypted')
expect(contents).not.toContain('eql_v3')
// Permissions block (single source of truth).
expect(contents).toContain(SUPABASE_PERMISSIONS_SQL.trim())
})
Expand Down Expand Up @@ -203,13 +212,33 @@ describe('writeSupabaseEqlMigration', () => {
})
})

describe('resolveEqlVersion', () => {
it('defaults a missing version to DEFAULT_EQL_VERSION (v3)', () => {
expect(DEFAULT_EQL_VERSION).toBe(3)
expect(resolveEqlVersion(undefined)).toBe(3)
})

it('resolves explicit strings to their generation', () => {
expect(resolveEqlVersion('2')).toBe(2)
expect(resolveEqlVersion('3')).toBe(3)
})
})

describe('validateInstallFlags', () => {
it('returns null for an empty options object', () => {
expect(validateInstallFlags({})).toBeNull()
})

it('returns null when --supabase is paired with --migration', () => {
expect(validateInstallFlags({ supabase: true, migration: true })).toBeNull()
it('returns null when --supabase + --migration is pinned to --eql-version 2', () => {
// --migration is a v2-only path, so under the v3 default it needs an
// explicit `--eql-version 2` alongside --supabase.
expect(
validateInstallFlags({
supabase: true,
migration: true,
eqlVersion: '2',
}),
).toBeNull()
})

it('returns null when --supabase is paired with --direct', () => {
Expand Down Expand Up @@ -279,6 +308,36 @@ describe('validateInstallFlags', () => {
}),
).toMatch(/--migrations-dir/)
})

it('defaults to v3: a plain install with no version is valid', () => {
expect(validateInstallFlags({})).toBeNull()
expect(validateInstallFlags({ supabase: true })).toBeNull()
})

it('v2-only flags without an explicit --eql-version 2 are rejected under the v3 default', () => {
// No eqlVersion passed → resolves to the v3 default, which can't do these.
for (const opts of [
{ drizzle: true },
{ latest: true },
{ supabase: true, migration: true },
{ supabase: true, migrationsDir: 'db/migrations' },
]) {
const err = validateInstallFlags(opts)
expect(err).toMatch(/--eql-version 2/)
}
})

it('accepts the v2-only paths when --eql-version 2 is explicit', () => {
expect(validateInstallFlags({ eqlVersion: '2', drizzle: true })).toBeNull()
expect(validateInstallFlags({ eqlVersion: '2', latest: true })).toBeNull()
expect(
validateInstallFlags({
eqlVersion: '2',
supabase: true,
migration: true,
}),
).toBeNull()
})
})

describe('routeInstallPathForEqlVersion', () => {
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/bin/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ Commands:
eql upgrade Upgrade EQL extensions to the latest version
eql status Show EQL installation status

db push Push encryption schema (writes pending if active config already exists)
db activate Promote pending → active without renames (use after additive db push)
db push (EQL v2 + Proxy) Push encryption schema to eql_v2_configuration
db activate (EQL v2 + Proxy) Promote pending → active without renames
db validate Validate encryption schema
db migrate Run pending encrypt config migrations
db test-connection Test database connectivity
Expand All @@ -124,7 +124,6 @@ Examples:
${STASH} init # set up CipherStash in this project
${STASH} auth login # authenticate
${STASH} eql install # install EQL extensions
${STASH} db push # push encryption schema
${STASH} manifest --json # structured command surface for docs / agents
`.trim()

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cli/__tests__/help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('renderCommandHelp', () => {
expect(out).toContain('--force')
// Value-taking flag renders its placeholder + default annotation.
expect(out).toContain('--eql-version <2|3>')
expect(out).toContain('(default: 2)')
expect(out).toContain('(default: 3)')
})

it('surfaces a flag env var alongside its description', () => {
Expand Down
15 changes: 9 additions & 6 deletions packages/cli/src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,12 +331,13 @@ export const registry: CommandGroup[] = [
name: '--eql-version',
value: '<2|3>',
description:
'EQL generation to target. v3 is the native eql_v3.* domain schema (direct install only for now).',
default: '2',
'EQL generation to target. v3 (native eql_v3.* domain schema) is the default; pass `2` for the legacy composite type. --drizzle, --migration, --migrations-dir, and --latest are v2-only and require `--eql-version 2`.',
default: '3',
},
{
name: '--latest',
description: 'Fetch the latest EQL from GitHub (v2 only).',
description:
'Fetch the latest EQL from GitHub (v2 only — requires --eql-version 2).',
},
{
name: '--name',
Expand All @@ -363,12 +364,14 @@ export const registry: CommandGroup[] = [
{
name: '--eql-version',
value: '<2|3>',
description: 'EQL generation to target.',
default: '2',
description:
'EQL generation to target. v3 is the default; pass `2` for the legacy composite type. --latest is v2-only and requires `--eql-version 2`.',
default: '3',
},
{
name: '--latest',
description: 'Fetch the latest EQL from GitHub (v2 only).',
description:
'Fetch the latest EQL from GitHub (v2 only — requires --eql-version 2).',
},
DATABASE_URL_FLAG,
],
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/db/activate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export async function activateCommand(
)
if (pending.rows[0]?.exists !== true) {
p.log.error(
'No pending EQL configuration to activate. Run `stash db push` first to register a change.',
'No pending EQL configuration to activate. This applies to the EQL v2 + CipherStash Proxy config lifecycle — register a pending change before activating.',
)
Comment thread
coderdan marked this conversation as resolved.
exitCode = 1
return
Expand Down
54 changes: 33 additions & 21 deletions packages/cli/src/commands/db/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
downloadEqlSql,
EQLInstaller,
loadBundledEqlSql,
resolveEqlVersion,
} from '@/installer/index.js'
import { ensureEncryptionClient } from './client-scaffold.js'
import { offerStashConfig } from './config-scaffold.js'
Expand Down Expand Up @@ -77,9 +78,10 @@ export interface InstallOptions {
*/
scaffoldConfig?: 'ensure' | 'offer' | 'skip'
/**
* EQL generation to install: `'2'` (default, composite `eql_v2_encrypted`)
* or `'3'` (native `eql_v3.*` domain schema). v3 currently supports the
* direct install path only — not `--drizzle`, `--migration`, or `--latest`.
* EQL generation to install: `'3'` (default, native `eql_v3.*` domain
* schema) or `'2'` (composite `eql_v2_encrypted`). v3 currently supports the
* direct install path only — `--drizzle`, `--migration`, `--migrations-dir`,
* and `--latest` require an explicit `'2'`.
*/
eqlVersion?: string
}
Expand Down Expand Up @@ -204,7 +206,7 @@ export async function installCommand(options: InstallOptions) {
// CIP-2985.
const resolved = resolveProviderOptions(options, databaseUrl)

const eqlVersion: 2 | 3 = options.eqlVersion === '3' ? 3 : 2
const eqlVersion: 2 | 3 = resolveEqlVersion(options.eqlVersion)

// v3 supports the direct install path only. Explicit --drizzle/--migration
// are rejected up-front by validateInstallFlags; auto-DETECTED drizzle or
Expand Down Expand Up @@ -640,23 +642,10 @@ export function validateInstallFlags(options: InstallOptions): string | null {
return `Unknown \`--eql-version ${options.eqlVersion}\`. Supported values: 2, 3.`
}

if (options.eqlVersion === '3') {
const incompatible = options.drizzle
? '--drizzle'
: options.migration
? '--migration'
: options.latest
? '--latest'
: // --migrations-dir only feeds the Supabase v2 migration-file path;
// the v3 direct install would silently ignore it — reject instead.
options.migrationsDir !== undefined
? '--migrations-dir'
: null
if (incompatible) {
return `\`--eql-version 3\` does not support \`${incompatible}\` yet — v3 currently installs via the direct path only.`
}
}

// `--migration` / `--direct` / `--migrations-dir` require `--supabase`. Check
// this before the version gate below so a bare `--migration` still points at
// the missing `--supabase` (its more fundamental prerequisite) rather than
// the version.
const subFlag =
options.migration === true
? '--migration'
Expand All @@ -670,6 +659,29 @@ export function validateInstallFlags(options: InstallOptions): string | null {
return `\`${subFlag}\` requires \`--supabase\`. Re-run with \`eql install --supabase ${subFlag}\`.`
}

// v3 is the default and installs via the direct path only. The Drizzle /
// Supabase-migration / `--latest` paths are v2-only, so they require an
// explicit `--eql-version 2` — otherwise they'd silently resolve to a v3
// direct install that ignores what the user asked for. `--migrations-dir`
// only feeds the Supabase v2 migration-file path, so it's in the same bucket.
const resolvedVersion = resolveEqlVersion(options.eqlVersion)
if (resolvedVersion === 3) {
const v2OnlyFlag = options.drizzle
? '--drizzle'
: options.migration
? '--migration'
: options.latest
? '--latest'
: options.migrationsDir !== undefined
? '--migrations-dir'
: null
if (v2OnlyFlag) {
return options.eqlVersion === '3'
? `\`--eql-version 3\` does not support \`${v2OnlyFlag}\` yet — v3 currently installs via the direct path only.`
: `\`${v2OnlyFlag}\` requires EQL v2. Re-run with \`--eql-version 2 ${v2OnlyFlag}\` (v3 is the default and installs via the direct path only).`
}
}

return null
}

Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/commands/db/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,24 @@ export async function pushCommand(options: {
await client.connect()
s.stop('Connected to Postgres.')

// `db push` writes `public.eql_v2_configuration` — a v2 + CipherStash Proxy
// artifact. EQL v3 has no configuration table (config lives in each
// column's `eql_v3.*` domain type) and nothing reads it, so if the table is
// absent — a v3-only database, or a database with no EQL installed at all —
// there's nothing to push. Check the table directly (one query on the
// connection we already hold) and exit cleanly instead of failing later
// with a raw `relation "public.eql_v2_configuration" does not exist`.
const tableResult = await client.query<{ reg: string | null }>(
"SELECT to_regclass('public.eql_v2_configuration') AS reg",
)
if (tableResult.rows[0]?.reg == null) {
p.log.info(
"`stash db push` writes the EQL v2 configuration table, which this database doesn't have. It only applies to EQL v2 with CipherStash Proxy — under EQL v3 (the default) encryption config lives in each column's `eql_v3.*` type, so there's nothing to push. For a v2 + Proxy setup, run `stash eql install --eql-version 2` first.",
)
p.outro('Nothing to do.')
return
}

s.start('Checking public.eql_v2_configuration state...')
const activeResult = await client.query<{ exists: boolean }>(
"SELECT EXISTS(SELECT 1 FROM public.eql_v2_configuration WHERE state = 'active') AS exists",
Expand Down
37 changes: 22 additions & 15 deletions packages/cli/src/commands/db/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@ export async function statusCommand(options: { databaseUrl?: string } = {}) {
let versionV3: string | null

try {
installedV2 = await installer.isInstalled()
installedV2 = await installer.isInstalled({ eqlVersion: 2 })
installedV3 = await installer.isInstalled({ eqlVersion: 3 })
versionV2 = installedV2 ? await installer.getInstalledVersion() : null
versionV2 = installedV2
? await installer.getInstalledVersion({ eqlVersion: 2 })
: null
versionV3 = installedV3
? await installer.getInstalledVersion({ eqlVersion: 3 })
: null
Expand Down Expand Up @@ -91,7 +93,22 @@ export async function statusCommand(options: { databaseUrl?: string } = {}) {
)
}

// 3. Check for active encrypt config (proxy mode)
// 3. Encrypt configuration.
//
// `public.eql_v2_configuration` is a v2 + CipherStash Proxy artifact: the v2
// install creates it and Proxy reads it. EQL v3 has no configuration table —
// encryption config lives in each column's `eql_v3.*` domain type — so on a
// v3-only install there's nothing to probe. Gate the check on v2 being
// installed; this also removes the old dead-end that told v3-only users to
// run `db push` (which neither creates that table nor applies to v3).
if (!installedV2) {
p.log.info(
"Encrypt config: carried in each column's `eql_v3.*` type (EQL v3 has no Proxy config table).",
)
p.outro('Status check complete.')
return
}
Comment thread
coderdan marked this conversation as resolved.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
s.start('Checking encrypt configuration...')

const client = new pg.Client({ connectionString: config.databaseUrl })
Expand All @@ -110,22 +127,12 @@ export async function statusCommand(options: { databaseUrl?: string } = {}) {
`Active encrypt config: yes (${result.rowCount} active ${result.rowCount === 1 ? 'row' : 'rows'})`,
)
} else {
p.log.info(
'Active encrypt config: none (only needed for CipherStash Proxy)',
)
p.log.info('Active encrypt config: none (only used by CipherStash Proxy)')
}
} catch (error) {
s.stop('Configuration check failed.')

// The table may not exist if push has never been run — that's fine
const message = error instanceof Error ? error.message : String(error)
if (message.includes('does not exist')) {
p.log.info(
`Active encrypt config: table not found (run \`${runnerCommand(pm, 'stash db push')}\` to create it)`,
)
} else {
p.log.error(`Failed to check encrypt configuration: ${message}`)
}
p.log.error(`Failed to check encrypt configuration: ${message}`)
} finally {
await client.end()
}
Expand Down
Loading
Loading