@cipherstash/migrate 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,740 @@
1
+ import { ClientBase, PoolClient } from 'pg';
2
+ import { z } from 'zod';
3
+
4
+ /**
5
+ * DDL for `cipherstash.cs_migrations` — the append-only per-column event
6
+ * log that tracks encryption-migration runtime state (phase, backfill
7
+ * cursor, rows processed). Installed by `stash db install`.
8
+ *
9
+ * All statements are `CREATE … IF NOT EXISTS` so running the installer
10
+ * multiple times or alongside an existing deployment is safe.
11
+ *
12
+ * This table is intentionally kept separate from `eql_v2_configuration`:
13
+ * - That table's `data` JSONB has a strict CHECK constraint that forbids
14
+ * custom metadata, so we cannot stuff backfill progress into it.
15
+ * - Its `state` enum is global (`pending`/`encrypting`/`active`/`inactive`
16
+ * — only one of the first three at a time), which cannot represent
17
+ * multiple columns in different phases simultaneously.
18
+ * - Checkpoint writes during backfill would collide with Proxy's 60s
19
+ * config refresh cycle.
20
+ */
21
+ declare const MIGRATIONS_SCHEMA_SQL = "\nCREATE SCHEMA IF NOT EXISTS cipherstash;\n\nCREATE TABLE IF NOT EXISTS cipherstash.cs_migrations (\n id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n table_name text NOT NULL,\n column_name text NOT NULL,\n event text NOT NULL,\n phase text NOT NULL,\n cursor_value text,\n rows_processed bigint,\n rows_total bigint,\n details jsonb,\n created_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE INDEX IF NOT EXISTS cs_migrations_column_id_desc\n ON cipherstash.cs_migrations (table_name, column_name, id DESC);\n";
22
+ /**
23
+ * Create the `cipherstash` schema and `cs_migrations` table if they do not
24
+ * already exist. Safe to call on every `stash db install` invocation.
25
+ *
26
+ * Requires `CREATE SCHEMA` privileges on the database. If the caller lacks
27
+ * them, the query will fail and the error bubbles up — the CLI currently
28
+ * warns but does not abort, on the theory that a human DBA can install
29
+ * this schema out-of-band using `MIGRATIONS_SCHEMA_SQL` directly.
30
+ */
31
+ declare function installMigrationsSchema(client: ClientBase): Promise<void>;
32
+
33
+ /**
34
+ * Discrete event types written to the `cipherstash.cs_migrations` event log.
35
+ *
36
+ * Events are snake_case (for SQL readability); phases are kebab-case (for
37
+ * user-facing CLI output). Most events correspond 1:1 with a phase, except:
38
+ * - `backfill_started` and `backfill_checkpoint` both live inside phase
39
+ * `backfilling`; the `_started` event records the initial intent (and
40
+ * whether we resumed), while each `_checkpoint` records a committed chunk.
41
+ * - `error` records a failure at whatever phase was current; it does not
42
+ * change the effective phase (so a retry resumes from where it failed).
43
+ */
44
+ type MigrationEvent = 'schema_added' | 'dual_writing' | 'backfill_started' | 'backfill_checkpoint' | 'backfilled' | 'cut_over' | 'dropped' | 'error';
45
+ /**
46
+ * The per-column lifecycle phase as surfaced in status/plan output and
47
+ * driven by the `stash encrypt {backfill,cutover,drop}` commands.
48
+ *
49
+ * ```
50
+ * schema-added → the <col>_encrypted column exists and is registered with EQL
51
+ * dual-writing → app writes both plaintext and encrypted on inserts/updates
52
+ * backfilling → runBackfill is (or has been) encrypting historical rows
53
+ * backfilled → all historical rows encrypted; safe to cut over reads
54
+ * cut-over → columns renamed (via eql_v2.rename_encrypted_columns)
55
+ * dropped → old plaintext column removed
56
+ * ```
57
+ */
58
+ type MigrationPhase = 'schema-added' | 'dual-writing' | 'backfilling' | 'backfilled' | 'cut-over' | 'dropped';
59
+ /**
60
+ * Composite key of `<table>.<column>`. Used as the map key by
61
+ * {@link latestByColumn}.
62
+ */
63
+ type ColumnKey = `${string}.${string}`;
64
+ /**
65
+ * A single row from `cipherstash.cs_migrations`, decoded with numeric bigints
66
+ * converted to `number` and column names camel-cased. `id` is a string to
67
+ * avoid JavaScript bigint precision loss for very large tables.
68
+ */
69
+ interface MigrationStateRow {
70
+ /** Row id, stringified from the bigint column. Monotonically increasing. */
71
+ id: string;
72
+ tableName: string;
73
+ columnName: string;
74
+ event: MigrationEvent;
75
+ /** Effective phase *after* this event. */
76
+ phase: MigrationPhase;
77
+ /**
78
+ * Value of `pkColumn` for the last row processed in the most recent
79
+ * chunk. Set on `backfill_checkpoint` and `backfilled`; `null` on other
80
+ * event types. Stored as text so it works for any PK type.
81
+ */
82
+ cursorValue: string | null;
83
+ /** Cumulative rows encrypted. `null` on non-backfill events. */
84
+ rowsProcessed: number | null;
85
+ /** Target rows for this migration. `null` on non-backfill events. */
86
+ rowsTotal: number | null;
87
+ /**
88
+ * Free-form event-specific metadata. Examples: `{ chunkSize, resumed }`
89
+ * on `backfill_started`; `{ message, chunkIndex }` on `error`.
90
+ */
91
+ details: Record<string, unknown> | null;
92
+ createdAt: Date;
93
+ }
94
+ /**
95
+ * Input to {@link appendEvent}. All fields other than `tableName`,
96
+ * `columnName`, `event`, and `phase` are optional and stored as `NULL` when
97
+ * omitted.
98
+ */
99
+ interface AppendEventInput {
100
+ tableName: string;
101
+ columnName: string;
102
+ event: MigrationEvent;
103
+ phase: MigrationPhase;
104
+ cursorValue?: string | null;
105
+ rowsProcessed?: number | null;
106
+ rowsTotal?: number | null;
107
+ details?: Record<string, unknown> | null;
108
+ }
109
+ /**
110
+ * Append a new event row to `cipherstash.cs_migrations`. The table is
111
+ * append-only — existing rows are never updated, so history is preserved
112
+ * and concurrent writers never clobber each other.
113
+ *
114
+ * The "current state" of a column is derived by selecting the row with the
115
+ * greatest `id` for that `(tableName, columnName)` pair.
116
+ */
117
+ declare function appendEvent(client: ClientBase, input: AppendEventInput): Promise<MigrationStateRow>;
118
+ /**
119
+ * Return the most recent event row for every column tracked in
120
+ * `cs_migrations`, keyed by `"<tableName>.<columnName>"`. Used by
121
+ * `stash encrypt status` and `plan` to render a table view.
122
+ *
123
+ * Columns with no recorded events are simply absent from the map — there
124
+ * is no synthetic "nothing happened yet" entry.
125
+ */
126
+ declare function latestByColumn(client: ClientBase): Promise<Map<ColumnKey, MigrationStateRow>>;
127
+ /**
128
+ * Latest event row for a single column, or `null` if the column has no
129
+ * recorded events. Used by the backfill runner to determine whether it is
130
+ * resuming a checkpoint or starting fresh.
131
+ */
132
+ declare function progress(client: ClientBase, tableName: string, columnName: string): Promise<MigrationStateRow | null>;
133
+
134
+ /**
135
+ * Thin, typed wrappers around the EQL (Encrypt Query Language) functions
136
+ * installed by `stash db install`. These mirror the canonical SQL API that
137
+ * CipherStash Proxy also drives, so every action we take here stays
138
+ * visible to Proxy using the same column-level config.
139
+ *
140
+ * Defined by the EQL project at
141
+ * https://github.com/cipherstash/encrypt-query-language — see
142
+ * `src/config/functions.sql` and `src/encryptindex/functions.sql` for the
143
+ * source of truth.
144
+ */
145
+ /**
146
+ * A column that has been registered in the `pending` EQL configuration but
147
+ * is not yet part of the `active` config. Returned by
148
+ * {@link selectPendingColumns}.
149
+ */
150
+ interface PendingColumn {
151
+ tableName: string;
152
+ columnName: string;
153
+ }
154
+ /**
155
+ * Return columns present in the `pending` EQL config but absent (or
156
+ * different) in the `active` one. Wraps `eql_v2.select_pending_columns()`.
157
+ * Useful for showing "what's about to change" before calling
158
+ * {@link readyForEncryption} + activating the pending config.
159
+ */
160
+ declare function selectPendingColumns(client: ClientBase): Promise<PendingColumn[]>;
161
+ /**
162
+ * Check EQL's precondition for activating a pending configuration: every
163
+ * pending column must have a matching `eql_v2_encrypted`-typed target
164
+ * column in the schema. Returns `true` if activation is safe.
165
+ * Wraps `eql_v2.ready_for_encryption()`.
166
+ */
167
+ declare function readyForEncryption(client: ClientBase): Promise<boolean>;
168
+ /**
169
+ * Atomically rename every `<col>` → `<col>_plaintext` and
170
+ * `<col>_encrypted` → `<col>` across tables in the **pending** EQL config.
171
+ * Wraps `eql_v2.rename_encrypted_columns()`.
172
+ *
173
+ * This is the **cut-over primitive**: after this returns, any SQL that
174
+ * reads `<col>` transparently receives the encrypted column (decrypted on
175
+ * read by Proxy or Protect). Call inside a transaction.
176
+ *
177
+ * **Requires a pending configuration.** The underlying EQL function calls
178
+ * `select_pending_columns()` and raises `'No pending configuration exists
179
+ * to encrypt'` if none is registered. Call `db push` to register a pending
180
+ * config first; if no rename targets are present in the diff, the loop
181
+ * inside `rename_encrypted_columns()` does nothing — but the function
182
+ * still requires pending to exist.
183
+ *
184
+ * Renames physical columns only — does not advance the EQL state machine.
185
+ * Pair with {@link migrateConfig} + {@link activateConfig} to finish the
186
+ * pending → encrypting → active transition.
187
+ */
188
+ declare function renameEncryptedColumns(client: ClientBase): Promise<void>;
189
+ /**
190
+ * Advance the EQL state machine: `pending → encrypting`. Wraps
191
+ * `eql_v2.migrate_config()`.
192
+ *
193
+ * Throws when:
194
+ * - There is no pending configuration to migrate.
195
+ * - There is already an encrypting configuration in flight.
196
+ * - Some pending column lacks its encrypted target column (`<col>_encrypted`
197
+ * doesn't exist in the schema with `eql_v2_encrypted` UDT).
198
+ */
199
+ declare function migrateConfig(client: ClientBase): Promise<void>;
200
+ /**
201
+ * Advance the EQL state machine: `encrypting → active`. Wraps
202
+ * `eql_v2.activate_config()`. Marks any prior `active` row as `inactive`
203
+ * in the same call.
204
+ *
205
+ * Throws when there is no encrypting configuration to activate. Always
206
+ * call after {@link migrateConfig} has flipped the pending row to
207
+ * encrypting (typically inside the same transaction as
208
+ * {@link renameEncryptedColumns} for cutover; or alone for non-rename
209
+ * activations like adding a new column to an existing config).
210
+ */
211
+ declare function activateConfig(client: ClientBase): Promise<void>;
212
+ /**
213
+ * Discard the pending configuration without applying it. Wraps
214
+ * `eql_v2.discard()`. Used by `db push` to clean up any stale pending
215
+ * before writing a new one — the state machine only allows one pending
216
+ * row at a time, and a stale pending blocks new pushes.
217
+ *
218
+ * No-op when no pending exists.
219
+ */
220
+ declare function discardPendingConfig(client: ClientBase): Promise<void>;
221
+ /**
222
+ * Nudge Proxy to re-read its config immediately instead of waiting for its
223
+ * next 60-second refresh tick. Wraps `eql_v2.reload_config()`.
224
+ *
225
+ * **Must be executed through a CipherStash Proxy connection** — when
226
+ * connected directly to Postgres, `reload_config()` is a no-op (by design,
227
+ * per the EQL documentation). The CLI's `cutover` command accepts a
228
+ * `--proxy-url` flag and will connect to that separately to issue this.
229
+ */
230
+ declare function reloadConfig(client: ClientBase): Promise<void>;
231
+ /**
232
+ * Return EQL's count of rows in `<tableName>.<columnName>` whose encrypted
233
+ * payload's config version matches the currently active config. Useful as
234
+ * a cheap sanity check — 0 after a backfill generally means something's
235
+ * wrong (wrong config active, or the backfill wrote with a stale version).
236
+ *
237
+ * Wraps `eql_v2.count_encrypted_with_active_config(table, column)`.
238
+ *
239
+ * Returns `bigint` because the underlying Postgres function returns
240
+ * `BIGINT` and naively coercing to JS `number` loses precision past
241
+ * `Number.MAX_SAFE_INTEGER` — exactly the row counts large-table users
242
+ * are running this against. Callers that need a JS number can do their
243
+ * own range check.
244
+ */
245
+ declare function countEncryptedWithActiveConfig(client: ClientBase, tableName: string, columnName: string): Promise<bigint>;
246
+
247
+ /**
248
+ * Inputs to {@link fetchUnencryptedPage}.
249
+ */
250
+ interface KeysetPageOptions {
251
+ /** Physical table name. Supports `schema.table`. */
252
+ tableName: string;
253
+ /**
254
+ * Primary-key column used for keyset pagination. Must be comparable with
255
+ * `>`. Cast to text by the query so any PK type works.
256
+ */
257
+ pkColumn: string;
258
+ /** Column to read the plaintext from, e.g. `email`. */
259
+ plaintextColumn: string;
260
+ /**
261
+ * Target encrypted column, e.g. `email_encrypted`. Rows where this is
262
+ * already non-null are skipped (idempotency guard).
263
+ */
264
+ encryptedColumn: string;
265
+ /**
266
+ * Exclusive lower bound on `pkColumn`. Set to `null` to start from the
267
+ * beginning; pass the `lastPk` of the previous page to continue.
268
+ */
269
+ after: string | null;
270
+ /** Maximum rows returned per call. */
271
+ limit: number;
272
+ /**
273
+ * When true, drop the `<encrypted_col> IS NULL` guard so rows that
274
+ * already have ciphertext are re-emitted. Used by `stash encrypt
275
+ * backfill --force` to recover from drift (e.g. dual-writes weren't
276
+ * actually deployed when an earlier backfill ran). Off by default — the
277
+ * guard makes normal re-runs idempotent.
278
+ */
279
+ force?: boolean;
280
+ }
281
+ /**
282
+ * One page of rows from {@link fetchUnencryptedPage}. `lastPk` is `null`
283
+ * only when the page is empty — the caller's completion signal.
284
+ */
285
+ interface KeysetPage<Row = Record<string, unknown>> {
286
+ rows: Row[];
287
+ lastPk: string | null;
288
+ }
289
+ /**
290
+ * Fetch the next page of rows that still need encryption for a given column.
291
+ *
292
+ * Guards with `plaintext_col IS NOT NULL AND encrypted_col IS NULL` so a
293
+ * concurrent backfill or a re-run never re-processes the same row, even if
294
+ * the pagination cursor is lost. The ORDER BY + LIMIT form gives keyset
295
+ * (seek) pagination rather than OFFSET, which keeps the scan bounded as
296
+ * the cursor advances.
297
+ */
298
+ declare function fetchUnencryptedPage(client: ClientBase, opts: KeysetPageOptions): Promise<KeysetPage<{
299
+ pk: string;
300
+ plaintext: unknown;
301
+ }>>;
302
+ /**
303
+ * Count rows that still need encryption: `plaintext IS NOT NULL AND
304
+ * encrypted IS NULL`. Called once at the start of a backfill to compute
305
+ * `rowsTotal` for progress reporting; does not hold a snapshot, so new
306
+ * rows inserted during the backfill are simply picked up on the next
307
+ * chunk's SELECT.
308
+ *
309
+ * When `force` is true, drops the `encrypted IS NULL` guard — used by
310
+ * `--force` to count every plaintext row, including ones that already
311
+ * have a (potentially stale) ciphertext.
312
+ */
313
+ declare function countUnencrypted(client: ClientBase, tableName: string, plaintextColumn: string, encryptedColumn: string, force?: boolean): Promise<number>;
314
+ /**
315
+ * Quote a possibly schema-qualified table name for use in a SQL statement.
316
+ * `"foo"` → `"foo"`; `"public.foo"` → `"public"."foo"`. Use for identifiers
317
+ * that cannot be parameterised.
318
+ */
319
+ declare function qualifyTable(tableName: string): string;
320
+
321
+ /**
322
+ * Quote a PostgreSQL identifier. Doubles any embedded `"` and wraps in `"`.
323
+ * Use for table/column names that cannot be parameterised.
324
+ */
325
+ declare function quoteIdent(identifier: string): string;
326
+
327
+ /**
328
+ * Shape returned by {@link EncryptionClientLike.bulkEncryptModels} on success.
329
+ * `data` is the array of models with the configured fields replaced by
330
+ * ciphertext payloads, preserving `__pk` and any other non-encrypted fields.
331
+ */
332
+ interface BulkEncryptResultSuccess<T> {
333
+ failure?: undefined;
334
+ data: T[];
335
+ }
336
+ /**
337
+ * Shape returned by {@link EncryptionClientLike.bulkEncryptModels} on failure.
338
+ * Matches `@byteslice/result`'s `{ failure: { message } }` convention used
339
+ * by `@cipherstash/stack`. The backfill halts and writes an `error` event
340
+ * to `cs_migrations` when this is returned.
341
+ */
342
+ interface BulkEncryptResultFailure {
343
+ failure: {
344
+ message: string;
345
+ type?: string;
346
+ };
347
+ data?: undefined;
348
+ }
349
+ /**
350
+ * Discriminated union returned by bulk-encrypt. Narrow with `if (r.failure)`
351
+ * vs `if (r.data)`. No exceptions are thrown by the underlying operation.
352
+ */
353
+ type BulkEncryptResult<T> = BulkEncryptResultSuccess<T> | BulkEncryptResultFailure;
354
+ /**
355
+ * A thenable wrapper around {@link BulkEncryptResult}. `@cipherstash/stack`'s
356
+ * `bulkEncryptModels` returns a fluent operation builder that resolves to a
357
+ * `Result` when awaited; this alias accepts anything `PromiseLike` so we
358
+ * don't bind to the full operation class in type-land.
359
+ */
360
+ type BulkEncryptThenable<T> = PromiseLike<BulkEncryptResult<T>>;
361
+ /**
362
+ * Minimal surface of `@cipherstash/stack`'s `EncryptionClient` used by
363
+ * {@link runBackfill}. Only `bulkEncryptModels` is required — the backfill
364
+ * encrypts in batches, it does not need the single-value `encrypt` API.
365
+ *
366
+ * Supplying an object that duck-types to this interface is enough; you do
367
+ * not have to import `EncryptionClient` itself.
368
+ *
369
+ * @example
370
+ * ```ts
371
+ * // Typical wiring: the user's `src/encryption/index.ts` exports an
372
+ * // already-initialised client, and you pass it through.
373
+ * import { encryptionClient } from './src/encryption/index.js'
374
+ * await runBackfill({ encryptionClient, ... })
375
+ * ```
376
+ */
377
+ interface EncryptionClientLike {
378
+ /**
379
+ * Bulk-encrypt a batch of plaintext models against a table schema.
380
+ *
381
+ * @param input - Array of models. Each row is `{ [schemaColumnKey]: plaintext, ... }`.
382
+ * The backfill also includes a `__pk` field per row so it can correlate
383
+ * the encrypted result back to the database row on UPDATE.
384
+ * @param table - The `EncryptedTable` schema for the target table. Typed
385
+ * as `any` here to keep this library decoupled from `@cipherstash/stack`.
386
+ */
387
+ bulkEncryptModels(input: Array<Record<string, unknown>>, table: any): BulkEncryptThenable<Record<string, unknown>>;
388
+ }
389
+ /**
390
+ * Snapshot of backfill progress, passed to {@link BackfillOptions.onProgress}
391
+ * after every successful chunk commit. Values represent the cumulative state
392
+ * *after* the most recent chunk — including any rows processed by a prior
393
+ * run that this invocation resumed from.
394
+ */
395
+ interface BackfillProgress {
396
+ /** Total rows written to the encrypted column so far (includes a resumed prior run). */
397
+ rowsProcessed: number;
398
+ /** Total rows we expect to process over the life of this migration (incl. resumed). */
399
+ rowsTotal: number;
400
+ /** PK of the last row processed in the most recent chunk, cast to text. */
401
+ lastPk: string | null;
402
+ /** Chunk size used for this run (echoed from {@link BackfillOptions.chunkSize}). */
403
+ chunkSize: number;
404
+ /** Zero-based index of the chunk that just completed. */
405
+ chunkIndex: number;
406
+ }
407
+ /**
408
+ * Options for {@link runBackfill}.
409
+ *
410
+ * Distinguishes three separate name spaces that a reader has to keep straight:
411
+ * - **Physical names** ({@link tableName}, {@link plaintextColumn}, {@link encryptedColumn}, {@link pkColumn})
412
+ * are Postgres identifiers used verbatim in SQL.
413
+ * - **Schema name** ({@link schemaColumnKey}) is the key on the `EncryptedTable`
414
+ * schema object that corresponds to the column. In the common drizzle
415
+ * convention — where the schema declares the encrypted column (not the
416
+ * plaintext one) — this equals `encryptedColumn`. Pass explicitly only
417
+ * when your schema's object keys diverge from the physical column names.
418
+ */
419
+ interface BackfillOptions {
420
+ /**
421
+ * A pg pool client the runner owns for the duration of the call. The
422
+ * runner issues `BEGIN`/`COMMIT` on this connection for each chunk, so it
423
+ * must not be shared across concurrent work during the backfill.
424
+ *
425
+ * Acquire with `const db = await pool.connect()`, release with
426
+ * `db.release()` in your `finally`.
427
+ */
428
+ db: PoolClient;
429
+ /**
430
+ * Initialised encryption client. See {@link EncryptionClientLike} for the
431
+ * required surface (just `bulkEncryptModels`).
432
+ */
433
+ encryptionClient: EncryptionClientLike;
434
+ /**
435
+ * The `EncryptedTable` schema object for the target table, as exported
436
+ * from the user's `src/encryption/index.ts`. Passed through to
437
+ * `encryptionClient.bulkEncryptModels(models, tableSchema)`. Typed as
438
+ * `any` to avoid coupling this library to `@cipherstash/stack`.
439
+ */
440
+ tableSchema: any;
441
+ /**
442
+ * Physical Postgres table name. Supports `"schema.table"` for non-default
443
+ * schemas (identifiers are quoted automatically).
444
+ */
445
+ tableName: string;
446
+ /**
447
+ * The key in {@link tableSchema} that corresponds to this column. With
448
+ * the drizzle `extractEncryptionSchema` convention, where the schema is
449
+ * derived from a table like `{ email_encrypted: encryptedType(...) }`,
450
+ * this equals {@link encryptedColumn}. With a handwritten
451
+ * `encryptedTable('users', { email: … })` schema where there's only one
452
+ * column, this usually equals {@link plaintextColumn}.
453
+ */
454
+ schemaColumnKey: string;
455
+ /**
456
+ * Physical column that holds the plaintext being encrypted, e.g. `email`.
457
+ * The runner reads rows where this is `NOT NULL` and the target encrypted
458
+ * column is `NULL`.
459
+ */
460
+ plaintextColumn: string;
461
+ /**
462
+ * Physical column that receives the `eql_v2_encrypted` ciphertext JSON,
463
+ * e.g. `email_encrypted`. Must already exist (typically created by
464
+ * `drizzle-kit` / a prior migration) before backfill starts.
465
+ */
466
+ encryptedColumn: string;
467
+ /**
468
+ * Physical single-column primary key used for keyset pagination — the
469
+ * runner issues `WHERE pk > $after ORDER BY pk ASC LIMIT $n`. Must be
470
+ * comparable with `>` (bigint, integer, text, uuid all work). Composite
471
+ * primary keys are not yet supported.
472
+ */
473
+ pkColumn: string;
474
+ /**
475
+ * Rows per chunk / transaction. Default 1000. Tune down if you see lock
476
+ * contention, up for tables with small row payloads. A single chunk is
477
+ * one `BEGIN`/`UPDATE`/`INSERT checkpoint`/`COMMIT` cycle, so the value
478
+ * also bounds how much work is lost when you `Ctrl-C` mid-chunk.
479
+ */
480
+ chunkSize?: number;
481
+ /**
482
+ * Optional abort signal. Checked *between* chunks — the in-flight chunk
483
+ * always completes and checkpoints before the loop exits. Safe to wire
484
+ * to `SIGINT` / `SIGTERM`; the CLI does exactly this.
485
+ */
486
+ signal?: AbortSignal;
487
+ /**
488
+ * Invoked synchronously after each chunk has committed. Safe for logging
489
+ * and UI updates; throwing from this callback will kill the backfill.
490
+ */
491
+ onProgress?: (progress: BackfillProgress) => void;
492
+ /**
493
+ * Optional coercion applied to each row's plaintext value before it is
494
+ * passed to {@link EncryptionClientLike.bulkEncryptModels}. Needed when
495
+ * the pg driver's native JS type doesn't match the schema's declared
496
+ * dataType — e.g. pg returns `numeric` as a string, but a schema
497
+ * declaring `dataType('number')` expects a JS number. The CLI
498
+ * builds an appropriate coercer from the schema's `cast_as`; library
499
+ * callers can supply their own or leave undefined (identity).
500
+ */
501
+ transformPlaintext?: (value: unknown) => unknown;
502
+ /**
503
+ * When true, encrypt every row whose plaintext is non-null — including
504
+ * rows that already have a ciphertext in {@link encryptedColumn}.
505
+ *
506
+ * The default backfill skips already-encrypted rows for idempotency.
507
+ * `force` is the recovery path for *drift*: rows where the ciphertext
508
+ * is out of sync with the plaintext because the application was
509
+ * updating the plaintext column without dual-writing the encrypted
510
+ * twin. The drifted ciphertext is overwritten by re-encrypting the
511
+ * current plaintext.
512
+ *
513
+ * Not destructive (overwriting a correctly-encrypted value with a
514
+ * fresh encryption of the same plaintext is wasted work, not data
515
+ * loss), but expensive: every row is re-encrypted even if it didn't
516
+ * need to be. The CLI also appends `details: { force: true }` to the
517
+ * `backfill_started` event so audit queries can spot the recovery
518
+ * runs in `cs_migrations` history.
519
+ */
520
+ force?: boolean;
521
+ }
522
+ /**
523
+ * Return value from {@link runBackfill}.
524
+ */
525
+ interface BackfillResult {
526
+ /**
527
+ * `true` if the run began from a previously-recorded checkpoint rather
528
+ * than starting fresh. Determined by the most recent event for this
529
+ * column being a `backfill_checkpoint`.
530
+ */
531
+ resumed: boolean;
532
+ /**
533
+ * Cumulative rows written to the encrypted column, including any from a
534
+ * prior run this invocation resumed from.
535
+ */
536
+ rowsProcessed: number;
537
+ /**
538
+ * Total rows the migration expects to process end-to-end (including
539
+ * resumed). Computed as `priorProcessed + currentRunTotal` at start.
540
+ */
541
+ rowsTotal: number;
542
+ /**
543
+ * `true` if this run drained all remaining rows. `false` means the run
544
+ * was aborted (via {@link BackfillOptions.signal}) or is otherwise
545
+ * paused and should be resumed by re-invoking with the same options.
546
+ */
547
+ completed: boolean;
548
+ }
549
+ /**
550
+ * Run a chunked, resumable, idempotent backfill of plaintext → encrypted.
551
+ *
552
+ * Per chunk, in a single transaction:
553
+ * 1. `SELECT` the next page of rows where the encrypted column is `NULL`
554
+ * and the PK is greater than the cursor.
555
+ * 2. Encrypt the batch via {@link EncryptionClientLike.bulkEncryptModels}.
556
+ * 3. `UPDATE … FROM (VALUES …)` to write the ciphertext back.
557
+ * 4. `INSERT` a `backfill_checkpoint` event into `cipherstash.cs_migrations`.
558
+ * 5. `COMMIT`.
559
+ *
560
+ * **Idempotency** — the `encrypted IS NULL` guard in both the SELECT and the
561
+ * UPDATE's `WHERE` clause means re-runs never double-write a row, even if
562
+ * the cursor is lost.
563
+ *
564
+ * **Resumability** — restarting with the same arguments will pick up from
565
+ * the last committed checkpoint. Use {@link BackfillOptions.signal} to
566
+ * abort cleanly on `SIGINT`.
567
+ *
568
+ * **Failure handling** — if any chunk fails (encrypt error or DB error),
569
+ * the transaction is rolled back, an `error` event is appended to
570
+ * `cs_migrations` for diagnostics, and the error is re-thrown.
571
+ *
572
+ * @example
573
+ * ```ts
574
+ * const db = await pool.connect()
575
+ * try {
576
+ * const result = await runBackfill({
577
+ * db,
578
+ * encryptionClient,
579
+ * tableSchema: usersTable,
580
+ * tableName: 'users',
581
+ * schemaColumnKey: 'email',
582
+ * plaintextColumn: 'email',
583
+ * encryptedColumn: 'email_encrypted',
584
+ * pkColumn: 'id',
585
+ * chunkSize: 1000,
586
+ * onProgress: (p) => console.log(`${p.rowsProcessed}/${p.rowsTotal}`),
587
+ * })
588
+ * console.log(result.completed ? 'done' : 'paused — re-run to resume')
589
+ * } finally {
590
+ * db.release()
591
+ * }
592
+ * ```
593
+ */
594
+ declare function runBackfill(options: BackfillOptions): Promise<BackfillResult>;
595
+
596
+ /**
597
+ * Intent for a single column within the manifest. Expresses *what the user
598
+ * wants the state of this column to be*, not the observed reality — the
599
+ * `status` / `plan` commands diff this against `cs_migrations` and EQL to
600
+ * surface drift.
601
+ */
602
+ declare const ManifestColumnSchema: z.ZodObject<{
603
+ /** Physical column name, e.g. `email`. */
604
+ column: z.ZodString;
605
+ /**
606
+ * EQL cast type. Text by default. See the EQL docs for the full list
607
+ * (`text | int | small_int | big_int | real | double | boolean | date |
608
+ * jsonb | json | float | decimal | timestamp`).
609
+ */
610
+ castAs: z.ZodDefault<z.ZodEnum<["text", "int", "small_int", "big_int", "real", "double", "boolean", "date", "jsonb", "json", "float", "decimal", "timestamp"]>>;
611
+ /** Desired EQL index set. Driver of the `indexes: {…}` block in EQL config. */
612
+ indexes: z.ZodDefault<z.ZodArray<z.ZodEnum<["unique", "match", "ore", "ste_vec"]>, "many">>;
613
+ /**
614
+ * The phase the user wants this column to reach. `cut-over` is the
615
+ * typical end state (reads transparently decrypted); advance to
616
+ * `dropped` only once you're confident the plaintext column is no
617
+ * longer needed.
618
+ */
619
+ targetPhase: z.ZodDefault<z.ZodEnum<["schema-added", "dual-writing", "backfilled", "cut-over", "dropped"]>>;
620
+ /**
621
+ * Override for primary-key detection during backfill. Omit to let the
622
+ * CLI auto-detect via `information_schema`.
623
+ */
624
+ pkColumn: z.ZodOptional<z.ZodString>;
625
+ }, "strip", z.ZodTypeAny, {
626
+ column: string;
627
+ castAs: "boolean" | "text" | "int" | "small_int" | "big_int" | "real" | "double" | "date" | "jsonb" | "json" | "float" | "decimal" | "timestamp";
628
+ indexes: ("unique" | "match" | "ore" | "ste_vec")[];
629
+ targetPhase: "backfilled" | "dropped" | "schema-added" | "dual-writing" | "cut-over";
630
+ pkColumn?: string | undefined;
631
+ }, {
632
+ column: string;
633
+ pkColumn?: string | undefined;
634
+ castAs?: "boolean" | "text" | "int" | "small_int" | "big_int" | "real" | "double" | "date" | "jsonb" | "json" | "float" | "decimal" | "timestamp" | undefined;
635
+ indexes?: ("unique" | "match" | "ore" | "ste_vec")[] | undefined;
636
+ targetPhase?: "backfilled" | "dropped" | "schema-added" | "dual-writing" | "cut-over" | undefined;
637
+ }>;
638
+ /**
639
+ * Root manifest shape. Stored at `.cipherstash/migrations.json`. Versioned
640
+ * (currently `1`) so we can evolve it without breaking existing manifests.
641
+ */
642
+ declare const ManifestSchema: z.ZodObject<{
643
+ version: z.ZodDefault<z.ZodLiteral<1>>;
644
+ /** Map of table name → array of column intents for that table. */
645
+ tables: z.ZodRecord<z.ZodString, z.ZodArray<z.ZodObject<{
646
+ /** Physical column name, e.g. `email`. */
647
+ column: z.ZodString;
648
+ /**
649
+ * EQL cast type. Text by default. See the EQL docs for the full list
650
+ * (`text | int | small_int | big_int | real | double | boolean | date |
651
+ * jsonb | json | float | decimal | timestamp`).
652
+ */
653
+ castAs: z.ZodDefault<z.ZodEnum<["text", "int", "small_int", "big_int", "real", "double", "boolean", "date", "jsonb", "json", "float", "decimal", "timestamp"]>>;
654
+ /** Desired EQL index set. Driver of the `indexes: {…}` block in EQL config. */
655
+ indexes: z.ZodDefault<z.ZodArray<z.ZodEnum<["unique", "match", "ore", "ste_vec"]>, "many">>;
656
+ /**
657
+ * The phase the user wants this column to reach. `cut-over` is the
658
+ * typical end state (reads transparently decrypted); advance to
659
+ * `dropped` only once you're confident the plaintext column is no
660
+ * longer needed.
661
+ */
662
+ targetPhase: z.ZodDefault<z.ZodEnum<["schema-added", "dual-writing", "backfilled", "cut-over", "dropped"]>>;
663
+ /**
664
+ * Override for primary-key detection during backfill. Omit to let the
665
+ * CLI auto-detect via `information_schema`.
666
+ */
667
+ pkColumn: z.ZodOptional<z.ZodString>;
668
+ }, "strip", z.ZodTypeAny, {
669
+ column: string;
670
+ castAs: "boolean" | "text" | "int" | "small_int" | "big_int" | "real" | "double" | "date" | "jsonb" | "json" | "float" | "decimal" | "timestamp";
671
+ indexes: ("unique" | "match" | "ore" | "ste_vec")[];
672
+ targetPhase: "backfilled" | "dropped" | "schema-added" | "dual-writing" | "cut-over";
673
+ pkColumn?: string | undefined;
674
+ }, {
675
+ column: string;
676
+ pkColumn?: string | undefined;
677
+ castAs?: "boolean" | "text" | "int" | "small_int" | "big_int" | "real" | "double" | "date" | "jsonb" | "json" | "float" | "decimal" | "timestamp" | undefined;
678
+ indexes?: ("unique" | "match" | "ore" | "ste_vec")[] | undefined;
679
+ targetPhase?: "backfilled" | "dropped" | "schema-added" | "dual-writing" | "cut-over" | undefined;
680
+ }>, "many">>;
681
+ }, "strip", z.ZodTypeAny, {
682
+ version: 1;
683
+ tables: Record<string, {
684
+ column: string;
685
+ castAs: "boolean" | "text" | "int" | "small_int" | "big_int" | "real" | "double" | "date" | "jsonb" | "json" | "float" | "decimal" | "timestamp";
686
+ indexes: ("unique" | "match" | "ore" | "ste_vec")[];
687
+ targetPhase: "backfilled" | "dropped" | "schema-added" | "dual-writing" | "cut-over";
688
+ pkColumn?: string | undefined;
689
+ }[]>;
690
+ }, {
691
+ tables: Record<string, {
692
+ column: string;
693
+ pkColumn?: string | undefined;
694
+ castAs?: "boolean" | "text" | "int" | "small_int" | "big_int" | "real" | "double" | "date" | "jsonb" | "json" | "float" | "decimal" | "timestamp" | undefined;
695
+ indexes?: ("unique" | "match" | "ore" | "ste_vec")[] | undefined;
696
+ targetPhase?: "backfilled" | "dropped" | "schema-added" | "dual-writing" | "cut-over" | undefined;
697
+ }[]>;
698
+ version?: 1 | undefined;
699
+ }>;
700
+ type Manifest = z.infer<typeof ManifestSchema>;
701
+ type ManifestColumn = z.infer<typeof ManifestColumnSchema>;
702
+ /** Canonical on-disk location for the manifest. */
703
+ declare function manifestPath(cwd?: string): string;
704
+ /**
705
+ * Read and validate the manifest. Returns `null` when no manifest file
706
+ * exists (this is not an error — most commands still work without one;
707
+ * they just can't show intent-vs-observed drift).
708
+ *
709
+ * Throws on schema validation failures (zod errors).
710
+ */
711
+ declare function readManifest(cwd?: string): Promise<Manifest | null>;
712
+ /**
713
+ * Validate and write the manifest to `.cipherstash/migrations.json`,
714
+ * creating the directory if it doesn't exist. Rewrites the file
715
+ * atomically-enough for config purposes; not safe under concurrent writers.
716
+ */
717
+ declare function writeManifest(manifest: Manifest, cwd?: string): Promise<void>;
718
+ /**
719
+ * Read the manifest, upsert a single column entry under the named table,
720
+ * and write it back. If no manifest exists, creates one. If the column
721
+ * already exists for the table, the existing entry is replaced with the
722
+ * supplied values — useful for keeping the intent in lockstep with what
723
+ * the lifecycle commands actually committed.
724
+ *
725
+ * Called by `stash encrypt backfill` on first run for a column so the
726
+ * intent leg of the three-source state model exists in the repo. The
727
+ * agent (or the user) is free to hand-edit the file later — re-running
728
+ * `backfill` won't clobber a column the user has annotated, only the
729
+ * fields this function controls.
730
+ */
731
+ declare function upsertManifestColumn(table: string, column: ManifestColumn, cwd?: string): Promise<void>;
732
+ /**
733
+ * Update just the `targetPhase` of an existing column entry. No-op if
734
+ * the column isn't tracked yet — used by `stash encrypt drop` to bump
735
+ * the intent forward when the user commits to removing the plaintext
736
+ * column entirely.
737
+ */
738
+ declare function setManifestTargetPhase(table: string, columnName: string, targetPhase: ManifestColumn['targetPhase'], cwd?: string): Promise<void>;
739
+
740
+ export { type BackfillOptions, type BackfillProgress, type BackfillResult, type ColumnKey, type KeysetPage, type KeysetPageOptions, MIGRATIONS_SCHEMA_SQL, type Manifest, type ManifestColumn, type MigrationEvent, type MigrationPhase, type MigrationStateRow, activateConfig, appendEvent, countEncryptedWithActiveConfig, countUnencrypted, discardPendingConfig, fetchUnencryptedPage, installMigrationsSchema, latestByColumn, manifestPath, migrateConfig, progress, qualifyTable, quoteIdent, readManifest, readyForEncryption, reloadConfig, renameEncryptedColumns, runBackfill, selectPendingColumns, setManifestTargetPhase, upsertManifestColumn, writeManifest };