Skip to content

Commit 76e37c0

Browse files
committed
feat(cli): tighten column-selection UX in init / schema build
- Lift eql_v2_encrypted columns out of the multiselect; show them as a "will be kept as-is" note and merge them into the schema automatically. Closest we can get to "displayed but not toggleable" given clack has no disabled-row affordance. - Drop required:true. Empty submissions now route to an explicit recovery: warn-and-reprompt with no priors, or "Skip encryption for the <x> table?" confirm when at least one other table has been configured this run. - Confirmation summary after >=1 column picked, with re-prompt on no. - All-already-encrypted edge case: skip the multiselect and confirm "keep as-is?" so we never offer an empty picker. - selectTableColumns return type is now a discriminated { kind: 'schema' | 'skip' | 'cancel' } so the outer loop tells skip from cancel. - Filter eql_v2_* tables out of introspection. eql_v2_configuration is EQL's own configuration store; encrypting it would break EQL itself.
1 parent dad8a3f commit 76e37c0

3 files changed

Lines changed: 288 additions & 44 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'stash': patch
3+
---
4+
5+
`stash init` and `stash schema build`: tighter UX in the per-table column picker.
6+
7+
- **No more silent skip-throughs.** The multiselect no longer relies on clack's `required: true`. If you press enter with nothing toggled, you get an explicit recovery prompt instead of being railroaded into the next step. When you've already configured another table this run, the recovery offers "Skip encryption for the `<x>` table"; otherwise it warns and re-shows the picker.
8+
- **Confirmation summary before moving on.** After ≥1 column is selected, init reads the picks back ("Encrypt 3 columns in `users` (email, name, and ssn)?") and lets you back out into the picker if you misclicked.
9+
- **Already-encrypted columns are no longer toggleable.** Columns whose Postgres type is `eql_v2_encrypted` are surfaced as a "will be kept as-is" note and merged into the schema automatically, instead of sitting in the multiselect where deselecting them would silently drop them. If every column in a table is already encrypted, init now confirms "keep as-is?" and skips the multiselect entirely.
10+
- **`selectTableColumns` now returns a discriminated `{ kind: 'schema' | 'skip' | 'cancel' }`** so the outer loop can distinguish "user skipped this table" from "user cancelled the whole flow".
11+
- **EQL-managed tables are filtered out of introspection.** Anything in the `eql_v2_` namespace (e.g. `eql_v2_configuration`) is no longer offered as a choice — encrypting EQL's own configuration store would break EQL itself.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
type DbTable,
4+
allSearchOps,
5+
buildColumnDefs,
6+
joinNames,
7+
pgTypeToDataType,
8+
} from '../introspect.js'
9+
10+
const usersTable: DbTable = {
11+
tableName: 'users',
12+
columns: [
13+
{
14+
columnName: 'id',
15+
dataType: 'integer',
16+
udtName: 'int4',
17+
isEqlEncrypted: false,
18+
},
19+
{
20+
columnName: 'email',
21+
dataType: 'text',
22+
udtName: 'text',
23+
isEqlEncrypted: false,
24+
},
25+
{
26+
columnName: 'name',
27+
dataType: 'text',
28+
udtName: 'text',
29+
isEqlEncrypted: false,
30+
},
31+
{
32+
columnName: 'ssn',
33+
dataType: 'USER-DEFINED',
34+
udtName: 'eql_v2_encrypted',
35+
isEqlEncrypted: true,
36+
},
37+
],
38+
}
39+
40+
describe('pgTypeToDataType', () => {
41+
it.each([
42+
['int4', 'number'],
43+
['numeric', 'number'],
44+
['bool', 'boolean'],
45+
['timestamptz', 'date'],
46+
['jsonb', 'json'],
47+
['text', 'string'],
48+
['unknown_udt', 'string'],
49+
])('%s → %s', (udt, expected) => {
50+
expect(pgTypeToDataType(udt)).toBe(expected)
51+
})
52+
})
53+
54+
describe('allSearchOps', () => {
55+
it('includes freeTextSearch only for strings', () => {
56+
expect(allSearchOps('string')).toContain('freeTextSearch')
57+
expect(allSearchOps('number')).not.toContain('freeTextSearch')
58+
expect(allSearchOps('date')).not.toContain('freeTextSearch')
59+
})
60+
61+
it('always includes equality and orderAndRange', () => {
62+
for (const t of ['string', 'number', 'boolean', 'date', 'json'] as const) {
63+
expect(allSearchOps(t)).toEqual(
64+
expect.arrayContaining(['equality', 'orderAndRange']),
65+
)
66+
}
67+
})
68+
})
69+
70+
describe('buildColumnDefs', () => {
71+
it('always includes already-encrypted columns even when not picked', () => {
72+
const defs = buildColumnDefs(usersTable, ['email'], true)
73+
expect(defs.map((c) => c.name)).toEqual(['email', 'ssn'])
74+
})
75+
76+
it('preserves source column order', () => {
77+
const defs = buildColumnDefs(usersTable, ['name', 'email'], true)
78+
// email comes before name in usersTable
79+
expect(defs.map((c) => c.name)).toEqual(['email', 'name', 'ssn'])
80+
})
81+
82+
it('drops search ops when searchable is false', () => {
83+
const defs = buildColumnDefs(usersTable, ['email'], false)
84+
for (const c of defs) {
85+
expect(c.searchOps).toEqual([])
86+
}
87+
})
88+
89+
it('emits the locked column when nothing was picked', () => {
90+
const defs = buildColumnDefs(usersTable, [], true)
91+
expect(defs.map((c) => c.name)).toEqual(['ssn'])
92+
})
93+
94+
it('returns an empty array when nothing is picked and nothing is locked', () => {
95+
const noLocked: DbTable = {
96+
tableName: 'plain',
97+
columns: usersTable.columns.filter((c) => !c.isEqlEncrypted),
98+
}
99+
expect(buildColumnDefs(noLocked, [], true)).toEqual([])
100+
})
101+
102+
it('maps udt to dataType correctly', () => {
103+
const defs = buildColumnDefs(usersTable, ['email', 'id'], true)
104+
const email = defs.find((c) => c.name === 'email')
105+
const id = defs.find((c) => c.name === 'id')
106+
expect(email?.dataType).toBe('string')
107+
expect(id?.dataType).toBe('number')
108+
})
109+
})
110+
111+
describe('joinNames', () => {
112+
it('formats one name', () => {
113+
expect(joinNames(['a'])).toBe('a')
114+
})
115+
116+
it('formats two names with "and"', () => {
117+
expect(joinNames(['a', 'b'])).toBe('a and b')
118+
})
119+
120+
it('formats three names with Oxford comma', () => {
121+
expect(joinNames(['a', 'b', 'c'])).toBe('a, b, and c')
122+
})
123+
})

0 commit comments

Comments
 (0)