Skip to content

Commit 1df1a49

Browse files
committed
chore: address review feedback
1 parent 8b45e74 commit 1df1a49

19 files changed

Lines changed: 569 additions & 508 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { latestByColumn } from '@cipherstash/migrate'
2+
import type pg from 'pg'
3+
4+
/**
5+
* `latestByColumn` from `@cipherstash/migrate`, but tolerant of the
6+
* pre-install case where `cipherstash.cs_migrations` doesn't exist.
7+
* The encryption-rollout commands all need to be readable on a fresh
8+
* project; treating "table missing" as "no events" keeps them so.
9+
*/
10+
export async function latestByColumnSafe(
11+
client: pg.ClientBase,
12+
): Promise<ReturnType<typeof latestByColumn> extends Promise<infer T> ? T : never> {
13+
try {
14+
return (await latestByColumn(client)) as Awaited<ReturnType<typeof latestByColumn>>
15+
} catch (err) {
16+
if (
17+
err instanceof Error &&
18+
/cs_migrations|schema "cipherstash"/i.test(err.message)
19+
) {
20+
return new Map() as Awaited<ReturnType<typeof latestByColumn>>
21+
}
22+
throw err
23+
}
24+
}
25+
26+
export interface EqlColumnInfo {
27+
/** Index kinds attached to this column in the EQL config (`unique`,
28+
* `match`, `ore`, `ste_vec`). Empty when no indexes are configured. */
29+
indexes: string[]
30+
/** Lifecycle state of the EQL config row this column belongs to. */
31+
state: 'active' | 'pending' | 'encrypting'
32+
}
33+
34+
/**
35+
* Read every column registered in `eql_v2_configuration` (active,
36+
* pending, or encrypting) keyed by `<table>.<column>`. Active rows win
37+
* when a column appears in more than one state.
38+
*
39+
* The call is best-effort: if `eql_v2_configuration` doesn't exist yet
40+
* (EQL not installed), an empty map is returned instead of throwing.
41+
*/
42+
export async function fetchActiveEqlConfig(
43+
client: pg.ClientBase,
44+
): Promise<Map<string, EqlColumnInfo>> {
45+
const out = new Map<string, EqlColumnInfo>()
46+
try {
47+
const result = await client.query<{ state: string; data: unknown }>(
48+
`SELECT state, data FROM public.eql_v2_configuration
49+
WHERE state IN ('active', 'pending', 'encrypting')
50+
ORDER BY CASE state WHEN 'active' THEN 0 WHEN 'encrypting' THEN 1 ELSE 2 END`,
51+
)
52+
for (const row of result.rows) {
53+
const data = row.data as {
54+
tables?: Record<
55+
string,
56+
Record<string, { indexes?: Record<string, unknown> }>
57+
>
58+
} | null
59+
if (!data?.tables) continue
60+
for (const [tableName, columns] of Object.entries(data.tables)) {
61+
for (const [columnName, column] of Object.entries(columns)) {
62+
const key = `${tableName}.${columnName}`
63+
if (out.has(key)) continue
64+
out.set(key, {
65+
indexes: Object.keys(column.indexes ?? {}),
66+
state: row.state as 'active' | 'pending' | 'encrypting',
67+
})
68+
}
69+
}
70+
}
71+
} catch (err) {
72+
if (err instanceof Error && /eql_v2_configuration/i.test(err.message)) {
73+
return out
74+
}
75+
throw err
76+
}
77+
return out
78+
}
79+
80+
/**
81+
* Read `information_schema.columns` and group column names by table.
82+
* When `tables` is provided the query is constrained to that set —
83+
* status's quest log only ever needs ~5 specific tables, so passing
84+
* the manifest's tables avoids a full-schema scan.
85+
*/
86+
export async function fetchPhysicalColumns(
87+
client: pg.ClientBase,
88+
tables?: ReadonlyArray<string>,
89+
): Promise<Map<string, Set<string>>> {
90+
const out = new Map<string, Set<string>>()
91+
try {
92+
const result =
93+
tables === undefined
94+
? await client.query<{ table_name: string; column_name: string }>(
95+
`SELECT table_name, column_name FROM information_schema.columns
96+
WHERE table_schema = current_schema()`,
97+
)
98+
: await client.query<{ table_name: string; column_name: string }>(
99+
`SELECT table_name, column_name FROM information_schema.columns
100+
WHERE table_schema = current_schema()
101+
AND table_name = ANY($1::text[])`,
102+
[tables],
103+
)
104+
for (const row of result.rows) {
105+
const set = out.get(row.table_name) ?? new Set<string>()
106+
set.add(row.column_name)
107+
out.set(row.table_name, set)
108+
}
109+
} catch {
110+
// information_schema is always present; failures here are surprising
111+
// enough to swallow rather than crash the read-only status path.
112+
}
113+
return out
114+
}

