super-productivity/packages/super-sync-server/tests/migrate-deploy-script.spec.ts
Johannes Millan 3b0cb9b836 fix(supersync): address multi-agent review of CONCURRENTLY recovery
- Tighten recovery guard to the idempotent drop-then-create shape (require
  both DROP and CREATE INDEX CONCURRENTLY). A bare CREATE (20260511000000) is
  now refused by gate, deterministically — matching its documented fail-loud
  intent instead of relying on the CREATE erroring by accident. Reconciles
  README with the committed migration + migration-sql.spec.ts.
- Escape single quotes in print_manual_recovery so the printed escape-hatch
  commands are copy-pasteable for SQL containing quotes (e.g. the full-state
  WHERE op_type IN ('SYNC_IMPORT', ...) migration).
- Fix MIGRATE_LOG temp-file leak across retry attempts.
- Add an in-script per-step timeout (with_timeout) so the Dockerfile CMD /
  helm initContainer paths (no outer timeout) can't hang forever on a blocked
  concurrent build; 124 fails loudly.
- Harden parse_failing_migration: sentence-anchored P3009 parse + reject
  names outside the migration charset (path-traversal defence).
- Gate also accepts SQLSTATE 25001 (stable Postgres contract) not just the
  localizable English message.
- MAX_ATTEMPTS -> documented tight bound (6); fail_loudly wording fixed
  (was contradictory for the non-CONCURRENTLY case).
- Tests: bare-CREATE refusal, P3009 decoy-token parser hardening; trim
  migration-sql.spec.ts to the architectural-invariant subset (no hardcoded
  names) per 'test behavior not implementation'.

Full server suite: 36 files, 727 passed / 5 skipped.
2026-05-15 22:03:25 +02:00

290 lines
9.9 KiB
TypeScript

