@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.
package/dist/index.js ADDED
@@ -0,0 +1,465 @@
1
+ // src/install.ts
2
+ var MIGRATIONS_SCHEMA_SQL = `
3
+ CREATE SCHEMA IF NOT EXISTS cipherstash;
4
+
5
+ CREATE TABLE IF NOT EXISTS cipherstash.cs_migrations (
6
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
7
+ table_name text NOT NULL,
8
+ column_name text NOT NULL,
9
+ event text NOT NULL,
10
+ phase text NOT NULL,
11
+ cursor_value text,
12
+ rows_processed bigint,
13
+ rows_total bigint,
14
+ details jsonb,
15
+ created_at timestamptz NOT NULL DEFAULT now()
16
+ );
17
+
18
+ CREATE INDEX IF NOT EXISTS cs_migrations_column_id_desc
19
+ ON cipherstash.cs_migrations (table_name, column_name, id DESC);
20
+ `;
21
+ async function installMigrationsSchema(client) {
22
+ await client.query(MIGRATIONS_SCHEMA_SQL);
23
+ }
24
+
25
+ // src/state.ts
26
+ async function appendEvent(client, input) {
27
+ const result = await client.query(
28
+ `INSERT INTO cipherstash.cs_migrations
29
+ (table_name, column_name, event, phase, cursor_value, rows_processed, rows_total, details)
30
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
31
+ RETURNING id, table_name, column_name, event, phase, cursor_value, rows_processed, rows_total, details, created_at`,
32
+ [
33
+ input.tableName,
34
+ input.columnName,
35
+ input.event,
36
+ input.phase,
37
+ input.cursorValue ?? null,
38
+ input.rowsProcessed ?? null,
39
+ input.rowsTotal ?? null,
40
+ input.details ?? null
41
+ ]
42
+ );
43
+ return rowToState(result.rows[0]);
44
+ }
45
+ async function latestByColumn(client) {
46
+ const result = await client.query(
47
+ `SELECT DISTINCT ON (table_name, column_name)
48
+ id, table_name, column_name, event, phase, cursor_value, rows_processed, rows_total, details, created_at
49
+ FROM cipherstash.cs_migrations
50
+ ORDER BY table_name, column_name, id DESC`
51
+ );
52
+ const map = /* @__PURE__ */ new Map();
53
+ for (const row of result.rows) {
54
+ const state = rowToState(row);
55
+ map.set(`${state.tableName}.${state.columnName}`, state);
56
+ }
57
+ return map;
58
+ }
59
+ async function progress(client, tableName, columnName) {
60
+ const result = await client.query(
61
+ `SELECT id, table_name, column_name, event, phase, cursor_value, rows_processed, rows_total, details, created_at
62
+ FROM cipherstash.cs_migrations
63
+ WHERE table_name = $1 AND column_name = $2
64
+ ORDER BY id DESC
65
+ LIMIT 1`,
66
+ [tableName, columnName]
67
+ );
68
+ if (result.rows.length === 0) return null;
69
+ return rowToState(result.rows[0]);
70
+ }
71
+ function rowToState(row) {
72
+ return {
73
+ id: String(row.id),
74
+ tableName: row.table_name,
75
+ columnName: row.column_name,
76
+ event: row.event,
77
+ phase: row.phase,
78
+ cursorValue: row.cursor_value,
79
+ rowsProcessed: row.rows_processed === null ? null : Number(row.rows_processed),
80
+ rowsTotal: row.rows_total === null ? null : Number(row.rows_total),
81
+ details: row.details,
82
+ createdAt: row.created_at
83
+ };
84
+ }
85
+
86
+ // src/eql.ts
87
+ async function selectPendingColumns(client) {
88
+ const result = await client.query("SELECT table_name, column_name FROM eql_v2.select_pending_columns()");
89
+ return result.rows.map((row) => ({
90
+ tableName: row.table_name,
91
+ columnName: row.column_name
92
+ }));
93
+ }
94
+ async function readyForEncryption(client) {
95
+ const result = await client.query(
96
+ "SELECT eql_v2.ready_for_encryption() AS ready"
97
+ );
98
+ return result.rows[0]?.ready === true;
99
+ }
100
+ async function renameEncryptedColumns(client) {
101
+ await client.query("SELECT eql_v2.rename_encrypted_columns()");
102
+ }
103
+ async function migrateConfig(client) {
104
+ await client.query("SELECT eql_v2.migrate_config()");
105
+ }
106
+ async function activateConfig(client) {
107
+ await client.query("SELECT eql_v2.activate_config()");
108
+ }
109
+ async function discardPendingConfig(client) {
110
+ await client.query(
111
+ "DELETE FROM public.eql_v2_configuration WHERE state = 'pending'"
112
+ );
113
+ }
114
+ async function reloadConfig(client) {
115
+ await client.query("SELECT eql_v2.reload_config()");
116
+ }
117
+ async function countEncryptedWithActiveConfig(client, tableName, columnName) {
118
+ const result = await client.query(
119
+ "SELECT eql_v2.count_encrypted_with_active_config($1, $2) AS count",
120
+ [tableName, columnName]
121
+ );
122
+ return BigInt(result.rows[0]?.count ?? "0");
123
+ }
124
+
125
+ // src/sql.ts
126
+ function quoteIdent(identifier) {
127
+ return `"${identifier.replace(/"/g, '""')}"`;
128
+ }
129
+
130
+ // src/cursor.ts
131
+ async function fetchUnencryptedPage(client, opts) {
132
+ const pk = quoteIdent(opts.pkColumn);
133
+ const plain = quoteIdent(opts.plaintextColumn);
134
+ const enc = quoteIdent(opts.encryptedColumn);
135
+ const table = qualifyTable(opts.tableName);
136
+ const params = [];
137
+ let where = opts.force ? `${plain} IS NOT NULL` : `${plain} IS NOT NULL AND ${enc} IS NULL`;
138
+ if (opts.after !== null) {
139
+ params.push(opts.after);
140
+ where += ` AND ${pk} > $${params.length}`;
141
+ }
142
+ params.push(opts.limit);
143
+ const limitParam = `$${params.length}`;
144
+ const sql = `
145
+ SELECT ${pk}::text AS pk, ${plain} AS plaintext
146
+ FROM ${table}
147
+ WHERE ${where}
148
+ ORDER BY ${pk} ASC
149
+ LIMIT ${limitParam}
150
+ `;
151
+ const result = await client.query(
152
+ sql,
153
+ params
154
+ );
155
+ const rows = result.rows;
156
+ const lastPk = rows.length > 0 ? rows[rows.length - 1]?.pk : null;
157
+ return { rows, lastPk };
158
+ }
159
+ async function countUnencrypted(client, tableName, plaintextColumn, encryptedColumn, force = false) {
160
+ const plain = quoteIdent(plaintextColumn);
161
+ const enc = quoteIdent(encryptedColumn);
162
+ const table = qualifyTable(tableName);
163
+ const where = force ? `${plain} IS NOT NULL` : `${plain} IS NOT NULL AND ${enc} IS NULL`;
164
+ const result = await client.query(
165
+ `SELECT count(*)::text AS count FROM ${table} WHERE ${where}`
166
+ );
167
+ return Number(result.rows[0]?.count ?? 0);
168
+ }
169
+ function qualifyTable(tableName) {
170
+ if (tableName.includes(".")) {
171
+ const parts = tableName.split(".");
172
+ return parts.map(quoteIdent).join(".");
173
+ }
174
+ return quoteIdent(tableName);
175
+ }
176
+
177
+ // src/backfill.ts
178
+ import { isEncryptedPayload } from "@cipherstash/stack";
179
+ async function runBackfill(options) {
180
+ const chunkSize = options.chunkSize ?? 1e3;
181
+ const { db, tableName, pkColumn, plaintextColumn, encryptedColumn } = options;
182
+ const force = options.force ?? false;
183
+ const rowsTotal = await countUnencrypted(
184
+ db,
185
+ tableName,
186
+ plaintextColumn,
187
+ encryptedColumn,
188
+ force
189
+ );
190
+ const last = await progress(db, tableName, plaintextColumn);
191
+ const resumeCursor = last?.event === "backfill_checkpoint" ? last.cursorValue : null;
192
+ const resumed = resumeCursor !== null;
193
+ const priorProcessed = last?.event === "backfill_checkpoint" ? last.rowsProcessed ?? 0 : 0;
194
+ await appendEvent(db, {
195
+ tableName,
196
+ columnName: plaintextColumn,
197
+ event: "backfill_started",
198
+ phase: "backfilling",
199
+ cursorValue: resumeCursor,
200
+ rowsProcessed: priorProcessed,
201
+ rowsTotal: priorProcessed + rowsTotal,
202
+ details: { chunkSize, resumed, ...force ? { force: true } : {} }
203
+ });
204
+ let cursor = resumeCursor;
205
+ let rowsProcessed = priorProcessed;
206
+ const rowsTotalWithResumed = priorProcessed + rowsTotal;
207
+ let chunkIndex = 0;
208
+ let completed = false;
209
+ try {
210
+ while (true) {
211
+ if (options.signal?.aborted) break;
212
+ const page = await fetchUnencryptedPage(db, {
213
+ tableName,
214
+ pkColumn,
215
+ plaintextColumn,
216
+ encryptedColumn,
217
+ after: cursor,
218
+ limit: chunkSize,
219
+ force
220
+ });
221
+ if (page.rows.length === 0) {
222
+ completed = true;
223
+ break;
224
+ }
225
+ const coerce = options.transformPlaintext ?? ((v) => v);
226
+ const models = page.rows.map((row) => ({
227
+ __pk: row.pk,
228
+ [options.schemaColumnKey]: coerce(row.plaintext)
229
+ }));
230
+ const encryptResult = await options.encryptionClient.bulkEncryptModels(
231
+ models,
232
+ options.tableSchema
233
+ );
234
+ if (encryptResult.failure) {
235
+ throw new Error(
236
+ `bulkEncryptModels failed: ${encryptResult.failure.message}`
237
+ );
238
+ }
239
+ for (const [i, row] of encryptResult.data.entries()) {
240
+ const value = row[options.schemaColumnKey];
241
+ if (!isEncryptedPayload(value)) {
242
+ const pk = row.__pk ?? page.rows[i]?.pk;
243
+ const valueType = value === null ? "null" : typeof value;
244
+ throw new Error(
245
+ `Encryption client returned a non-ciphertext value (type: ${valueType}) at model key "${options.schemaColumnKey}" for pk=${pk}. This usually means the schema column key does not match your EncryptedTable. Verify that your schema declares a column keyed "${options.schemaColumnKey}", or pass --schema-column-key <name> to override.`
246
+ );
247
+ }
248
+ }
249
+ await db.query("BEGIN");
250
+ try {
251
+ await writeEncryptedChunk(db, {
252
+ tableName,
253
+ pkColumn,
254
+ encryptedColumn,
255
+ schemaColumnKey: options.schemaColumnKey,
256
+ encryptedRows: encryptResult.data,
257
+ force
258
+ });
259
+ rowsProcessed += page.rows.length;
260
+ cursor = page.lastPk;
261
+ await appendEvent(db, {
262
+ tableName,
263
+ columnName: plaintextColumn,
264
+ event: "backfill_checkpoint",
265
+ phase: "backfilling",
266
+ cursorValue: cursor,
267
+ rowsProcessed,
268
+ rowsTotal: rowsTotalWithResumed,
269
+ details: { chunkIndex, chunkRows: page.rows.length }
270
+ });
271
+ await db.query("COMMIT");
272
+ } catch (err) {
273
+ await db.query("ROLLBACK").catch(() => {
274
+ });
275
+ throw err;
276
+ }
277
+ options.onProgress?.({
278
+ rowsProcessed,
279
+ rowsTotal: rowsTotalWithResumed,
280
+ lastPk: cursor,
281
+ chunkSize,
282
+ chunkIndex
283
+ });
284
+ chunkIndex += 1;
285
+ }
286
+ if (completed) {
287
+ await appendEvent(db, {
288
+ tableName,
289
+ columnName: plaintextColumn,
290
+ event: "backfilled",
291
+ phase: "backfilled",
292
+ cursorValue: cursor,
293
+ rowsProcessed,
294
+ rowsTotal: rowsTotalWithResumed,
295
+ details: { chunkCount: chunkIndex }
296
+ });
297
+ }
298
+ } catch (err) {
299
+ await appendEvent(db, {
300
+ tableName,
301
+ columnName: plaintextColumn,
302
+ event: "error",
303
+ phase: "backfilling",
304
+ cursorValue: cursor,
305
+ rowsProcessed,
306
+ rowsTotal: rowsTotalWithResumed,
307
+ details: {
308
+ message: err instanceof Error ? err.message : String(err),
309
+ chunkIndex
310
+ }
311
+ });
312
+ throw err;
313
+ }
314
+ return {
315
+ resumed,
316
+ rowsProcessed,
317
+ rowsTotal: rowsTotalWithResumed,
318
+ completed
319
+ };
320
+ }
321
+ async function writeEncryptedChunk(db, opts) {
322
+ if (opts.encryptedRows.length === 0) return;
323
+ const table = qualifyTable(opts.tableName);
324
+ const pk = quoteIdent(opts.pkColumn);
325
+ const enc = quoteIdent(opts.encryptedColumn);
326
+ const params = [];
327
+ const valuesSql = opts.encryptedRows.map((row) => {
328
+ const pkValue = row.__pk;
329
+ const encryptedValue = row[opts.schemaColumnKey];
330
+ params.push(pkValue);
331
+ const pkParam = `$${params.length}`;
332
+ params.push(encryptedValue);
333
+ const encParam = `$${params.length}::jsonb`;
334
+ return `(${pkParam}, ${encParam})`;
335
+ }).join(", ");
336
+ const where = opts.force ? `t.${pk}::text = v.pk::text` : `t.${pk}::text = v.pk::text AND t.${enc} IS NULL`;
337
+ const sql = `
338
+ UPDATE ${table} AS t
339
+ SET ${enc} = v.enc
340
+ FROM (VALUES ${valuesSql}) AS v(pk, enc)
341
+ WHERE ${where}
342
+ `;
343
+ await db.query(sql, params);
344
+ }
345
+
346
+ // src/manifest.ts
347
+ import * as fs from "node:fs/promises";
348
+ import * as path from "node:path";
349
+ import { z } from "zod";
350
+ var IndexKind = z.enum(["unique", "match", "ore", "ste_vec"]);
351
+ var CastAs = z.enum([
352
+ "text",
353
+ "int",
354
+ "small_int",
355
+ "big_int",
356
+ "real",
357
+ "double",
358
+ "boolean",
359
+ "date",
360
+ "jsonb",
361
+ "json",
362
+ "float",
363
+ "decimal",
364
+ "timestamp"
365
+ ]);
366
+ var ManifestColumnSchema = z.object({
367
+ /** Physical column name, e.g. `email`. */
368
+ column: z.string(),
369
+ /**
370
+ * EQL cast type. Text by default. See the EQL docs for the full list
371
+ * (`text | int | small_int | big_int | real | double | boolean | date |
372
+ * jsonb | json | float | decimal | timestamp`).
373
+ */
374
+ castAs: CastAs.default("text"),
375
+ /** Desired EQL index set. Driver of the `indexes: {…}` block in EQL config. */
376
+ indexes: z.array(IndexKind).default([]),
377
+ /**
378
+ * The phase the user wants this column to reach. `cut-over` is the
379
+ * typical end state (reads transparently decrypted); advance to
380
+ * `dropped` only once you're confident the plaintext column is no
381
+ * longer needed.
382
+ */
383
+ targetPhase: z.enum(["schema-added", "dual-writing", "backfilled", "cut-over", "dropped"]).default("cut-over"),
384
+ /**
385
+ * Override for primary-key detection during backfill. Omit to let the
386
+ * CLI auto-detect via `information_schema`.
387
+ */
388
+ pkColumn: z.string().optional()
389
+ });
390
+ var ManifestSchema = z.object({
391
+ version: z.literal(1).default(1),
392
+ /** Map of table name → array of column intents for that table. */
393
+ tables: z.record(z.array(ManifestColumnSchema))
394
+ });
395
+ function manifestPath(cwd = process.cwd()) {
396
+ return path.join(cwd, ".cipherstash", "migrations.json");
397
+ }
398
+ async function readManifest(cwd = process.cwd()) {
399
+ const filePath = manifestPath(cwd);
400
+ let raw;
401
+ try {
402
+ raw = await fs.readFile(filePath, "utf-8");
403
+ } catch (err) {
404
+ if (err.code === "ENOENT") return null;
405
+ throw err;
406
+ }
407
+ const parsed = ManifestSchema.parse(JSON.parse(raw));
408
+ return parsed;
409
+ }
410
+ async function writeManifest(manifest, cwd = process.cwd()) {
411
+ const filePath = manifestPath(cwd);
412
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
413
+ const validated = ManifestSchema.parse(manifest);
414
+ await fs.writeFile(
415
+ filePath,
416
+ `${JSON.stringify(validated, null, 2)}
417
+ `,
418
+ "utf-8"
419
+ );
420
+ }
421
+ async function upsertManifestColumn(table, column, cwd = process.cwd()) {
422
+ const existing = await readManifest(cwd) ?? { version: 1, tables: {} };
423
+ const tableColumns = existing.tables[table] ?? [];
424
+ const remaining = tableColumns.filter((c) => c.column !== column.column);
425
+ existing.tables[table] = [...remaining, column];
426
+ await writeManifest(existing, cwd);
427
+ }
428
+ async function setManifestTargetPhase(table, columnName, targetPhase, cwd = process.cwd()) {
429
+ const existing = await readManifest(cwd);
430
+ if (!existing) return;
431
+ const tableColumns = existing.tables[table];
432
+ if (!tableColumns) return;
433
+ const current = tableColumns.find((c) => c.column === columnName);
434
+ if (!current) return;
435
+ existing.tables[table] = tableColumns.map(
436
+ (c) => c.column === columnName ? { ...c, targetPhase } : c
437
+ );
438
+ await writeManifest(existing, cwd);
439
+ }
440
+ export {
441
+ MIGRATIONS_SCHEMA_SQL,
442
+ activateConfig,
443
+ appendEvent,
444
+ countEncryptedWithActiveConfig,
445
+ countUnencrypted,
446
+ discardPendingConfig,
447
+ fetchUnencryptedPage,
448
+ installMigrationsSchema,
449
+ latestByColumn,
450
+ manifestPath,
451
+ migrateConfig,
452
+ progress,
453
+ qualifyTable,
454
+ quoteIdent,
455
+ readManifest,
456
+ readyForEncryption,
457
+ reloadConfig,
458
+ renameEncryptedColumns,
459
+ runBackfill,
460
+ selectPendingColumns,
461
+ setManifestTargetPhase,
462
+ upsertManifestColumn,
463
+ writeManifest
464
+ };
465
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/install.ts","../src/state.ts","../src/eql.ts","../src/sql.ts","../src/cursor.ts","../src/backfill.ts","../src/manifest.ts"],"sourcesContent":["import type { ClientBase } from 'pg'\n\n/**\n * DDL for `cipherstash.cs_migrations` — the append-only per-column event\n * log that tracks encryption-migration runtime state (phase, backfill\n * cursor, rows processed). Installed by `stash db install`.\n *\n * All statements are `CREATE … IF NOT EXISTS` so running the installer\n * multiple times or alongside an existing deployment is safe.\n *\n * This table is intentionally kept separate from `eql_v2_configuration`:\n * - That table's `data` JSONB has a strict CHECK constraint that forbids\n * custom metadata, so we cannot stuff backfill progress into it.\n * - Its `state` enum is global (`pending`/`encrypting`/`active`/`inactive`\n * — only one of the first three at a time), which cannot represent\n * multiple columns in different phases simultaneously.\n * - Checkpoint writes during backfill would collide with Proxy's 60s\n * config refresh cycle.\n */\nexport 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`\n\n/**\n * Create the `cipherstash` schema and `cs_migrations` table if they do not\n * already exist. Safe to call on every `stash db install` invocation.\n *\n * Requires `CREATE SCHEMA` privileges on the database. If the caller lacks\n * them, the query will fail and the error bubbles up — the CLI currently\n * warns but does not abort, on the theory that a human DBA can install\n * this schema out-of-band using `MIGRATIONS_SCHEMA_SQL` directly.\n */\nexport async function installMigrationsSchema(\n client: ClientBase,\n): Promise<void> {\n await client.query(MIGRATIONS_SCHEMA_SQL)\n}\n","import type { ClientBase } from 'pg'\n\n/**\n * Discrete event types written to the `cipherstash.cs_migrations` event log.\n *\n * Events are snake_case (for SQL readability); phases are kebab-case (for\n * user-facing CLI output). Most events correspond 1:1 with a phase, except:\n * - `backfill_started` and `backfill_checkpoint` both live inside phase\n * `backfilling`; the `_started` event records the initial intent (and\n * whether we resumed), while each `_checkpoint` records a committed chunk.\n * - `error` records a failure at whatever phase was current; it does not\n * change the effective phase (so a retry resumes from where it failed).\n */\nexport type MigrationEvent =\n | 'schema_added'\n | 'dual_writing'\n | 'backfill_started'\n | 'backfill_checkpoint'\n | 'backfilled'\n | 'cut_over'\n | 'dropped'\n | 'error'\n\n/**\n * The per-column lifecycle phase as surfaced in status/plan output and\n * driven by the `stash encrypt {backfill,cutover,drop}` commands.\n *\n * ```\n * schema-added → the <col>_encrypted column exists and is registered with EQL\n * dual-writing → app writes both plaintext and encrypted on inserts/updates\n * backfilling → runBackfill is (or has been) encrypting historical rows\n * backfilled → all historical rows encrypted; safe to cut over reads\n * cut-over → columns renamed (via eql_v2.rename_encrypted_columns)\n * dropped → old plaintext column removed\n * ```\n */\nexport type MigrationPhase =\n | 'schema-added'\n | 'dual-writing'\n | 'backfilling'\n | 'backfilled'\n | 'cut-over'\n | 'dropped'\n\n/**\n * Composite key of `<table>.<column>`. Used as the map key by\n * {@link latestByColumn}.\n */\nexport type ColumnKey = `${string}.${string}`\n\n/**\n * A single row from `cipherstash.cs_migrations`, decoded with numeric bigints\n * converted to `number` and column names camel-cased. `id` is a string to\n * avoid JavaScript bigint precision loss for very large tables.\n */\nexport interface MigrationStateRow {\n /** Row id, stringified from the bigint column. Monotonically increasing. */\n id: string\n tableName: string\n columnName: string\n event: MigrationEvent\n /** Effective phase *after* this event. */\n phase: MigrationPhase\n /**\n * Value of `pkColumn` for the last row processed in the most recent\n * chunk. Set on `backfill_checkpoint` and `backfilled`; `null` on other\n * event types. Stored as text so it works for any PK type.\n */\n cursorValue: string | null\n /** Cumulative rows encrypted. `null` on non-backfill events. */\n rowsProcessed: number | null\n /** Target rows for this migration. `null` on non-backfill events. */\n rowsTotal: number | null\n /**\n * Free-form event-specific metadata. Examples: `{ chunkSize, resumed }`\n * on `backfill_started`; `{ message, chunkIndex }` on `error`.\n */\n details: Record<string, unknown> | null\n createdAt: Date\n}\n\n/**\n * Input to {@link appendEvent}. All fields other than `tableName`,\n * `columnName`, `event`, and `phase` are optional and stored as `NULL` when\n * omitted.\n */\nexport interface AppendEventInput {\n tableName: string\n columnName: string\n event: MigrationEvent\n phase: MigrationPhase\n cursorValue?: string | null\n rowsProcessed?: number | null\n rowsTotal?: number | null\n details?: Record<string, unknown> | null\n}\n\n/**\n * Append a new event row to `cipherstash.cs_migrations`. The table is\n * append-only — existing rows are never updated, so history is preserved\n * and concurrent writers never clobber each other.\n *\n * The \"current state\" of a column is derived by selecting the row with the\n * greatest `id` for that `(tableName, columnName)` pair.\n */\nexport async function appendEvent(\n client: ClientBase,\n input: AppendEventInput,\n): Promise<MigrationStateRow> {\n const result = await client.query(\n `INSERT INTO cipherstash.cs_migrations\n (table_name, column_name, event, phase, cursor_value, rows_processed, rows_total, details)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING id, table_name, column_name, event, phase, cursor_value, rows_processed, rows_total, details, created_at`,\n [\n input.tableName,\n input.columnName,\n input.event,\n input.phase,\n input.cursorValue ?? null,\n input.rowsProcessed ?? null,\n input.rowsTotal ?? null,\n input.details ?? null,\n ],\n )\n return rowToState(result.rows[0])\n}\n\n/**\n * Return the most recent event row for every column tracked in\n * `cs_migrations`, keyed by `\"<tableName>.<columnName>\"`. Used by\n * `stash encrypt status` and `plan` to render a table view.\n *\n * Columns with no recorded events are simply absent from the map — there\n * is no synthetic \"nothing happened yet\" entry.\n */\nexport async function latestByColumn(\n client: ClientBase,\n): Promise<Map<ColumnKey, MigrationStateRow>> {\n const result = await client.query(\n `SELECT DISTINCT ON (table_name, column_name)\n id, table_name, column_name, event, phase, cursor_value, rows_processed, rows_total, details, created_at\n FROM cipherstash.cs_migrations\n ORDER BY table_name, column_name, id DESC`,\n )\n const map = new Map<ColumnKey, MigrationStateRow>()\n for (const row of result.rows) {\n const state = rowToState(row)\n map.set(`${state.tableName}.${state.columnName}`, state)\n }\n return map\n}\n\n/**\n * Latest event row for a single column, or `null` if the column has no\n * recorded events. Used by the backfill runner to determine whether it is\n * resuming a checkpoint or starting fresh.\n */\nexport async function progress(\n client: ClientBase,\n tableName: string,\n columnName: string,\n): Promise<MigrationStateRow | null> {\n const result = await client.query(\n `SELECT id, table_name, column_name, event, phase, cursor_value, rows_processed, rows_total, details, created_at\n FROM cipherstash.cs_migrations\n WHERE table_name = $1 AND column_name = $2\n ORDER BY id DESC\n LIMIT 1`,\n [tableName, columnName],\n )\n if (result.rows.length === 0) return null\n return rowToState(result.rows[0])\n}\n\nfunction rowToState(row: {\n id: string | number\n table_name: string\n column_name: string\n event: MigrationEvent\n phase: MigrationPhase\n cursor_value: string | null\n rows_processed: string | number | null\n rows_total: string | number | null\n details: Record<string, unknown> | null\n created_at: Date\n}): MigrationStateRow {\n return {\n id: String(row.id),\n tableName: row.table_name,\n columnName: row.column_name,\n event: row.event,\n phase: row.phase,\n cursorValue: row.cursor_value,\n rowsProcessed:\n row.rows_processed === null ? null : Number(row.rows_processed),\n rowsTotal: row.rows_total === null ? null : Number(row.rows_total),\n details: row.details,\n createdAt: row.created_at,\n }\n}\n","import type { ClientBase } from 'pg'\n\n/**\n * Thin, typed wrappers around the EQL (Encrypt Query Language) functions\n * installed by `stash db install`. These mirror the canonical SQL API that\n * CipherStash Proxy also drives, so every action we take here stays\n * visible to Proxy using the same column-level config.\n *\n * Defined by the EQL project at\n * https://github.com/cipherstash/encrypt-query-language — see\n * `src/config/functions.sql` and `src/encryptindex/functions.sql` for the\n * source of truth.\n */\n\n/**\n * A column that has been registered in the `pending` EQL configuration but\n * is not yet part of the `active` config. Returned by\n * {@link selectPendingColumns}.\n */\nexport interface PendingColumn {\n tableName: string\n columnName: string\n}\n\n/**\n * Return columns present in the `pending` EQL config but absent (or\n * different) in the `active` one. Wraps `eql_v2.select_pending_columns()`.\n * Useful for showing \"what's about to change\" before calling\n * {@link readyForEncryption} + activating the pending config.\n */\nexport async function selectPendingColumns(\n client: ClientBase,\n): Promise<PendingColumn[]> {\n const result = await client.query<{\n table_name: string\n column_name: string\n }>('SELECT table_name, column_name FROM eql_v2.select_pending_columns()')\n return result.rows.map((row) => ({\n tableName: row.table_name,\n columnName: row.column_name,\n }))\n}\n\n/**\n * Check EQL's precondition for activating a pending configuration: every\n * pending column must have a matching `eql_v2_encrypted`-typed target\n * column in the schema. Returns `true` if activation is safe.\n * Wraps `eql_v2.ready_for_encryption()`.\n */\nexport async function readyForEncryption(client: ClientBase): Promise<boolean> {\n const result = await client.query<{ ready: boolean }>(\n 'SELECT eql_v2.ready_for_encryption() AS ready',\n )\n return result.rows[0]?.ready === true\n}\n\n/**\n * Atomically rename every `<col>` → `<col>_plaintext` and\n * `<col>_encrypted` → `<col>` across tables in the **pending** EQL config.\n * Wraps `eql_v2.rename_encrypted_columns()`.\n *\n * This is the **cut-over primitive**: after this returns, any SQL that\n * reads `<col>` transparently receives the encrypted column (decrypted on\n * read by Proxy or Protect). Call inside a transaction.\n *\n * **Requires a pending configuration.** The underlying EQL function calls\n * `select_pending_columns()` and raises `'No pending configuration exists\n * to encrypt'` if none is registered. Call `db push` to register a pending\n * config first; if no rename targets are present in the diff, the loop\n * inside `rename_encrypted_columns()` does nothing — but the function\n * still requires pending to exist.\n *\n * Renames physical columns only — does not advance the EQL state machine.\n * Pair with {@link migrateConfig} + {@link activateConfig} to finish the\n * pending → encrypting → active transition.\n */\nexport async function renameEncryptedColumns(\n client: ClientBase,\n): Promise<void> {\n await client.query('SELECT eql_v2.rename_encrypted_columns()')\n}\n\n/**\n * Advance the EQL state machine: `pending → encrypting`. Wraps\n * `eql_v2.migrate_config()`.\n *\n * Throws when:\n * - There is no pending configuration to migrate.\n * - There is already an encrypting configuration in flight.\n * - Some pending column lacks its encrypted target column (`<col>_encrypted`\n * doesn't exist in the schema with `eql_v2_encrypted` UDT).\n */\nexport async function migrateConfig(client: ClientBase): Promise<void> {\n await client.query('SELECT eql_v2.migrate_config()')\n}\n\n/**\n * Advance the EQL state machine: `encrypting → active`. Wraps\n * `eql_v2.activate_config()`. Marks any prior `active` row as `inactive`\n * in the same call.\n *\n * Throws when there is no encrypting configuration to activate. Always\n * call after {@link migrateConfig} has flipped the pending row to\n * encrypting (typically inside the same transaction as\n * {@link renameEncryptedColumns} for cutover; or alone for non-rename\n * activations like adding a new column to an existing config).\n */\nexport async function activateConfig(client: ClientBase): Promise<void> {\n await client.query('SELECT eql_v2.activate_config()')\n}\n\n/**\n * Discard the pending configuration without applying it. Wraps\n * `eql_v2.discard()`. Used by `db push` to clean up any stale pending\n * before writing a new one — the state machine only allows one pending\n * row at a time, and a stale pending blocks new pushes.\n *\n * No-op when no pending exists.\n */\nexport async function discardPendingConfig(client: ClientBase): Promise<void> {\n // The EQL `discard()` function raises when there's no pending row —\n // we want a no-op in that case, so DELETE directly. Safer than\n // wrapping eql_v2.discard() in a try/catch that swallows shape errors.\n await client.query(\n \"DELETE FROM public.eql_v2_configuration WHERE state = 'pending'\",\n )\n}\n\n/**\n * Nudge Proxy to re-read its config immediately instead of waiting for its\n * next 60-second refresh tick. Wraps `eql_v2.reload_config()`.\n *\n * **Must be executed through a CipherStash Proxy connection** — when\n * connected directly to Postgres, `reload_config()` is a no-op (by design,\n * per the EQL documentation). The CLI's `cutover` command accepts a\n * `--proxy-url` flag and will connect to that separately to issue this.\n */\nexport async function reloadConfig(client: ClientBase): Promise<void> {\n await client.query('SELECT eql_v2.reload_config()')\n}\n\n/**\n * Return EQL's count of rows in `<tableName>.<columnName>` whose encrypted\n * payload's config version matches the currently active config. Useful as\n * a cheap sanity check — 0 after a backfill generally means something's\n * wrong (wrong config active, or the backfill wrote with a stale version).\n *\n * Wraps `eql_v2.count_encrypted_with_active_config(table, column)`.\n *\n * Returns `bigint` because the underlying Postgres function returns\n * `BIGINT` and naively coercing to JS `number` loses precision past\n * `Number.MAX_SAFE_INTEGER` — exactly the row counts large-table users\n * are running this against. Callers that need a JS number can do their\n * own range check.\n */\nexport async function countEncryptedWithActiveConfig(\n client: ClientBase,\n tableName: string,\n columnName: string,\n): Promise<bigint> {\n const result = await client.query<{ count: string }>(\n 'SELECT eql_v2.count_encrypted_with_active_config($1, $2) AS count',\n [tableName, columnName],\n )\n return BigInt(result.rows[0]?.count ?? '0')\n}\n","/**\n * Quote a PostgreSQL identifier. Doubles any embedded `\"` and wraps in `\"`.\n * Use for table/column names that cannot be parameterised.\n */\nexport function quoteIdent(identifier: string): string {\n return `\"${identifier.replace(/\"/g, '\"\"')}\"`\n}\n","import type { ClientBase } from 'pg'\nimport { quoteIdent } from './sql.js'\n\n/**\n * Inputs to {@link fetchUnencryptedPage}.\n */\nexport interface KeysetPageOptions {\n /** Physical table name. Supports `schema.table`. */\n tableName: string\n /**\n * Primary-key column used for keyset pagination. Must be comparable with\n * `>`. Cast to text by the query so any PK type works.\n */\n pkColumn: string\n /** Column to read the plaintext from, e.g. `email`. */\n plaintextColumn: string\n /**\n * Target encrypted column, e.g. `email_encrypted`. Rows where this is\n * already non-null are skipped (idempotency guard).\n */\n encryptedColumn: string\n /**\n * Exclusive lower bound on `pkColumn`. Set to `null` to start from the\n * beginning; pass the `lastPk` of the previous page to continue.\n */\n after: string | null\n /** Maximum rows returned per call. */\n limit: number\n /**\n * When true, drop the `<encrypted_col> IS NULL` guard so rows that\n * already have ciphertext are re-emitted. Used by `stash encrypt\n * backfill --force` to recover from drift (e.g. dual-writes weren't\n * actually deployed when an earlier backfill ran). Off by default — the\n * guard makes normal re-runs idempotent.\n */\n force?: boolean\n}\n\n/**\n * One page of rows from {@link fetchUnencryptedPage}. `lastPk` is `null`\n * only when the page is empty — the caller's completion signal.\n */\nexport interface KeysetPage<Row = Record<string, unknown>> {\n rows: Row[]\n lastPk: string | null\n}\n\n/**\n * Fetch the next page of rows that still need encryption for a given column.\n *\n * Guards with `plaintext_col IS NOT NULL AND encrypted_col IS NULL` so a\n * concurrent backfill or a re-run never re-processes the same row, even if\n * the pagination cursor is lost. The ORDER BY + LIMIT form gives keyset\n * (seek) pagination rather than OFFSET, which keeps the scan bounded as\n * the cursor advances.\n */\nexport async function fetchUnencryptedPage(\n client: ClientBase,\n opts: KeysetPageOptions,\n): Promise<KeysetPage<{ pk: string; plaintext: unknown }>> {\n const pk = quoteIdent(opts.pkColumn)\n const plain = quoteIdent(opts.plaintextColumn)\n const enc = quoteIdent(opts.encryptedColumn)\n const table = qualifyTable(opts.tableName)\n\n const params: unknown[] = []\n let where = opts.force\n ? `${plain} IS NOT NULL`\n : `${plain} IS NOT NULL AND ${enc} IS NULL`\n if (opts.after !== null) {\n params.push(opts.after)\n where += ` AND ${pk} > $${params.length}`\n }\n params.push(opts.limit)\n const limitParam = `$${params.length}`\n\n const sql = `\n SELECT ${pk}::text AS pk, ${plain} AS plaintext\n FROM ${table}\n WHERE ${where}\n ORDER BY ${pk} ASC\n LIMIT ${limitParam}\n `\n const result = await client.query<{ pk: string; plaintext: unknown }>(\n sql,\n params,\n )\n const rows = result.rows\n const lastPk = rows.length > 0 ? rows[rows.length - 1]?.pk : null\n return { rows, lastPk }\n}\n\n/**\n * Count rows that still need encryption: `plaintext IS NOT NULL AND\n * encrypted IS NULL`. Called once at the start of a backfill to compute\n * `rowsTotal` for progress reporting; does not hold a snapshot, so new\n * rows inserted during the backfill are simply picked up on the next\n * chunk's SELECT.\n *\n * When `force` is true, drops the `encrypted IS NULL` guard — used by\n * `--force` to count every plaintext row, including ones that already\n * have a (potentially stale) ciphertext.\n */\nexport async function countUnencrypted(\n client: ClientBase,\n tableName: string,\n plaintextColumn: string,\n encryptedColumn: string,\n force = false,\n): Promise<number> {\n const plain = quoteIdent(plaintextColumn)\n const enc = quoteIdent(encryptedColumn)\n const table = qualifyTable(tableName)\n const where = force\n ? `${plain} IS NOT NULL`\n : `${plain} IS NOT NULL AND ${enc} IS NULL`\n const result = await client.query<{ count: string }>(\n `SELECT count(*)::text AS count FROM ${table} WHERE ${where}`,\n )\n return Number(result.rows[0]?.count ?? 0)\n}\n\n/**\n * Quote a possibly schema-qualified table name for use in a SQL statement.\n * `\"foo\"` → `\"foo\"`; `\"public.foo\"` → `\"public\".\"foo\"`. Use for identifiers\n * that cannot be parameterised.\n */\nexport function qualifyTable(tableName: string): string {\n if (tableName.includes('.')) {\n const parts = tableName.split('.')\n return parts.map(quoteIdent).join('.')\n }\n return quoteIdent(tableName)\n}\n","import { isEncryptedPayload } from '@cipherstash/stack'\nimport type { ClientBase, PoolClient } from 'pg'\nimport {\n countUnencrypted,\n fetchUnencryptedPage,\n qualifyTable,\n} from './cursor.js'\nimport { quoteIdent } from './sql.js'\nimport { type MigrationPhase, appendEvent, progress } from './state.js'\n\n// Loose structural types — keep this library decoupled from @cipherstash/stack\n// so @cipherstash/migrate can be built and tested without pulling the full\n// stack graph in. `EncryptionClientLike` matches the shape `EncryptionClient`\n// from `@cipherstash/stack/encryption` exposes via `bulkEncryptModels`.\n\n/**\n * Shape returned by {@link EncryptionClientLike.bulkEncryptModels} on success.\n * `data` is the array of models with the configured fields replaced by\n * ciphertext payloads, preserving `__pk` and any other non-encrypted fields.\n */\nexport interface BulkEncryptResultSuccess<T> {\n failure?: undefined\n data: T[]\n}\n\n/**\n * Shape returned by {@link EncryptionClientLike.bulkEncryptModels} on failure.\n * Matches `@byteslice/result`'s `{ failure: { message } }` convention used\n * by `@cipherstash/stack`. The backfill halts and writes an `error` event\n * to `cs_migrations` when this is returned.\n */\nexport interface BulkEncryptResultFailure {\n failure: { message: string; type?: string }\n data?: undefined\n}\n\n/**\n * Discriminated union returned by bulk-encrypt. Narrow with `if (r.failure)`\n * vs `if (r.data)`. No exceptions are thrown by the underlying operation.\n */\nexport type BulkEncryptResult<T> =\n | BulkEncryptResultSuccess<T>\n | BulkEncryptResultFailure\n\n/**\n * A thenable wrapper around {@link BulkEncryptResult}. `@cipherstash/stack`'s\n * `bulkEncryptModels` returns a fluent operation builder that resolves to a\n * `Result` when awaited; this alias accepts anything `PromiseLike` so we\n * don't bind to the full operation class in type-land.\n */\nexport type BulkEncryptThenable<T> = PromiseLike<BulkEncryptResult<T>>\n\n/**\n * Minimal surface of `@cipherstash/stack`'s `EncryptionClient` used by\n * {@link runBackfill}. Only `bulkEncryptModels` is required — the backfill\n * encrypts in batches, it does not need the single-value `encrypt` API.\n *\n * Supplying an object that duck-types to this interface is enough; you do\n * not have to import `EncryptionClient` itself.\n *\n * @example\n * ```ts\n * // Typical wiring: the user's `src/encryption/index.ts` exports an\n * // already-initialised client, and you pass it through.\n * import { encryptionClient } from './src/encryption/index.js'\n * await runBackfill({ encryptionClient, ... })\n * ```\n */\nexport interface EncryptionClientLike {\n /**\n * Bulk-encrypt a batch of plaintext models against a table schema.\n *\n * @param input - Array of models. Each row is `{ [schemaColumnKey]: plaintext, ... }`.\n * The backfill also includes a `__pk` field per row so it can correlate\n * the encrypted result back to the database row on UPDATE.\n * @param table - The `EncryptedTable` schema for the target table. Typed\n * as `any` here to keep this library decoupled from `@cipherstash/stack`.\n */\n bulkEncryptModels(\n input: Array<Record<string, unknown>>,\n // biome-ignore lint/suspicious/noExplicitAny: Stack's EncryptedTable is generic\n table: any,\n ): BulkEncryptThenable<Record<string, unknown>>\n}\n\n/**\n * Snapshot of backfill progress, passed to {@link BackfillOptions.onProgress}\n * after every successful chunk commit. Values represent the cumulative state\n * *after* the most recent chunk — including any rows processed by a prior\n * run that this invocation resumed from.\n */\nexport interface BackfillProgress {\n /** Total rows written to the encrypted column so far (includes a resumed prior run). */\n rowsProcessed: number\n /** Total rows we expect to process over the life of this migration (incl. resumed). */\n rowsTotal: number\n /** PK of the last row processed in the most recent chunk, cast to text. */\n lastPk: string | null\n /** Chunk size used for this run (echoed from {@link BackfillOptions.chunkSize}). */\n chunkSize: number\n /** Zero-based index of the chunk that just completed. */\n chunkIndex: number\n}\n\n/**\n * Options for {@link runBackfill}.\n *\n * Distinguishes three separate name spaces that a reader has to keep straight:\n * - **Physical names** ({@link tableName}, {@link plaintextColumn}, {@link encryptedColumn}, {@link pkColumn})\n * are Postgres identifiers used verbatim in SQL.\n * - **Schema name** ({@link schemaColumnKey}) is the key on the `EncryptedTable`\n * schema object that corresponds to the column. In the common drizzle\n * convention — where the schema declares the encrypted column (not the\n * plaintext one) — this equals `encryptedColumn`. Pass explicitly only\n * when your schema's object keys diverge from the physical column names.\n */\nexport interface BackfillOptions {\n /**\n * A pg pool client the runner owns for the duration of the call. The\n * runner issues `BEGIN`/`COMMIT` on this connection for each chunk, so it\n * must not be shared across concurrent work during the backfill.\n *\n * Acquire with `const db = await pool.connect()`, release with\n * `db.release()` in your `finally`.\n */\n db: PoolClient\n /**\n * Initialised encryption client. See {@link EncryptionClientLike} for the\n * required surface (just `bulkEncryptModels`).\n */\n encryptionClient: EncryptionClientLike\n /**\n * The `EncryptedTable` schema object for the target table, as exported\n * from the user's `src/encryption/index.ts`. Passed through to\n * `encryptionClient.bulkEncryptModels(models, tableSchema)`. Typed as\n * `any` to avoid coupling this library to `@cipherstash/stack`.\n */\n // biome-ignore lint/suspicious/noExplicitAny: Stack's EncryptedTable is generic\n tableSchema: any\n /**\n * Physical Postgres table name. Supports `\"schema.table\"` for non-default\n * schemas (identifiers are quoted automatically).\n */\n tableName: string\n /**\n * The key in {@link tableSchema} that corresponds to this column. With\n * the drizzle `extractEncryptionSchema` convention, where the schema is\n * derived from a table like `{ email_encrypted: encryptedType(...) }`,\n * this equals {@link encryptedColumn}. With a handwritten\n * `encryptedTable('users', { email: … })` schema where there's only one\n * column, this usually equals {@link plaintextColumn}.\n */\n schemaColumnKey: string\n /**\n * Physical column that holds the plaintext being encrypted, e.g. `email`.\n * The runner reads rows where this is `NOT NULL` and the target encrypted\n * column is `NULL`.\n */\n plaintextColumn: string\n /**\n * Physical column that receives the `eql_v2_encrypted` ciphertext JSON,\n * e.g. `email_encrypted`. Must already exist (typically created by\n * `drizzle-kit` / a prior migration) before backfill starts.\n */\n encryptedColumn: string\n /**\n * Physical single-column primary key used for keyset pagination — the\n * runner issues `WHERE pk > $after ORDER BY pk ASC LIMIT $n`. Must be\n * comparable with `>` (bigint, integer, text, uuid all work). Composite\n * primary keys are not yet supported.\n */\n pkColumn: string\n /**\n * Rows per chunk / transaction. Default 1000. Tune down if you see lock\n * contention, up for tables with small row payloads. A single chunk is\n * one `BEGIN`/`UPDATE`/`INSERT checkpoint`/`COMMIT` cycle, so the value\n * also bounds how much work is lost when you `Ctrl-C` mid-chunk.\n */\n chunkSize?: number\n /**\n * Optional abort signal. Checked *between* chunks — the in-flight chunk\n * always completes and checkpoints before the loop exits. Safe to wire\n * to `SIGINT` / `SIGTERM`; the CLI does exactly this.\n */\n signal?: AbortSignal\n /**\n * Invoked synchronously after each chunk has committed. Safe for logging\n * and UI updates; throwing from this callback will kill the backfill.\n */\n onProgress?: (progress: BackfillProgress) => void\n /**\n * Optional coercion applied to each row's plaintext value before it is\n * passed to {@link EncryptionClientLike.bulkEncryptModels}. Needed when\n * the pg driver's native JS type doesn't match the schema's declared\n * dataType — e.g. pg returns `numeric` as a string, but a schema\n * declaring `dataType('number')` expects a JS number. The CLI\n * builds an appropriate coercer from the schema's `cast_as`; library\n * callers can supply their own or leave undefined (identity).\n */\n transformPlaintext?: (value: unknown) => unknown\n /**\n * When true, encrypt every row whose plaintext is non-null — including\n * rows that already have a ciphertext in {@link encryptedColumn}.\n *\n * The default backfill skips already-encrypted rows for idempotency.\n * `force` is the recovery path for *drift*: rows where the ciphertext\n * is out of sync with the plaintext because the application was\n * updating the plaintext column without dual-writing the encrypted\n * twin. The drifted ciphertext is overwritten by re-encrypting the\n * current plaintext.\n *\n * Not destructive (overwriting a correctly-encrypted value with a\n * fresh encryption of the same plaintext is wasted work, not data\n * loss), but expensive: every row is re-encrypted even if it didn't\n * need to be. The CLI also appends `details: { force: true }` to the\n * `backfill_started` event so audit queries can spot the recovery\n * runs in `cs_migrations` history.\n */\n force?: boolean\n}\n\n/**\n * Return value from {@link runBackfill}.\n */\nexport interface BackfillResult {\n /**\n * `true` if the run began from a previously-recorded checkpoint rather\n * than starting fresh. Determined by the most recent event for this\n * column being a `backfill_checkpoint`.\n */\n resumed: boolean\n /**\n * Cumulative rows written to the encrypted column, including any from a\n * prior run this invocation resumed from.\n */\n rowsProcessed: number\n /**\n * Total rows the migration expects to process end-to-end (including\n * resumed). Computed as `priorProcessed + currentRunTotal` at start.\n */\n rowsTotal: number\n /**\n * `true` if this run drained all remaining rows. `false` means the run\n * was aborted (via {@link BackfillOptions.signal}) or is otherwise\n * paused and should be resumed by re-invoking with the same options.\n */\n completed: boolean\n}\n\n/**\n * Run a chunked, resumable, idempotent backfill of plaintext → encrypted.\n *\n * Per chunk, in a single transaction:\n * 1. `SELECT` the next page of rows where the encrypted column is `NULL`\n * and the PK is greater than the cursor.\n * 2. Encrypt the batch via {@link EncryptionClientLike.bulkEncryptModels}.\n * 3. `UPDATE … FROM (VALUES …)` to write the ciphertext back.\n * 4. `INSERT` a `backfill_checkpoint` event into `cipherstash.cs_migrations`.\n * 5. `COMMIT`.\n *\n * **Idempotency** — the `encrypted IS NULL` guard in both the SELECT and the\n * UPDATE's `WHERE` clause means re-runs never double-write a row, even if\n * the cursor is lost.\n *\n * **Resumability** — restarting with the same arguments will pick up from\n * the last committed checkpoint. Use {@link BackfillOptions.signal} to\n * abort cleanly on `SIGINT`.\n *\n * **Failure handling** — if any chunk fails (encrypt error or DB error),\n * the transaction is rolled back, an `error` event is appended to\n * `cs_migrations` for diagnostics, and the error is re-thrown.\n *\n * @example\n * ```ts\n * const db = await pool.connect()\n * try {\n * const result = await runBackfill({\n * db,\n * encryptionClient,\n * tableSchema: usersTable,\n * tableName: 'users',\n * schemaColumnKey: 'email',\n * plaintextColumn: 'email',\n * encryptedColumn: 'email_encrypted',\n * pkColumn: 'id',\n * chunkSize: 1000,\n * onProgress: (p) => console.log(`${p.rowsProcessed}/${p.rowsTotal}`),\n * })\n * console.log(result.completed ? 'done' : 'paused — re-run to resume')\n * } finally {\n * db.release()\n * }\n * ```\n */\nexport async function runBackfill(\n options: BackfillOptions,\n): Promise<BackfillResult> {\n const chunkSize = options.chunkSize ?? 1000\n const { db, tableName, pkColumn, plaintextColumn, encryptedColumn } = options\n\n const force = options.force ?? false\n const rowsTotal = await countUnencrypted(\n db,\n tableName,\n plaintextColumn,\n encryptedColumn,\n force,\n )\n\n const last = await progress(db, tableName, plaintextColumn)\n const resumeCursor =\n last?.event === 'backfill_checkpoint' ? last.cursorValue : null\n const resumed = resumeCursor !== null\n const priorProcessed =\n last?.event === 'backfill_checkpoint' ? (last.rowsProcessed ?? 0) : 0\n\n await appendEvent(db, {\n tableName,\n columnName: plaintextColumn,\n event: 'backfill_started',\n phase: 'backfilling',\n cursorValue: resumeCursor,\n rowsProcessed: priorProcessed,\n rowsTotal: priorProcessed + rowsTotal,\n details: { chunkSize, resumed, ...(force ? { force: true } : {}) },\n })\n\n let cursor = resumeCursor\n let rowsProcessed = priorProcessed\n const rowsTotalWithResumed = priorProcessed + rowsTotal\n let chunkIndex = 0\n let completed = false\n\n try {\n while (true) {\n if (options.signal?.aborted) break\n\n const page = await fetchUnencryptedPage(db, {\n tableName,\n pkColumn,\n plaintextColumn,\n encryptedColumn,\n after: cursor,\n limit: chunkSize,\n force,\n })\n\n if (page.rows.length === 0) {\n completed = true\n break\n }\n\n const coerce = options.transformPlaintext ?? ((v: unknown) => v)\n const models = page.rows.map((row) => ({\n __pk: row.pk,\n [options.schemaColumnKey]: coerce(row.plaintext),\n }))\n\n const encryptResult = await options.encryptionClient.bulkEncryptModels(\n models,\n options.tableSchema,\n )\n\n if (encryptResult.failure) {\n throw new Error(\n `bulkEncryptModels failed: ${encryptResult.failure.message}`,\n )\n }\n\n // Leak guard: every row's schemaColumnKey field must be a valid EQL\n // payload ({ v, i, c|sv, … }). If any row comes back as a primitive\n // or a mis-shaped object, the encryption client silently passed the\n // plaintext through — typically because the schema is keyed by a\n // different name than `schemaColumnKey`. Fail loudly before any\n // write commits; this is what prevents `(82.60)`-shaped composite\n // values from ending up in the encrypted column.\n for (const [i, row] of encryptResult.data.entries()) {\n const value = row[options.schemaColumnKey]\n if (!isEncryptedPayload(value)) {\n const pk = row.__pk ?? page.rows[i]?.pk\n // Only the *type* of the offender goes into the error — never the\n // value. The path that hits this branch is precisely the one\n // where the encryption client passed plaintext through, so the\n // value here IS sensitive plaintext. Bubbling it into the\n // exception text would leak via downstream loggers and via the\n // cs_migrations error event (`details.error`).\n const valueType = value === null ? 'null' : typeof value\n throw new Error(\n `Encryption client returned a non-ciphertext value (type: ${valueType}) at model key \"${options.schemaColumnKey}\" for pk=${pk}. This usually means the schema column key does not match your EncryptedTable. Verify that your schema declares a column keyed \"${options.schemaColumnKey}\", or pass --schema-column-key <name> to override.`,\n )\n }\n }\n\n await db.query('BEGIN')\n try {\n await writeEncryptedChunk(db, {\n tableName,\n pkColumn,\n encryptedColumn,\n schemaColumnKey: options.schemaColumnKey,\n encryptedRows: encryptResult.data,\n force,\n })\n rowsProcessed += page.rows.length\n cursor = page.lastPk\n await appendEvent(db, {\n tableName,\n columnName: plaintextColumn,\n event: 'backfill_checkpoint',\n phase: 'backfilling',\n cursorValue: cursor,\n rowsProcessed,\n rowsTotal: rowsTotalWithResumed,\n details: { chunkIndex, chunkRows: page.rows.length },\n })\n await db.query('COMMIT')\n } catch (err) {\n await db.query('ROLLBACK').catch(() => {})\n throw err\n }\n\n options.onProgress?.({\n rowsProcessed,\n rowsTotal: rowsTotalWithResumed,\n lastPk: cursor,\n chunkSize,\n chunkIndex,\n })\n chunkIndex += 1\n }\n\n if (completed) {\n await appendEvent(db, {\n tableName,\n columnName: plaintextColumn,\n event: 'backfilled',\n phase: 'backfilled',\n cursorValue: cursor,\n rowsProcessed,\n rowsTotal: rowsTotalWithResumed,\n details: { chunkCount: chunkIndex },\n })\n }\n } catch (err) {\n await appendEvent(db, {\n tableName,\n columnName: plaintextColumn,\n event: 'error',\n phase: 'backfilling',\n cursorValue: cursor,\n rowsProcessed,\n rowsTotal: rowsTotalWithResumed,\n details: {\n message: err instanceof Error ? err.message : String(err),\n chunkIndex,\n },\n })\n throw err\n }\n\n return {\n resumed,\n rowsProcessed,\n rowsTotal: rowsTotalWithResumed,\n completed,\n }\n}\n\ninterface WriteChunkOptions {\n tableName: string\n pkColumn: string\n encryptedColumn: string\n schemaColumnKey: string\n encryptedRows: Array<Record<string, unknown>>\n /**\n * When true, drop the `t.<enc> IS NULL` clause so already-encrypted\n * rows get overwritten. Mirrors the same flag in the SELECT side.\n */\n force?: boolean\n}\n\nasync function writeEncryptedChunk(\n db: ClientBase,\n opts: WriteChunkOptions,\n): Promise<void> {\n if (opts.encryptedRows.length === 0) return\n\n const table = qualifyTable(opts.tableName)\n const pk = quoteIdent(opts.pkColumn)\n const enc = quoteIdent(opts.encryptedColumn)\n\n const params: unknown[] = []\n const valuesSql = opts.encryptedRows\n .map((row) => {\n const pkValue = row.__pk\n const encryptedValue = row[opts.schemaColumnKey]\n params.push(pkValue)\n const pkParam = `$${params.length}`\n params.push(encryptedValue)\n const encParam = `$${params.length}::jsonb`\n return `(${pkParam}, ${encParam})`\n })\n .join(', ')\n\n const where = opts.force\n ? `t.${pk}::text = v.pk::text`\n : `t.${pk}::text = v.pk::text AND t.${enc} IS NULL`\n\n const sql = `\n UPDATE ${table} AS t\n SET ${enc} = v.enc\n FROM (VALUES ${valuesSql}) AS v(pk, enc)\n WHERE ${where}\n `\n\n await db.query(sql, params)\n}\n","import * as fs from 'node:fs/promises'\nimport * as path from 'node:path'\nimport { z } from 'zod'\n\n/**\n * The four EQL index kinds recognised by Proxy. Keep in sync with the\n * `indexes` CHECK constraint in `eql_v2_configuration`.\n */\nconst IndexKind = z.enum(['unique', 'match', 'ore', 'ste_vec'])\n\n/**\n * EQL cast types accepted by the configuration. Validated up front so a\n * typo (`timestampz`, `strnig`) fails at manifest read rather than blowing\n * up much later when the CLI tries to use it.\n */\nconst CastAs = z.enum([\n 'text',\n 'int',\n 'small_int',\n 'big_int',\n 'real',\n 'double',\n 'boolean',\n 'date',\n 'jsonb',\n 'json',\n 'float',\n 'decimal',\n 'timestamp',\n])\n\n/**\n * Intent for a single column within the manifest. Expresses *what the user\n * wants the state of this column to be*, not the observed reality — the\n * `status` / `plan` commands diff this against `cs_migrations` and EQL to\n * surface drift.\n */\nconst ManifestColumnSchema = z.object({\n /** Physical column name, e.g. `email`. */\n column: z.string(),\n /**\n * EQL cast type. Text by default. See the EQL docs for the full list\n * (`text | int | small_int | big_int | real | double | boolean | date |\n * jsonb | json | float | decimal | timestamp`).\n */\n castAs: CastAs.default('text'),\n /** Desired EQL index set. Driver of the `indexes: {…}` block in EQL config. */\n indexes: z.array(IndexKind).default([]),\n /**\n * The phase the user wants this column to reach. `cut-over` is the\n * typical end state (reads transparently decrypted); advance to\n * `dropped` only once you're confident the plaintext column is no\n * longer needed.\n */\n targetPhase: z\n .enum(['schema-added', 'dual-writing', 'backfilled', 'cut-over', 'dropped'])\n .default('cut-over'),\n /**\n * Override for primary-key detection during backfill. Omit to let the\n * CLI auto-detect via `information_schema`.\n */\n pkColumn: z.string().optional(),\n})\n\n/**\n * Root manifest shape. Stored at `.cipherstash/migrations.json`. Versioned\n * (currently `1`) so we can evolve it without breaking existing manifests.\n */\nconst ManifestSchema = z.object({\n version: z.literal(1).default(1),\n /** Map of table name → array of column intents for that table. */\n tables: z.record(z.array(ManifestColumnSchema)),\n})\n\nexport type Manifest = z.infer<typeof ManifestSchema>\nexport type ManifestColumn = z.infer<typeof ManifestColumnSchema>\n\n/** Canonical on-disk location for the manifest. */\nexport function manifestPath(cwd: string = process.cwd()): string {\n return path.join(cwd, '.cipherstash', 'migrations.json')\n}\n\n/**\n * Read and validate the manifest. Returns `null` when no manifest file\n * exists (this is not an error — most commands still work without one;\n * they just can't show intent-vs-observed drift).\n *\n * Throws on schema validation failures (zod errors).\n */\nexport async function readManifest(\n cwd: string = process.cwd(),\n): Promise<Manifest | null> {\n const filePath = manifestPath(cwd)\n let raw: string\n try {\n raw = await fs.readFile(filePath, 'utf-8')\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null\n throw err\n }\n const parsed = ManifestSchema.parse(JSON.parse(raw))\n return parsed\n}\n\n/**\n * Validate and write the manifest to `.cipherstash/migrations.json`,\n * creating the directory if it doesn't exist. Rewrites the file\n * atomically-enough for config purposes; not safe under concurrent writers.\n */\nexport async function writeManifest(\n manifest: Manifest,\n cwd: string = process.cwd(),\n): Promise<void> {\n const filePath = manifestPath(cwd)\n await fs.mkdir(path.dirname(filePath), { recursive: true })\n const validated = ManifestSchema.parse(manifest)\n await fs.writeFile(\n filePath,\n `${JSON.stringify(validated, null, 2)}\\n`,\n 'utf-8',\n )\n}\n\n/**\n * Read the manifest, upsert a single column entry under the named table,\n * and write it back. If no manifest exists, creates one. If the column\n * already exists for the table, the existing entry is replaced with the\n * supplied values — useful for keeping the intent in lockstep with what\n * the lifecycle commands actually committed.\n *\n * Called by `stash encrypt backfill` on first run for a column so the\n * intent leg of the three-source state model exists in the repo. The\n * agent (or the user) is free to hand-edit the file later — re-running\n * `backfill` won't clobber a column the user has annotated, only the\n * fields this function controls.\n */\nexport async function upsertManifestColumn(\n table: string,\n column: ManifestColumn,\n cwd: string = process.cwd(),\n): Promise<void> {\n const existing = (await readManifest(cwd)) ?? { version: 1, tables: {} }\n const tableColumns = existing.tables[table] ?? []\n const remaining = tableColumns.filter((c) => c.column !== column.column)\n existing.tables[table] = [...remaining, column]\n await writeManifest(existing, cwd)\n}\n\n/**\n * Update just the `targetPhase` of an existing column entry. No-op if\n * the column isn't tracked yet — used by `stash encrypt drop` to bump\n * the intent forward when the user commits to removing the plaintext\n * column entirely.\n */\nexport async function setManifestTargetPhase(\n table: string,\n columnName: string,\n targetPhase: ManifestColumn['targetPhase'],\n cwd: string = process.cwd(),\n): Promise<void> {\n const existing = await readManifest(cwd)\n if (!existing) return\n const tableColumns = existing.tables[table]\n if (!tableColumns) return\n const current = tableColumns.find((c) => c.column === columnName)\n if (!current) return\n existing.tables[table] = tableColumns.map((c) =>\n c.column === columnName ? { ...c, targetPhase } : c,\n )\n await writeManifest(existing, cwd)\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@cipherstash/migrate",
3
+ "version": "0.2.0",
4
+ "description": "Plaintext-to-encrypted column migration for CipherStash: resumable backfill, per-column state, and EQL lifecycle orchestration.",
5
+ "keywords": [
6
+ "cipherstash",
7
+ "encryption",
8
+ "migration",
9
+ "postgres",
10
+ "eql"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "CipherStash <hello@cipherstash.com>",
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE",
18
+ "CHANGELOG.md"
19
+ ],
20
+ "type": "module",
21
+ "main": "./dist/index.cjs",
22
+ "module": "./dist/index.js",
23
+ "sideEffects": false,
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "import": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ },
31
+ "require": {
32
+ "types": "./dist/index.d.cts",
33
+ "default": "./dist/index.cjs"
34
+ }
35
+ },
36
+ "./package.json": "./package.json"
37
+ },
38
+ "dependencies": {
39
+ "zod": "^3.24.2"
40
+ },
41
+ "peerDependencies": {
42
+ "@cipherstash/stack": ">=0.6.0",
43
+ "pg": ">=8"
44
+ },
45
+ "devDependencies": {
46
+ "@types/pg": "^8.11.11",
47
+ "dotenv": "^16.6.1",
48
+ "pg": "8.13.1",
49
+ "tsup": "8.4.0",
50
+ "typescript": "5.6.3",
51
+ "vitest": "3.1.3",
52
+ "@cipherstash/stack": "0.15.3"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ },
57
+ "engines": {
58
+ "node": ">=18"
59
+ },
60
+ "scripts": {
61
+ "build": "tsup",
62
+ "dev": "tsup --watch",
63
+ "test": "vitest run",
64
+ "lint": "biome check ."
65
+ }
66
+ }