Skip to content
Closed
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
11 changes: 11 additions & 0 deletions .changeset/cli-init-column-selection-ux.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'stash': patch
---

`stash init` and `stash schema build`: tighter UX in the per-table column picker.

- **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.
- **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.
- **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.
- **`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".
- **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.
124 changes: 124 additions & 0 deletions packages/cli/src/commands/init/lib/__tests__/introspect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'
import {
type DbTable,
allSearchOps,
buildColumnDefs,
joinNames,
pgTypeToDataType,
} from '../introspect.js'

const usersTable: DbTable = {
tableName: 'users',
columns: [
{
columnName: 'id',
dataType: 'integer',
udtName: 'int4',
isEqlEncrypted: false,
},
{
columnName: 'email',
dataType: 'text',
udtName: 'text',
isEqlEncrypted: false,
},
{
columnName: 'name',
dataType: 'text',
udtName: 'text',
isEqlEncrypted: false,
},
{
columnName: 'ssn',
dataType: 'USER-DEFINED',
udtName: 'eql_v2_encrypted',
isEqlEncrypted: true,
},
],
}

const usersTableNoEql: DbTable = {
tableName: 'plain',
columns: usersTable.columns.filter((c) => !c.isEqlEncrypted),
}

describe('pgTypeToDataType', () => {
it.each([
['int4', 'number'],
['numeric', 'number'],
['bool', 'boolean'],
['timestamptz', 'date'],
['jsonb', 'json'],
['text', 'string'],
['unknown_udt', 'string'],
])('%s → %s', (udt, expected) => {
expect(pgTypeToDataType(udt)).toBe(expected)
})
})

describe('allSearchOps', () => {
it('includes freeTextSearch only for strings', () => {
expect(allSearchOps('string')).toContain('freeTextSearch')
expect(allSearchOps('number')).not.toContain('freeTextSearch')
expect(allSearchOps('date')).not.toContain('freeTextSearch')
})

it('always includes equality and orderAndRange', () => {
for (const t of ['string', 'number', 'boolean', 'date', 'json'] as const) {
expect(allSearchOps(t)).toEqual(
expect.arrayContaining(['equality', 'orderAndRange']),
)
}
})
})

describe('buildColumnDefs', () => {
it('always includes already-encrypted columns even when not picked', () => {
const defs = buildColumnDefs(usersTable, ['email'], true)
expect(defs.map((c) => c.name)).toEqual(['email', 'ssn'])
})

it('preserves source column order', () => {
const defs = buildColumnDefs(usersTable, ['name', 'email'], true)
// email comes before name in usersTable
expect(defs.map((c) => c.name)).toEqual(['email', 'name', 'ssn'])
})

it('drops search ops when searchable is false', () => {
const defs = buildColumnDefs(usersTable, ['email'], false)
for (const c of defs) {
expect(c.searchOps).toEqual([])
}
})

it('emits the locked column when nothing was picked', () => {
const defs = buildColumnDefs(usersTable, [], true)
expect(defs.map((c) => c.name)).toEqual(['ssn'])
})

it('returns an empty array when nothing is picked and nothing is locked', () => {
expect(buildColumnDefs(usersTableNoEql, [], true)).toEqual([])
})

it('maps udt to dataType correctly', () => {
const defs = buildColumnDefs(usersTable, ['email', 'id'], true)
const email = defs.find((c) => c.name === 'email')
const id = defs.find((c) => c.name === 'id')
expect(email?.dataType).toBe('string')
expect(id?.dataType).toBe('number')
})
})

describe('joinNames', () => {
it('formats one name', () => {
expect(joinNames(['a'])).toBe('a')
})

it('formats two names with "and"', () => {
expect(joinNames(['a', 'b'])).toBe('a and b')
})

it('formats three names with Oxford comma', () => {
expect(joinNames(['a', 'b', 'c'])).toBe('a, b, and c')
})
})
Loading
Loading