Skip to content

Commit 8b45e74

Browse files
committed
feat(cli): implement phased plans for rollout and backfill
1 parent e2136cb commit 8b45e74

20 files changed

Lines changed: 2679 additions & 459 deletions

File tree

packages/cli/src/bin/stash.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,20 @@ Init Flags:
110110
--supabase Use Supabase-specific setup flow
111111
--drizzle Use Drizzle-specific setup flow
112112
113+
Plan Flags:
114+
--complete-rollout Plan the entire encryption lifecycle (schema-add through drop)
115+
in one document. Skips the production-deploy gate that
116+
normally separates rollout from cutover. Only safe when this
117+
database is not backing a deployed application (local dev,
118+
sandbox, freshly seeded test environment).
119+
120+
Status Flags:
121+
--quest Force the quest-log output (emoji + progress bars)
122+
even in non-TTY contexts. Default is auto: fancy
123+
in a terminal, plain in CI / pipes / agents.
124+
--plain Force the plain-text output even in TTY contexts.
125+
--json Emit a structured JSON document instead.
126+
113127
Impl Flags:
114128
--continue-without-plan Skip planning and go straight to implementation
115129
(interactively confirms before proceeding)
@@ -382,13 +396,17 @@ async function main() {
382396
await initCommand(flags)
383397
break
384398
case 'plan':
385-
await planCommand()
399+
await planCommand(flags)
386400
break
387401
case 'impl':
388402
await implCommand(flags)
389403
break
390404
case 'status':
391-
await statusCommand()
405+
await statusCommand({
406+
quest: flags.quest,
407+
plain: flags.plain,
408+
json: flags.json,
409+
})
392410
break
393411
case 'auth': {
394412
const authArgs = subcommand ? [subcommand, ...commandArgs] : commandArgs

packages/cli/src/commands/impl/index.ts

Lines changed: 121 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,18 @@ import { existsSync, readFileSync } from 'node:fs'
22
import { resolve } from 'node:path'
33
import * as p from '@clack/prompts'
44
import { type AgentEnvironment, detectAgents } from '../init/detect-agents.js'
5-
import { parsePlanSummary, renderPlanSummary } from '../init/lib/parse-plan.js'
5+
import {
6+
type PlanStep,
7+
type PlanSummary,
8+
effectiveStep,
9+
parsePlanSummary,
10+
renderPlanSummary,
11+
} from '../init/lib/parse-plan.js'
612
import { readContextFile } from '../init/lib/read-context.js'
13+
import {
14+
classifyPhase,
15+
detectColumnStates,
16+
} from '../init/lib/rollout-state.js'
717
import { PLAN_REL_PATH } from '../init/lib/setup-prompt.js'
818
import {
919
CONTEXT_REL_PATH,
@@ -46,6 +56,71 @@ async function confirmContinueWithoutPlan(): Promise<void> {
4656
}
4757
}
4858

59+
/**
60+
* Verify the plan-summary's targeted columns have crossed the deploy gate
61+
* (a `dual_writing` event recorded in `cs_migrations`). Used only for
62+
* `cutover`-step plans. Returns the list of columns that are still in the
63+
* rollout step (no `dual_writing` event yet) — empty when the plan is
64+
* safe to proceed.
65+
*
66+
* On any DB error, returns `null` to signal "could not verify". The caller
67+
* still proceeds — refusing to run when the DB is just temporarily
68+
* unreachable would be too brittle, and the encrypt commands themselves
69+
* also gate on the same event before doing anything destructive.
70+
*/
71+
async function verifyCutoverPreconditions(
72+
cwd: string,
73+
summary: PlanSummary,
74+
): Promise<{ table: string; column: string }[] | null> {
75+
const migrate = summary.columns.filter((c) => c.path === 'migrate')
76+
if (migrate.length === 0) return []
77+
78+
let databaseUrl: string
79+
try {
80+
const { loadStashConfig } = await import('../../config/index.js')
81+
const config = await loadStashConfig()
82+
databaseUrl = config.databaseUrl
83+
} catch {
84+
return null
85+
}
86+
87+
const states = await detectColumnStates(databaseUrl, migrate)
88+
return states
89+
.filter((s) => classifyPhase(s.phase) !== 'cutover')
90+
.map((s) => ({ table: s.table, column: s.column }))
91+
}
92+
93+
/**
94+
* Print the deploy-gate banner shown at the end of a successful
95+
* rollout-step impl run. The banner is the explicit handover from the
96+
* CLI back to the user — the encrypted twin column and dual-write code
97+
* are now in the repo, but they only become safe once that code is
98+
* running in production. The CLI deliberately does not chain into
99+
* anything destructive here; the next destructive step (`stash encrypt
100+
* backfill`) requires the user to come back and run `stash plan` after
101+
* deploy.
102+
*/
103+
function printDeployGateBanner(cli: string): void {
104+
p.note(
105+
[
106+
'Encryption rollout is in your repo.',
107+
'',
108+
'Encrypted values are not yet flowing through your application because the',
109+
'dual-write code is not deployed. Until your production environment is running',
110+
'this code, backfilling historical rows would corrupt new writes (any row',
111+
'inserted during the backfill window would land in plaintext only and create',
112+
'silent migration drift).',
113+
'',
114+
'Next:',
115+
` 1. Deploy this branch to production.`,
116+
` 2. Run \`${cli} status\` to verify dual-writes are live.`,
117+
` 3. Run \`${cli} plan\` again — the CLI will detect dual-writes and draft`,
118+
` the encryption cutover (backfill → switch reads → drop plaintext).`,
119+
].join('\n'),
120+
'⛔ Deploy gate',
121+
)
122+
}
123+
49124
/**
50125
* `stash impl` — execute an encryption plan.
51126
*
@@ -54,13 +129,23 @@ async function confirmContinueWithoutPlan(): Promise<void> {
54129
*
55130
* - **Plan exists** (TTY): parse the structured summary block, render
56131
* a confirmation panel, ask the user to proceed. Default-yes.
132+
* For `cutover`-step plans, verify `dual_writing` events are
133+
* recorded for every migrate column before launching — refuse if
134+
* not, and point the user at re-running `stash plan` after deploy.
57135
* - **Plan exists** (non-TTY): proceed without confirmation.
58136
* - **No plan, `--continue-without-plan`**: confirm once, then implement.
59137
* - **No plan, TTY**: present a `p.select` — draft a plan first
60138
* (delegates to `planCommand`) or continue without one (confirms
61139
* once, then implements).
62140
* - **No plan, non-TTY**: error out with a clear next-action; CI must
63141
* pass `--continue-without-plan` or run `stash plan` first.
142+
*
143+
* After successful handoff, the outro depends on plan step:
144+
* - `rollout` — deploy-gate banner; explicit "do not run encrypt
145+
* backfill yet" message.
146+
* - `cutover` — confirmation that the rollout is fully complete.
147+
* - `complete` — same as cutover (escape hatch covers everything).
148+
* - no plan / no summary — generic "verify state" pointer.
64149
*/
65150
export async function implCommand(flags: Record<string, boolean>) {
66151
const cwd = process.cwd()
@@ -82,12 +167,38 @@ export async function implCommand(flags: Record<string, boolean>) {
82167
const continueWithoutPlan = flags['continue-without-plan'] === true
83168
const isTTY = process.stdout.isTTY
84169

170+
let planStep: PlanStep | undefined
171+
85172
try {
86173
if (planExists) {
174+
const summary = parsePlanSummary(readFileSync(planPath, 'utf-8'))
175+
planStep = summary ? effectiveStep(summary) : undefined
176+
177+
// Deploy-gate enforcement for cutover-step plans. Block before any
178+
// confirm prompt or handoff so the user never gets the chance to
179+
// press Enter through a misshapen flow. The check itself is
180+
// defensive: a DB outage doesn't fail the run (returns null), the
181+
// encrypt commands have their own gate.
182+
if (summary && planStep === 'cutover') {
183+
const missing = await verifyCutoverPreconditions(cwd, summary)
184+
if (missing && missing.length > 0) {
185+
p.log.error(
186+
'This plan is a cutover-step plan, but `cs_migrations` has no `dual_writing` event for the following column(s):',
187+
)
188+
for (const col of missing) {
189+
p.log.error(` · ${col.table}.${col.column}`)
190+
}
191+
p.log.info(
192+
`Backfill, cutover, and drop are unsafe until the dual-write code is live in production. Deploy your encryption-rollout PR first, then re-run \`${cli} status\` to verify and \`${cli} plan\` to redraft.`,
193+
)
194+
p.outro('Cutover blocked.')
195+
process.exit(1)
196+
}
197+
}
198+
87199
// Plan-summary checkpoint: the last save point before launching the
88200
// (potentially hour-long) implementation phase.
89201
if (isTTY) {
90-
const summary = parsePlanSummary(readFileSync(planPath, 'utf-8'))
91202
if (summary) {
92203
p.note(renderPlanSummary(summary), 'Plan summary')
93204
} else {
@@ -154,9 +265,14 @@ export async function implCommand(flags: Record<string, boolean>) {
154265

155266
await howToProceedStep.run(state)
156267

157-
p.outro(
158-
`Implementation handoff complete. Run \`${cli} db status\` to verify state.`,
159-
)
268+
if (planStep === 'rollout') {
269+
printDeployGateBanner(cli)
270+
p.outro('Encryption rollout handed off — deploy when ready.')
271+
} else {
272+
p.outro(
273+
`Implementation handoff complete. Run \`${cli} status\` to verify state.`,
274+
)
275+
}
160276
} catch (err) {
161277
if (err instanceof CancelledError) {
162278
p.cancel('Cancelled.')

0 commit comments

Comments
 (0)