Skip to content

Commit 9a568ee

Browse files
authored
Merge pull request #439 from cipherstash/phased-plans
2 parents e2136cb + 1df1a49 commit 9a568ee

24 files changed

Lines changed: 2900 additions & 619 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
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

0 commit comments

Comments
 (0)