packages/cli/src/commands/encrypt/status.ts

Lines changed: 8 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
22
import { loadStashConfig } from '@/config/index.js'
3-
import {
4-
type MigrationPhase,
5-
latestByColumn,
6-
readManifest,
7-
} from '@cipherstash/migrate'
3+
import { type MigrationPhase, readManifest } from '@cipherstash/migrate'
84
import * as p from '@clack/prompts'
95
import pg from 'pg'
6+
import {
7+
type EqlColumnInfo,
8+
fetchActiveEqlConfig,
9+
fetchPhysicalColumns,
10+
latestByColumnSafe,
11+
} from './lib/db-readers.js'
1012

1113
interface Row {
1214
table: string
@@ -53,7 +55,7 @@ export async function statusCommand() {
5355
if (manifest) {
5456
for (const [tableName, columns] of Object.entries(manifest.tables)) {
5557
for (const column of columns) {
56-
const key = `${tableName}.${column.column}`
58+
const key: `${string}.${string}` = `${tableName}.${column.column}`
5759
seen.add(key)
5860
rows.push(
5961
renderRow({
@@ -110,82 +112,6 @@ export async function statusCommand() {
110112
if (exitCode) process.exit(exitCode)
111113
}
112114

113-
async function latestByColumnSafe(client: pg.Client) {
114-
try {
115-
return await latestByColumn(client)
116-
} catch (err) {
117-
if (
118-
err instanceof Error &&
119-
/cs_migrations|schema "cipherstash"/i.test(err.message)
120-
) {
121-
return new Map()
122-
}
123-
throw err
124-
}
125-
}
126-
127-
interface EqlColumnInfo {
128-
indexes: string[]
129-
state: 'active' | 'pending' | 'encrypting'
130-
}
131-
132-
async function fetchActiveEqlConfig(
133-
client: pg.Client,
134-
): Promise<Map<string, EqlColumnInfo>> {
135-
const out = new Map<string, EqlColumnInfo>()
136-
try {
137-
const result = await client.query<{ state: string; data: unknown }>(
138-
`SELECT state, data FROM public.eql_v2_configuration
139-
WHERE state IN ('active', 'pending', 'encrypting')
140-
ORDER BY CASE state WHEN 'active' THEN 0 WHEN 'encrypting' THEN 1 ELSE 2 END`,
141-
)
142-
for (const row of result.rows) {
143-
const data = row.data as {
144-
tables?: Record<
145-
string,
146-
Record<string, { indexes?: Record<string, unknown> }>
147-
>
148-
} | null
149-
if (!data?.tables) continue
150-
for (const [tableName, columns] of Object.entries(data.tables)) {
151-
for (const [columnName, column] of Object.entries(columns)) {
152-
const key = `${tableName}.${columnName}`
153-
if (out.has(key)) continue
154-
out.set(key, {
155-
indexes: Object.keys(column.indexes ?? {}),
156-
state: row.state as 'active' | 'pending' | 'encrypting',
157-
})
158-
}
159-
}
160-
}
161-
} catch (err) {
162-
if (err instanceof Error && /eql_v2_configuration/i.test(err.message)) {
163-
return out
164-
}
165-
throw err
166-
}
167-
return out
168-
}
169-
170-
async function fetchPhysicalColumns(
171-
client: pg.Client,
172-
): Promise<Map<string, Set<string>>> {
173-
const out = new Map<string, Set<string>>()
174-
const result = await client.query<{
175-
table_name: string
176-
column_name: string
177-
}>(
178-
`SELECT table_name, column_name FROM information_schema.columns
179-
WHERE table_schema = current_schema()`,
180-
)
181-
for (const row of result.rows) {
182-
const set = out.get(row.table_name) ?? new Set<string>()
183-
set.add(row.column_name)
184-
out.set(row.table_name, set)
185-
}
186-
return out
187-
}
188-
189115
function renderRow(input: {
190116
tableName: string
191117
columnName: string

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

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ async function verifyCutoverPreconditions(
8585
}
8686

8787
const states = await detectColumnStates(databaseUrl, migrate)
88+
if (states === null) return null
8889
return states
8990
.filter((s) => classifyPhase(s.phase) !== 'cutover')
9091
.map((s) => ({ table: s.table, column: s.column }))
@@ -122,30 +123,13 @@ function printDeployGateBanner(cli: string): void {
122123
}
123124

124125
/**
125-
* `stash impl` — execute an encryption plan.
126+
* `stash impl` — execute an encryption plan via agent handoff.
126127
*
127-
* Always runs in implement mode. Behaviour branches on disk state and
128-
* flags:
129-
*
130-
* - **Plan exists** (TTY): parse the structured summary block, render
131-
* 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.
135-
* - **Plan exists** (non-TTY): proceed without confirmation.
136-
* - **No plan, `--continue-without-plan`**: confirm once, then implement.
137-
* - **No plan, TTY**: present a `p.select` — draft a plan first
138-
* (delegates to `planCommand`) or continue without one (confirms
139-
* once, then implements).
140-
* - **No plan, non-TTY**: error out with a clear next-action; CI must
141-
* 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.
128+
* Cutover-step plans are gated on a `dual_writing` event being recorded
129+
* in `cs_migrations` for every migrate column (the safety net that
130+
* stops destructive work running ahead of a production deploy of the
131+
* dual-write code). After a rollout-step handoff, the outro is the
132+
* deploy-gate banner instead of a generic "verify state" pointer.
149133
*/
150134
export async function implCommand(flags: Record<string, boolean>) {
151135
const cwd = process.cwd()
@@ -200,7 +184,7 @@ export async function implCommand(flags: Record<string, boolean>) {
200184
// (potentially hour-long) implementation phase.
201185
if (isTTY) {
202186
if (summary) {
203-
p.note(renderPlanSummary(summary), 'Plan summary')
187+
p.note(renderPlanSummary(summary, cli), 'Plan summary')
204188
} else {
205189
p.note(
206190
`Plan at \`${PLAN_REL_PATH}\` doesn't include a machine-readable summary. Open it in your editor before proceeding.`,

packages/cli/src/commands/impl/steps/handoff-wizard.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ import { runWizardSpawn } from '../../wizard/index.js'
1212
* Hand off to the CipherStash Agent (the in-house wizard package).
1313
*
1414
* Writes `.cipherstash/context.json` so the wizard has the same prepared
15-
* facts the other handoffs use, then spawns the wizard via `runWizardSpawn`
16-
* — the same path the top-level `stash wizard` subcommand takes, but with
17-
* the exit code surfaced rather than `process.exit`-ed so `stash impl` can
18-
* finish its own outro.
15+
* facts the other handoffs use (including the resolved `planStep` when
16+
* `stash plan` is the caller — the wizard reads it from there rather
17+
* than via argv), then spawns the wizard via `runWizardSpawn` — the same
18+
* path the top-level `stash wizard` subcommand takes, but with the exit
19+
* code surfaced rather than `process.exit`-ed so `stash impl` can finish
20+
* its own outro.
1921
*
2022
* No skills are installed here. The wizard fetches its own agent-side
2123
* prompt from the gateway and runs its own `maybeInstallSkills` flow.

0 commit comments

Comments
 (0)