learning_ai_common_plat/packages/field-encrypt/src/migration.ts
saravanakumardb1 bb3f5385fc feat(field-encrypt): create @bytelyst/field-encrypt package with AES-256-GCM envelope encryption
- 10 source files: types, aes-gcm, 3 key providers (memory/env/akv), envelope, key-cache, dek-store, guards, migration, factory
- 42 Vitest tests: AES-GCM roundtrips, tamper detection, unicode, 100KB payloads, key providers, DEK cache TTL/LRU, envelope lifecycle, migration (dry-run + idempotent), config validation
- AKV MEK creation script (scripts/create-encryption-keys.sh) for 10 product MEKs
- .env.example updated with FIELD_ENCRYPT_* vars
2026-03-21 09:18:10 -07:00

111 lines
3.0 KiB
TypeScript

/**
* @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<T> {
/** Fetch a batch of documents. Return empty array when done. */
fetchBatch: (offset: number, batchSize: number) => Promise<T[]>;
/** 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<EncryptedField>;
/** Write the encrypted value back to the store. */
writeBack: (doc: T, encrypted: EncryptedField) => Promise<void>;
/** 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<T>(
options: MigrateDocumentsOptions<T>
): Promise<MigrationResult> {
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;
}