Skip to content
Merged
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
20 changes: 20 additions & 0 deletions .changeset/column-migration-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@cipherstash/migrate': minor
Comment thread
coderabbitai[bot] marked this conversation as resolved.
'stash': patch
---

`@cipherstash/migrate` adds `ColumnMigration`, one place for the rules of a column migration in either direction: which twin column it pairs with (the flag, the recorded pairing, the manifest, then the naming convention), its phase, whether a forced pass is owed, and which open migration in the other direction blocks it.
`ColumnMigration.load()` reads it from `cipherstash.cs_migrations`; `confirmDualWrites()`, `recordDropGenerated()` and `recordSourceRemoved()` record events, refusing those the rules forbid with a `ColumnMigrationError`.
The pure rules are exported as `deriveColumnMigration`, `openCounterpart`, `planPassFor`, `passProgress`, `deriveTwinName` and `manifestTwin`.

`runBackfill` and `runDecryptBackfill` now apply the rules themselves rather than leaving them to the caller.
Every event records whether its pass was forced.
An interrupted forced pass is finished as one by the next run, with or without `force`, and the result says why in `forcedBy`.
A forward backfill resumes after a failed run from its last committed position, counts its total once when it resumes a forced pass, and refuses to start while a reverse migration of the same columns is open.
`runBackfill` now throws a `ColumnMigrationError` with code `twin-mismatch` when `encryptedColumn` differs from the encrypted column already recorded for that column, rather than continuing into a different one.
A reverse backfill over a twin that already holds values is forced unless the caller confirmed dual-writes with the `plaintext-current` waiver.
Error events store the error's class (`errorName`) and safe details instead of its message, which could carry row data.

`stash decrypt plan`, `status`, `backfill` and `drop` resolve every column through `ColumnMigration`, so they always agree on the twin and the phase.
`decrypt status` flags `forward-open`, `twin-has-values` and `forced-pass-owed`.
`decrypt backfill --confirm-plaintext-current` is accepted on a later run as long as no forced pass has started, and refused while a started forced pass is owed.
10 changes: 6 additions & 4 deletions languages/typescript/packages/cli/src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,9 +699,11 @@ export const registry: CommandGroup[] = [
'and never dropped: finish or abandon that migration first. The first',
'run also refuses a plaintext column that already holds values: the',
'backfill only fills NULLs, so stale values would survive. Pass --force',
'to overwrite them from the ciphertext (every later run is then forced',
'until a forced pass completes), or --confirm-plaintext-current to keep',
'them. Keep writing both columns until the drop is applied.',
'to overwrite them from the ciphertext, or --confirm-plaintext-current',
'to keep them. A forced pass, once started, is owed: every later run is',
'forced until one completes, and the waiver is refused while it is owed.',
'`decrypt status` flags a column that owes one (forced-pass-owed).',
'Keep writing both columns until the drop is applied.',
].join('\n'),
examples: [
'decrypt backfill --table users --column email_encrypted',
Expand Down Expand Up @@ -739,7 +741,7 @@ export const registry: CommandGroup[] = [
{
name: '--confirm-plaintext-current',
description:
'First run only: keep the values a plaintext column already holds, asserting they are current.',
'Keep the values a plaintext column already holds, asserting they are current. Refused while a started forced pass is owed.',
},
{
name: '--continue-on-undecryptable',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* The command registry is plain data with no imports, so `decrypt backfill`'s
* help writes its flag names and limits as literals. These tests hold them to
* the constants the command enforces, so the help cannot drift from them.
*/

import { MAX_DECRYPT_CHUNK_SIZE } from '@cipherstash/migrate'
import { describe, expect, it } from 'vitest'
import { renderCommandHelp } from '@/cli/help.js'
import { DEFAULT_DECRYPT_CHUNK_SIZE } from '../backfill.js'
import { TWIN_HAZARD_FLAGS } from '../lib/resolve-pair.js'

const help = renderCommandHelp('decrypt backfill', 'npx stash') ?? ''

describe('decrypt backfill help', () => {
it('states the chunk-size bound and default the command enforces', () => {
expect(help).toContain(`from 1 to ${MAX_DECRYPT_CHUNK_SIZE}`)
expect(help).toMatch(
new RegExp(`--chunk-size <n>[\\s\\S]*${DEFAULT_DECRYPT_CHUNK_SIZE}`),
)
})

it.each(Object.values(TWIN_HAZARD_FLAGS))(
'offers %s, a way past a twin that holds values, as a flag',
(flag) => {
expect(help).toContain(` ${flag}`)
},
)
})
Loading
Loading