diff --git a/docs/TENANT-REHOME.md b/docs/TENANT-REHOME.md index f12cc1e..d428eaa 100644 --- a/docs/TENANT-REHOME.md +++ b/docs/TENANT-REHOME.md @@ -2,13 +2,15 @@ `scripts/rehome-tenant.mjs` переносит все строки из одного tenant ID в другой в схеме `public`. Скрипт рассчитан на остановленное приложение и запускается только привилегированной ролью БД, способной обходить RLS и изменять таблицы. Строки пароля или подключения в отчёты не выводятся. -Сначала на восстановленной копии базы выполните dry-run: +CLI по умолчанию выполняет только read-only dry-run; `--dry-run` можно указать явно. Совместимые имена `--from`/`--to` и `--json` принимаются для rehearsal-обвязок; вывод всегда JSON. Сначала на восстановленной копии базы выполните dry-run: ```powershell -$env:DATABASE_REHOME_URL = 'postgresql://…' -node scripts/rehome-tenant.mjs --dry-run --source --target +$env:DATABASE_ADMIN_URL = 'postgresql://…' +node scripts/rehome-tenant.mjs --from --to --dry-run --json ``` +Для совместимости со старыми локальными инструкциями также принимаются `DATABASE_REHOME_URL` и `DATABASE_MIGRATION_URL` как fallback. `--apply` по-прежнему требует явный флаг, actor и проверенный `--backup-evidence`; aliases и default dry-run не разрешают запись сами по себе. + Dry-run открывает транзакцию `READ ONLY` и выводит только идентификаторы переноса и количество строк по таблицам. Он не блокирует записи; поэтому результат является снимком для планирования, а не гарантией актуальности на момент apply. На копии и в согласованное окно с остановленным приложением повторите dry-run непосредственно перед переносом. ## Обязательное подтверждение восстановления diff --git a/scripts/rehome-tenant.d.mts b/scripts/rehome-tenant.d.mts index ad88a0d..c073ef8 100644 --- a/scripts/rehome-tenant.d.mts +++ b/scripts/rehome-tenant.d.mts @@ -35,7 +35,16 @@ export interface RehomeReport { payrollLedger?: Array<{ currency: string; amountMinor: string }>; } -export function parseTenantRehomeArgs(args: string[]): Record; +export interface ParsedTenantRehomeArgs { + mode: 'dry-run' | 'apply'; + source: string; + target: string; + actor?: string; + backup_evidence?: string; + json?: true; +} + +export function parseTenantRehomeArgs(args: string[]): ParsedTenantRehomeArgs; export function validateBackupEvidenceObject( evidence: unknown, ): BackupEvidenceReport; diff --git a/scripts/rehome-tenant.mjs b/scripts/rehome-tenant.mjs index 0d63f58..cb11161 100644 --- a/scripts/rehome-tenant.mjs +++ b/scripts/rehome-tenant.mjs @@ -47,11 +47,21 @@ export function parseTenantRehomeArgs(args) { if (key === '--dry-run' || key === '--apply') { if (result.mode) throw new SafeOperationError('Choose exactly one of --dry-run or --apply.'); result.mode = key.slice(2); - } else if (['--source', '--target', '--actor', '--backup-evidence'].includes(key)) { + } else if (key === '--json') { + if (result.json) throw new SafeOperationError('Duplicate option --json.'); + result.json = true; + } else if (['--source', '--from', '--target', '--to', '--actor', '--backup-evidence'].includes(key)) { const value = args[index + 1]; if (!value || value.startsWith('--')) throw new SafeOperationError(`Missing value for ${key}.`); - const name = key.slice(2).replaceAll('-', '_'); - if (result[name]) throw new SafeOperationError(`Duplicate option ${key}.`); + const name = ({ + '--source': 'source', + '--from': 'source', + '--target': 'target', + '--to': 'target', + '--actor': 'actor', + '--backup-evidence': 'backup_evidence', + })[key]; + if (result[name]) throw new SafeOperationError(`Duplicate option for ${name.replaceAll('_', '-')}.`); result[name] = value; index += 1; } else { @@ -59,7 +69,7 @@ export function parseTenantRehomeArgs(args) { } } - if (!result.mode) throw new SafeOperationError('Specify --dry-run or --apply.'); + result.mode ??= 'dry-run'; for (const name of ['source', 'target']) { if (!ULID_RE.test(result[name] ?? '')) throw new SafeOperationError(`--${name} must be a valid ULID.`); } @@ -362,8 +372,8 @@ export async function rehomeTenant(client, options) { function parseCli(args) { const options = parseTenantRehomeArgs(args); - const connectionString = process.env.DATABASE_REHOME_URL ?? process.env.DATABASE_MIGRATION_URL; - if (!connectionString) throw new SafeOperationError('Set DATABASE_REHOME_URL or DATABASE_MIGRATION_URL.'); + const connectionString = process.env.DATABASE_ADMIN_URL ?? process.env.DATABASE_REHOME_URL ?? process.env.DATABASE_MIGRATION_URL; + if (!connectionString) throw new SafeOperationError('Set DATABASE_ADMIN_URL, DATABASE_REHOME_URL, or DATABASE_MIGRATION_URL.'); return { ...options, connectionString }; } diff --git a/tests/unit/rehome-tenant.test.ts b/tests/unit/rehome-tenant.test.ts index 292f39a..369bbd1 100644 --- a/tests/unit/rehome-tenant.test.ts +++ b/tests/unit/rehome-tenant.test.ts @@ -10,10 +10,18 @@ const actor = '01J00000000000000000000003'; const sha256 = 'a'.repeat(64); describe('tenant rehome argument and evidence validation', () => { - it('requires explicit apply mode and a backup evidence path', () => { - expect(() => parseTenantRehomeArgs(['--source', source, '--target', target])).toThrow('--dry-run or --apply'); - expect(() => parseTenantRehomeArgs(['--apply', '--source', source, '--target', target, '--actor', actor])) + it('defaults to read-only dry-run and accepts the published CLI aliases', () => { + expect(parseTenantRehomeArgs(['--from', source, '--to', target, '--json'])).toEqual({ + mode: 'dry-run', source, target, json: true, + }); + expect(parseTenantRehomeArgs(['--dry-run', '--source', source, '--target', target]).mode).toBe('dry-run'); + }); + + it('requires backup evidence for every apply invocation', () => { + expect(() => parseTenantRehomeArgs(['--apply', '--from', source, '--to', target, '--actor', actor])) .toThrow('--backup-evidence is required'); + expect(() => parseTenantRehomeArgs(['--from', source, '--source', source, '--to', target])) + .toThrow('Duplicate option for source.'); }); it('accepts only evidence with encrypted off-host backup and a matching successful restore', () => {