import { execFileSync } from 'node:child_process';
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
// Drives scripts/migrate-deploy.sh end-to-end with a fake `npx prisma` so the
// generic CONCURRENTLY recovery logic is exercised without a real database.
const currentDir = dirname(fileURLToPath(import.meta.url));
const SCRIPT = join(currentDir, '../scripts/migrate-deploy.sh');
const ENCRYPTED_OPS = '20260514000000_add_encrypted_ops_partial_index';
const ENCRYPTED_OPS_SQL = `DROP INDEX CONCURRENTLY IF EXISTS "operations_user_id_server_seq_encrypted_idx";
CREATE INDEX CONCURRENTLY "operations_user_id_server_seq_encrypted_idx"
ON "operations"("user_id", "server_seq")
WHERE "is_payload_encrypted" = true;`;
const PLAIN_MIGRATION = '20260601000000_add_plain_column';
const PLAIN_SQL = `ALTER TABLE "operations" ADD COLUMN "foo" TEXT;`;
interface RunResult {
status: number;
stdout: string;
executedSql: string;
resolveApplied: string[];
resolveRolledBack: string[];
}
let projectDir: string;
let stateDir: string;
let binDir: string;
const writeMigration = (name: string, sql: string): void => {
const dir = join(projectDir, 'prisma', 'migrations', name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'migration.sql'), sql);
};
// Fake `npx`: first arg is `prisma`. Behavior is driven by FAKE_* env vars and
// marker files under $FAKE_STATE so the deploy→recover→retry cycle is modelled.
const FAKE_NPX = `#!/bin/sh
set -eu
shift # drop "prisma"
sub="$1"; shift
state="$FAKE_STATE"
case "$sub" in
migrate)
action="$1"; shift
case "$action" in
deploy)
for m in $FAKE_FAIL; do
if [ ! -f "$state/applied_$m" ]; then
case "$FAKE_CODE" in
P3018)
echo "Applying migration \\\`$m\\\`"
echo "Error: P3018"
echo "A migration failed to apply. New migrations cannot be applied before the error is recovered from."
echo "Migration name: $m"
echo "Database error code: 25001"
echo "Database error:"
echo "ERROR: DROP INDEX CONCURRENTLY cannot run inside a transaction block"
;;
P3009)
echo "Error: P3009"
echo "migrate found failed migrations in the target database, new migrations will not be applied."
echo "The \\\`$m\\\` migration started at 2026-05-15 failed"
[ -n "\${FAKE_DECOY:-}" ] && echo "Applying migration \\\`\$FAKE_DECOY\\\`"
;;
*)
echo "Error: P1001"
echo "Can't reach database server"
;;
esac
exit 1
fi
done
echo "No pending migrations to apply."
exit 0
;;
resolve)
mode="$1"; name="$2"
if [ "$mode" = "--rolled-back" ]; then
echo "$name" >> "$state/rolledback"
exit "\${FAKE_ROLLEDBACK_EXIT:-0}"
fi
echo "$name" >> "$state/applied_list"
[ "\${FAKE_MARK:-1}" = "1" ] && : > "$state/applied_$name"
exit 0
;;
esac
;;
db)
# db execute --schema X --stdin (SQL on stdin)
cat >> "$state/executed_sql"
echo "" >> "$state/executed_sql"
echo "---STMT---" >> "$state/executed_sql"
exit "\${FAKE_EXEC_EXIT:-0}"
;;
esac
echo "fake npx: unhandled args" >&2
exit 99
`;
const run = (env: Record<string, string>): RunResult => {
let status = 0;
let stdout = '';
try {
stdout = execFileSync('sh', [SCRIPT], {
cwd: projectDir,
encoding: 'utf8',
env: {
PATH: `${binDir}:${process.env.PATH ?? ''}`,
TMPDIR: stateDir,
FAKE_STATE: stateDir,
...env,
},
});
} catch (e) {
const err = e as { status?: number; stdout?: string; stderr?: string };
status = err.status ?? 1;
stdout = `${err.stdout ?? ''}${err.stderr ?? ''}`;
}
const read = (f: string): string => {
try {
return readFileSync(join(stateDir, f), 'utf8');
} catch {
return '';
}
};
return {
status,
stdout,
executedSql: read('executed_sql'),
resolveApplied: read('applied_list').split('\n').filter(Boolean),
resolveRolledBack: read('rolledback').split('\n').filter(Boolean),
};
};
beforeEach(() => {
projectDir = mkdtempSync(join(tmpdir(), 'sup-migproj-'));
stateDir = mkdtempSync(join(tmpdir(), 'sup-migstate-'));
binDir = mkdtempSync(join(tmpdir(), 'sup-migbin-'));
mkdirSync(join(projectDir, 'prisma'), { recursive: true });
writeFileSync(join(projectDir, 'prisma', 'schema.prisma'), '// dummy');
const npxPath = join(binDir, 'npx');
writeFileSync(npxPath, FAKE_NPX);
chmodSync(npxPath, 0o755);
});
afterEach(() => {
for (const d of [projectDir, stateDir, binDir]) {
rmSync(d, { recursive: true, force: true });
}
});
describe('migrate-deploy.sh generic CONCURRENTLY recovery', () => {
it('clean deploy with no failures exits 0 and runs no recovery', () => {
const r = run({ FAKE_FAIL: '', FAKE_CODE: 'P3018' });
expect(r.status).toBe(0);
expect(r.resolveApplied).toEqual([]);
expect(r.executedSql).toBe('');
});
it('recovers a P3018 transaction-block failure out-of-band, then succeeds', () => {
writeMigration(ENCRYPTED_OPS, ENCRYPTED_OPS_SQL);
const r = run({ FAKE_FAIL: ENCRYPTED_OPS, FAKE_CODE: 'P3018' });
expect(r.status).toBe(0);
expect(r.resolveRolledBack).toContain(ENCRYPTED_OPS);
expect(r.resolveApplied).toContain(ENCRYPTED_OPS);
// Both statements ran out-of-band; multi-line CREATE collapsed to one line.
expect(r.executedSql).toContain(
'DROP INDEX CONCURRENTLY IF EXISTS "operations_user_id_server_seq_encrypted_idx";',
);
expect(r.executedSql).toContain(
'CREATE INDEX CONCURRENTLY "operations_user_id_server_seq_encrypted_idx" ON "operations"("user_id", "server_seq") WHERE "is_payload_encrypted" = true;',
);
const statements = r.executedSql
.split('---STMT---')
.map((s) => s.trim())
.filter(Boolean);
expect(statements).toHaveLength(2);
});
it('recovers a stuck P3009 failed migration', () => {
writeMigration(ENCRYPTED_OPS, ENCRYPTED_OPS_SQL);
const r = run({ FAKE_FAIL: ENCRYPTED_OPS, FAKE_CODE: 'P3009' });
expect(r.status).toBe(0);
expect(r.resolveApplied).toContain(ENCRYPTED_OPS);
});
it('refuses to auto-resolve a non-CONCURRENTLY migration', () => {
writeMigration(PLAIN_MIGRATION, PLAIN_SQL);
const r = run({ FAKE_FAIL: PLAIN_MIGRATION, FAKE_CODE: 'P3018' });
expect(r.status).not.toBe(0);
expect(r.stdout).toContain('refusing to auto-resolve');
expect(r.resolveApplied).toEqual([]);
});
it('refuses a bare CREATE INDEX CONCURRENTLY (intentionally fail-loud, no DROP)', () => {
const bare = '20260701000000_add_bare_concurrent_index';
// Same shape as the committed 20260511000000: deliberately no DROP, so a
// half-built INVALID index fails loudly instead of being marked applied.
const bareSql = `CREATE INDEX CONCURRENTLY "operations_bare_idx"
ON "operations"("user_id", "server_seq");`;
writeMigration(bare, bareSql);
const r = run({ FAKE_FAIL: bare, FAKE_CODE: 'P3018' });
expect(r.status).not.toBe(0);
expect(r.stdout).toContain('not a recoverable drop-then-create');
expect(r.resolveApplied).toEqual([]);
});
it('does NOT mark applied when an out-of-band statement fails', () => {
writeMigration(ENCRYPTED_OPS, ENCRYPTED_OPS_SQL);
const r = run({
FAKE_FAIL: ENCRYPTED_OPS,
FAKE_CODE: 'P3018',
FAKE_EXEC_EXIT: '1',
});
expect(r.status).not.toBe(0);
expect(r.stdout).toContain('was NOT marked applied');
expect(r.resolveApplied).toEqual([]);
});
it('aborts instead of looping when recovery does not clear the failure', () => {
writeMigration(ENCRYPTED_OPS, ENCRYPTED_OPS_SQL);
const r = run({
FAKE_FAIL: ENCRYPTED_OPS,
FAKE_CODE: 'P3018',
FAKE_MARK: '0', // resolve --applied never clears the marker
});
expect(r.status).not.toBe(0);
expect(r.stdout).toContain('failed again after out-of-band recovery');
});
it('passes through a genuine non-gate failure without auto-resolving', () => {
writeMigration(ENCRYPTED_OPS, ENCRYPTED_OPS_SQL);
const r = run({ FAKE_FAIL: ENCRYPTED_OPS, FAKE_CODE: 'OTHER' });
expect(r.status).not.toBe(0);
expect(r.stdout).toContain('Not auto-recovered');
expect(r.resolveApplied).toEqual([]);
});
it('targets the failed migration in a P3009 log that also backticks others', () => {
// A decoy backticked migration name appears AFTER the failed-migration
// sentence; the sentence-anchored parse must still pick ENCRYPTED_OPS,
// not the last backticked token.
writeMigration(ENCRYPTED_OPS, ENCRYPTED_OPS_SQL);
const r = run({
FAKE_FAIL: ENCRYPTED_OPS,
FAKE_CODE: 'P3009',
FAKE_DECOY: '20991231000000_decoy_later_token',
});
expect(r.status).toBe(0);
expect(r.resolveApplied).toContain(ENCRYPTED_OPS);
});
it('recovers several sequential CONCURRENTLY migrations in one deploy', () => {
const second = '20260514000002_add_payload_bytes_unbackfilled_index';
const secondSql = `DROP INDEX CONCURRENTLY IF EXISTS "operations_payload_bytes_unbackfilled_idx";
CREATE INDEX CONCURRENTLY "operations_payload_bytes_unbackfilled_idx"
ON "operations"("user_id", "id")
WHERE "payload_bytes" = 0;`;
writeMigration(ENCRYPTED_OPS, ENCRYPTED_OPS_SQL);
writeMigration(second, secondSql);
const r = run({
FAKE_FAIL: `${ENCRYPTED_OPS} ${second}`,
FAKE_CODE: 'P3018',
});
expect(r.status).toBe(0);
expect(r.resolveApplied).toEqual(expect.arrayContaining([ENCRYPTED_OPS, second]));
});
});