/** * @bytelyst/field-encrypt — Migration helpers * * Utilities for encrypting existing plaintext fields in-place. * Idempotent — skips already-encrypted fields via __encrypted sentinel. */ import type { EncryptedField } from './types.js'; import { isEncryptedField } from './guards.js'; /** Result of a migration run. */ export interface MigrationResult { /** Total documents scanned. */ scanned: number; /** Documents encrypted in this run. */ encrypted: number; /** Documents skipped (already encrypted). */ skipped: number; /** Documents that failed to encrypt. */ errors: number; /** Error details (first 10). */ errorDetails: Array<{ id: string; error: string }>; } /** Options for migrateDocuments(). */ export interface MigrateDocumentsOptions { /** Fetch a batch of documents. Return empty array when done. */ fetchBatch: (offset: number, batchSize: number) => Promise; /** Get the document ID for logging. */ getId: (doc: T) => string; /** Get the field value to check/encrypt. */ getField: (doc: T) => unknown; /** Encrypt the plaintext value. Returns the EncryptedField. */ encryptValue: (plaintext: string, doc: T) => Promise; /** Write the encrypted value back to the store. */ writeBack: (doc: T, encrypted: EncryptedField) => Promise; /** Batch size (default: 100). */ batchSize?: number; /** If true, don't write — just count. */ dryRun?: boolean; /** Progress callback. */ onProgress?: (result: MigrationResult) => void; } /** * Migrate plaintext fields to encrypted fields in batches. * * Idempotent: skips documents where the field is already an EncryptedField. */ export async function migrateDocuments( options: MigrateDocumentsOptions ): Promise { const batchSize = options.batchSize ?? 100; const result: MigrationResult = { scanned: 0, encrypted: 0, skipped: 0, errors: 0, errorDetails: [], }; let offset = 0; let batch: T[]; do { batch = await options.fetchBatch(offset, batchSize); for (const doc of batch) { result.scanned++; const fieldValue = options.getField(doc); // Skip already-encrypted if (isEncryptedField(fieldValue)) { result.skipped++; continue; } // Skip null/undefined if (fieldValue == null) { result.skipped++; continue; } const plaintext = typeof fieldValue === 'string' ? fieldValue : JSON.stringify(fieldValue); try { const encrypted = await options.encryptValue(plaintext, doc); if (!options.dryRun) { await options.writeBack(doc, encrypted); } result.encrypted++; } catch (err) { result.errors++; if (result.errorDetails.length < 10) { result.errorDetails.push({ id: options.getId(doc), error: err instanceof Error ? err.message : String(err), }); } } } offset += batch.length; options.onProgress?.(result); } while (batch.length === batchSize); return result; }