vibecarbon 0.4.0 → 0.5.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.
@@ -11,16 +11,15 @@
11
11
 
12
12
  import { spawn } from 'node:child_process';
13
13
  import { randomBytes } from 'node:crypto';
14
- import { existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'node:fs';
14
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
15
15
  import { tmpdir } from 'node:os';
16
16
  import { dirname, join } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import * as p from '@clack/prompts';
19
- import { uploadS3Backup } from '../../backup-s3.js';
20
19
  import { runCommand, runCommandAsync } from '../../command.js';
21
20
  import { perfAsync, perfTimer } from '../../perf.js';
22
21
  import { parseDotenv, shEscape } from '../../shell.js';
23
- import { NO_PIN_SSH_OPTS, scpDownload } from '../../ssh.js';
22
+ import { NO_PIN_SSH_OPTS, sshRunScript } from '../../ssh.js';
24
23
  import { postAdminUser, waitForGotrueHealth } from '../admin-user.js';
25
24
 
26
25
  const __filename = fileURLToPath(import.meta.url);
@@ -825,216 +824,283 @@ export function destroyCompose(ip, sshKeyPath, projectName) {
825
824
  }
826
825
 
827
826
  /**
828
- * Create a full backup via Docker Compose.
829
- * Produces a .tar.gz archive containing:
830
- * - postgres.sql.gz (main Supabase database, always)
831
- * - n8n.sql.gz (if n8n database exists)
832
- * - metabase.sql.gz (if metabase database exists)
833
- * - storage.tar.gz (if file-backend storage files exist)
827
+ * Build the shell command that runs the wal-g base backup on a Compose VPS.
828
+ *
829
+ * `carbon/backup/compose-backup.sh` is the SINGLE source of truth for the
830
+ * backup logic (guard + PGUSER=supabase_admin + wal-g backup-push + delete
831
+ * retain, plus a flock and set -e). This builder is a thin, quote-safe
832
+ * wrapper so the on-demand path and the cron path run byte-identical commands.
833
+ *
834
+ * The `RETAIN=<n>` prefix binds to `bash` (a real command, so the assignment
835
+ * is exported into the script's environment) — NOT to `cd` (a builtin, where
836
+ * it would be discarded). The script reads it via `${RETAIN:-7}`.
837
+ *
838
+ * Invoking via `bash backup/compose-backup.sh` (relative to remoteDir, which
839
+ * we cd into) means the script's exec bit is irrelevant — same lesson as
840
+ * wal-archive.sh. The bundle ships it to `${remoteDir}/backup/`.
841
+ *
842
+ * @param {string} remoteDir Absolute path to the project directory on the VPS
843
+ * (e.g. /opt/myapp)
844
+ * @param {number} [retain=7] Number of full base backups to keep. Validated
845
+ * to a positive integer (injection-safe + sane).
846
+ * @returns {string}
834
847
  */
835
- export function backupCompose(ip, sshKeyPath, projectName, options = {}) {
836
- const { clean = false } = options;
837
- const remoteDir = `/opt/${projectName}`;
838
- const timestamp = new Date().toISOString().replace(/T/, '_').replace(/[:.]/g, '').slice(0, 15);
839
- const archiveName = `${projectName}_${timestamp}_full.tar.gz`;
840
- const cleanFlags = clean ? '--clean --if-exists' : '';
841
-
842
- // Step 1: Dump postgres database
843
- const t1 = perfTimer('backup.pgDump.main');
844
- sshRun(
845
- ip,
846
- sshKeyPath,
847
- `cd ${remoteDir} && docker compose exec -T db pg_dump -U postgres ${cleanFlags} postgres | gzip > backups/_postgres.sql.gz`,
848
- { timeout: 300_000 },
849
- );
850
- t1.end();
851
-
852
- // Step 2: Dump optional service databases (n8n, metabase) if they exist
853
- const t2 = perfTimer('backup.pgDump.features');
854
- sshRun(
855
- ip,
856
- sshKeyPath,
857
- `cd ${remoteDir} && for db in n8n metabase; do docker compose exec -T db psql -U postgres -tAc "SELECT 1 FROM pg_database WHERE datname='\${db}'" 2>/dev/null | grep -q 1 && docker compose exec -T db pg_dump -U postgres "\${db}" | gzip > "backups/_\${db}.sql.gz" || true; done`,
858
- { timeout: 300_000 },
859
- );
860
- t2.end();
861
-
862
- // Step 3: Backup storage files if using file backend
863
- const t3 = perfTimer('backup.storageFiles');
864
- sshRun(
865
- ip,
866
- sshKeyPath,
867
- `cd ${remoteDir} && FCOUNT=$(docker compose exec -T storage sh -c 'find /var/lib/storage -type f 2>/dev/null | wc -l' 2>/dev/null || echo 0) && [ "$FCOUNT" -gt 0 ] 2>/dev/null && docker compose exec -T storage tar czf - -C / var/lib/storage > backups/_storage.tar.gz || true`,
868
- { timeout: 300_000 },
869
- );
870
- t3.end();
871
-
872
- // Step 4: Create combined archive from work files (prefixed with _)
873
- const t4 = perfTimer('backup.combineArchive');
874
- sshRun(
875
- ip,
876
- sshKeyPath,
877
- `cd ${remoteDir}/backups && tar czf ${archiveName} $(ls _postgres.sql.gz _n8n.sql.gz _metabase.sql.gz _storage.tar.gz 2>/dev/null) && rm -f _postgres.sql.gz _n8n.sql.gz _metabase.sql.gz _storage.tar.gz`,
878
- { timeout: 120_000 },
879
- );
880
- t4.end();
881
-
882
- return archiveName;
848
+ export function composeBackupCmd(remoteDir, retain = 7) {
849
+ const r = Number.isInteger(retain) && retain > 0 ? retain : 7;
850
+ return `cd ${remoteDir} && RETAIN=${r} bash backup/compose-backup.sh`;
883
851
  }
884
852
 
885
853
  /**
886
- * Upload a compose backup to S3.
854
+ * Trigger a wal-g base backup on a Compose VPS via compose-backup.sh.
855
+ *
856
+ * wal-g pushes the backup directly to S3 from inside the db container —
857
+ * there is no local archive to scp or upload. Success = exit 0.
887
858
  *
888
- * Downloads the archive from the VPS to a local temp file via SCP, then uploads
889
- * to S3 using the Node.js SDK. This replaces the previous approach of installing
890
- * awscli on the VPS (which failed silently on many server images).
859
+ * @param {string} ip
860
+ * @param {string} sshKeyPath
861
+ * @param {string} projectName
862
+ * @param {object} [options]
863
+ * @param {number} [options.retain] Full base backups to keep (default 7).
891
864
  */
892
- export async function uploadComposeBackupToS3(
893
- ip,
894
- sshKeyPath,
895
- projectName,
896
- archiveName,
897
- backupS3Config,
898
- ) {
899
- const localTmpPath = join(tmpdir(), `vc-backup-${archiveName}`);
900
- try {
901
- await perfAsync('backup.scpFromServer', async () =>
902
- scpDownload(ip, sshKeyPath, `/opt/${projectName}/backups/${archiveName}`, localTmpPath),
903
- );
904
- await perfAsync('backup.uploadS3', async () =>
905
- uploadS3Backup(backupS3Config, localTmpPath, `backups/${archiveName}`),
906
- );
907
- } finally {
908
- try {
909
- unlinkSync(localTmpPath);
910
- } catch {
911
- // Ignore cleanup errors
912
- }
913
- }
865
+ export function backupCompose(ip, sshKeyPath, projectName, options = {}) {
866
+ const remoteDir = `/opt/${projectName}`;
867
+ const t = perfTimer('backup.walgPush');
868
+ sshRun(ip, sshKeyPath, composeBackupCmd(remoteDir, options.retain ?? 7), { timeout: 900_000 });
869
+ t.end();
914
870
  }
915
871
 
916
872
  /**
917
- * Set up automated backup cron on the VPS.
873
+ * Set up the automated wal-g backup cron on the VPS.
874
+ *
875
+ * Installs a crontab entry that runs compose-backup.sh on the configured
876
+ * schedule — the SAME builder the on-demand path uses, so there is exactly
877
+ * one quoting/invocation path. No awscli required — wal-g already has S3
878
+ * credentials via env vars in docker-compose.yml.
879
+ *
880
+ * The cron line contains no single quotes (the builder is plain
881
+ * `cd … && RETAIN=N bash …`), so it is installed quote-safely by piping the
882
+ * new crontab through stdin (heredoc) rather than wrapping it in `echo '…'`.
883
+ *
884
+ * @param {string} ip
885
+ * @param {string} sshKeyPath
886
+ * @param {string} projectName
887
+ * @param {object} [backupConfig]
888
+ * @param {string} [backupConfig.schedule]
889
+ * @param {number} [backupConfig.retentionDays]
890
+ * @param {object} [opts]
891
+ * @param {(ip: string, key: string, script: string, o: object) => unknown} [opts.runScript=sshRunScript]
892
+ * Injectable script runner — defaults to the real SSH runner; overridden in unit tests.
918
893
  */
919
- export function setupComposeBackupCron(ip, sshKeyPath, projectName, backupS3Config, backupConfig) {
894
+ export function setupComposeBackupCron(ip, sshKeyPath, projectName, backupConfig, opts = {}) {
895
+ const { runScript = sshRunScript } = opts;
920
896
  const remoteDir = `/opt/${projectName}`;
921
- const schedule = backupConfig?.schedule || '0 */6 * * *';
922
- const retentionDays = backupConfig?.retentionDays || 30;
923
-
924
- // Build environment file for cron
925
- const envLines = [`PROJECT_NAME=${projectName}`, `BACKUP_RETENTION_DAYS=${retentionDays}`];
926
- if (backupS3Config) {
927
- envLines.push(
928
- `S3_BACKUP_BUCKET=${backupS3Config.bucket}`,
929
- `S3_ACCESS_KEY=${backupS3Config.accessKey}`,
930
- `S3_SECRET_KEY=${backupS3Config.secretKey}`,
931
- `S3_ENDPOINT=${backupS3Config.endpoint}`,
932
- `S3_REGION=${backupS3Config.region}`,
933
- );
934
- }
897
+ const schedule = backupConfig?.schedule || '0 2 * * *';
898
+ const retain =
899
+ Number.isInteger(backupConfig?.retentionDays) && backupConfig.retentionDays > 0
900
+ ? backupConfig.retentionDays
901
+ : 7;
902
+
903
+ const cronLine = `${schedule} ${composeBackupCmd(remoteDir, retain)} >> ${remoteDir}/backups/backup.log 2>&1`;
904
+
905
+ // Install quote-safely: build the next crontab on the server (existing minus
906
+ // any prior compose-backup.sh line, plus our line) and pipe via stdin. The
907
+ // cron line is fed to `cat` through a heredoc so no shell quoting can mangle
908
+ // it — there are no single quotes in it to collide with anyway.
909
+ const installScript = [
910
+ 'set -e',
911
+ `mkdir -p ${remoteDir}/backups`,
912
+ 'TMP_CRON=$(mktemp)',
913
+ 'crontab -l 2>/dev/null | grep -v \'compose-backup.sh\' > "$TMP_CRON" || true',
914
+ 'cat >> "$TMP_CRON" <<\'VC_CRON_EOF\'',
915
+ cronLine,
916
+ 'VC_CRON_EOF',
917
+ 'crontab "$TMP_CRON"',
918
+ 'rm -f "$TMP_CRON"',
919
+ ].join('\n');
920
+
921
+ runScript(ip, sshKeyPath, installScript, { timeout: 60_000 });
922
+ }
935
923
 
936
- // Install awscli for automated S3 uploads (fail loudly if it can't be installed)
937
- if (backupS3Config) {
938
- sshRun(
939
- ip,
940
- sshKeyPath,
941
- 'command -v aws >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y awscli || pip3 install awscli)',
942
- { timeout: 120_000 },
924
+ // ISO-8601 datetime: YYYY-MM-DDTHH:MM:SS followed by Z or ±HH:MM
925
+ const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/;
926
+
927
+ /**
928
+ * Build the shell script that fetches a wal-g base backup and writes the
929
+ * archive-recovery configuration so postgres replays WAL from S3 and
930
+ * promotes to read-write on completion (or at a PITR target).
931
+ *
932
+ * This is a pure function (no I/O) so it can be unit-tested without SSH.
933
+ * The script is SCP'd to the server and bind-mounted into the db container
934
+ * via `docker compose run --rm -v …:/restore.sh:ro db bash /restore.sh`,
935
+ * which avoids the quoting collision that would arise from embedding
936
+ * single-quoted postgres config values in a deeply-nested ssh command.
937
+ *
938
+ * Mirrors the k8s walg-restore init container in carbon/k8s/values/supabase.values.yaml.
939
+ *
940
+ * @param {'latest' | string} target 'latest' for the most-recent base backup,
941
+ * or an ISO-8601 datetime string for point-in-time recovery.
942
+ * @returns {string} The bash script body.
943
+ */
944
+ export function composeRestoreScript(target) {
945
+ if (target !== 'latest' && !ISO_DATETIME_RE.test(target)) {
946
+ throw new Error(
947
+ `Invalid target ${JSON.stringify(target)}: must be "latest" or an ISO-8601 datetime (e.g. 2026-05-31T12:00:00Z)`,
943
948
  );
944
949
  }
945
950
 
946
- // Write env file and install cron
947
- const envContent = envLines.join('\\n');
948
- sshRun(
949
- ip,
950
- sshKeyPath,
951
- [
952
- `printf '${envContent}\\n' > ${remoteDir}/backups/.backup-env`,
953
- `chmod 600 ${remoteDir}/backups/.backup-env`,
954
- `chmod +x ${remoteDir}/backup/compose-backup.sh`,
955
- `(crontab -l 2>/dev/null | grep -v 'compose-backup.sh' ; echo '${schedule} set -a && . ${remoteDir}/backups/.backup-env && set +a && ${remoteDir}/backup/compose-backup.sh >> ${remoteDir}/backups/backup.log 2>&1') | crontab -`,
956
- ].join(' && '),
957
- { timeout: 120_000 },
958
- );
951
+ // pitrLine goes inside the { ... } block — no separate redirect needed
952
+ const pitrLine = target !== 'latest' ? ` echo "recovery_target_time = '${target}'"` : null;
953
+
954
+ return [
955
+ '#!/bin/bash',
956
+ 'set -euo pipefail',
957
+ 'PGDATA=/var/lib/postgresql/data',
958
+ '',
959
+ '# Empty PGDATA — wal-g backup-fetch requires an empty target dir. We are',
960
+ '# inside the db container, which mounts the real <project>_db_data volume',
961
+ '# at $PGDATA, so this clears the correct data (unlike a `docker compose run',
962
+ "# -v db_data:/data` flag, which bypasses Compose's project-volume naming).",
963
+ 'if [ -f "$PGDATA/PG_VERSION" ]; then',
964
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: ${PGDATA:?} is a deliberate bash "fail if unset" guard, not a JS template placeholder
965
+ ' rm -rf "${PGDATA:?}/"* "${PGDATA:?}/".* 2>/dev/null || true',
966
+ 'fi',
967
+ '',
968
+ '# Fetch the base backup from S3 (wal-g reads S3 credentials from container env)',
969
+ `wal-g backup-fetch "$PGDATA" LATEST`,
970
+ '',
971
+ '# Remove any recovery settings left in postgresql.auto.conf by a previous',
972
+ "# restore so they don't accumulate across cycles (postgres takes the last",
973
+ '# value, but stale duplicates are confusing).',
974
+ 'if [ -f "$PGDATA/postgresql.auto.conf" ]; then',
975
+ ' sed -i "/^restore_command =/d; /^recovery_target_action =/d; /^recovery_target_time =/d; /^recovery_target_timeline =/d" "$PGDATA/postgresql.auto.conf"',
976
+ 'fi',
977
+ '',
978
+ '# Write archive-recovery config so postgres replays WAL segments from S3',
979
+ '# and promotes to read-write on end-of-WAL (or at the PITR target).',
980
+ '# Uses >> so any existing postgresql.auto.conf settings are preserved.',
981
+ '{',
982
+ ` echo "restore_command = 'wal-g wal-fetch \\"%f\\" \\"%p\\"'"`,
983
+ ` echo "recovery_target_action = 'promote'"`,
984
+ // Pin recovery to the FETCHED base backup's own timeline. Default is
985
+ // 'latest', which makes postgres chase the newest timeline it can find a
986
+ // .history file for. In HA, repeated restore→promote cycles accumulate
987
+ // DIVERGENT timelines in the shared wal-g S3 prefix, and 'latest' picks one
988
+ // that forked off BEFORE this base backup's checkpoint — postgres then
989
+ // crash-loops "requested timeline N is not a child of this server's history"
990
+ // / wal-g "Archive '0000000N.history' does not exist". 'current' recovers
991
+ // along the base backup's timeline to end-of-WAL, then promotes fresh.
992
+ // (RCA 2026-06-01: compose-ha kept-rig restore.)
993
+ ` echo "recovery_target_timeline = 'current'"`,
994
+ pitrLine,
995
+ '} >> "$PGDATA/postgresql.auto.conf"',
996
+ '',
997
+ '# Signal file that tells postgres to enter archive recovery mode (PG 12+)',
998
+ 'touch "$PGDATA/recovery.signal"',
999
+ ]
1000
+ .filter((line) => line !== null)
1001
+ .join('\n');
959
1002
  }
960
1003
 
961
1004
  /**
962
- * Restore from a backup archive via Docker Compose.
963
- * Supports legacy .tar.gz (pg_dump) and new WAL-G point-in-time recovery.
1005
+ * Restore from a wal-g base backup via Docker Compose with full archive
1006
+ * recovery. Postgres replays WAL segments from S3 via restore_command and
1007
+ * promotes to read-write on reaching end-of-WAL or the PITR target.
1008
+ *
1009
+ * S3 credentials are read from the db container's environment (sourced from
1010
+ * the project .env file) — DO NOT pass them as -e overrides; the container
1011
+ * already has WALG_S3_PREFIX + AWS_* set by carbon/docker-compose.yml.
1012
+ *
1013
+ * @param {string} ip
1014
+ * @param {string} sshKeyPath
1015
+ * @param {string} projectName
1016
+ * @param {'latest' | string} [target='latest'] 'latest' or ISO-8601 PITR timestamp.
964
1017
  */
965
- export async function restoreCompose(ip, sshKeyPath, projectName, backupFile, options = {}) {
1018
+ export async function restoreCompose(ip, sshKeyPath, projectName, target = 'latest') {
966
1019
  const remoteDir = `/opt/${projectName}`;
967
- const isWalg = backupFile === 'latest' || backupFile === 'walg';
968
1020
 
969
- // Stop the app container to prevent connections
1021
+ // Build and validate the restore script early fail before touching the
1022
+ // running server if the target is malformed.
1023
+ const scriptBody = composeRestoreScript(target);
1024
+
1025
+ // 1. Stop app to prevent connections during restore
970
1026
  await sshRunAsync(
971
1027
  ip,
972
1028
  sshKeyPath,
973
1029
  `cd ${remoteDir} && docker compose stop app 2>/dev/null || true`,
974
1030
  );
975
1031
 
976
- if (isWalg) {
977
- // --- BLAZING FAST WAL-G RESTORE ---
978
- // 1. Stop DB
979
- await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose stop db`);
980
- // 2. Clear data (WAL-G restore-fetch requires empty dir)
981
- await sshRunAsync(
982
- ip,
983
- sshKeyPath,
984
- `cd ${remoteDir} && docker compose run --rm -v db_data:/data alpine sh -c "rm -rf /data/*"`,
985
- );
986
- // 3. Run WAL-G restore
987
- const restoreCmd = `cd ${remoteDir} && docker compose run --rm \
988
- -e WALG_S3_PREFIX="${options.walgS3Prefix}" \
989
- -e AWS_ACCESS_KEY_ID="${options.s3AccessKey}" \
990
- -e AWS_SECRET_ACCESS_KEY="${options.s3SecretKey}" \
991
- -e AWS_ENDPOINT="${options.s3Endpoint}" \
992
- -e AWS_REGION="${options.s3Region}" \
993
- db wal-g backup-fetch /var/lib/postgresql/data LATEST`;
994
- await sshRunAsync(ip, sshKeyPath, restoreCmd, { timeout: 900_000 });
995
- // 4. Start DB
996
- await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose start db`);
997
- } else {
998
- // --- LEGACY PG_DUMP RESTORE ---
999
- // Extract archive
1000
- await sshRunAsync(
1001
- ip,
1002
- sshKeyPath,
1003
- `mkdir -p /tmp/vb-restore && tar --no-xattrs --no-same-owner -xzf ${remoteDir}/backups/${backupFile} -C /tmp/vb-restore`,
1004
- { timeout: 120_000 },
1005
- );
1032
+ // 2. Stop the DB. The restore container (step 4) clears PGDATA *itself*
1033
+ // from inside the db container where the real ${project}_db_data volume
1034
+ // is mounted at $PGDATA — so there is no separate volume-clearing step.
1035
+ // (A `docker compose run --rm -v db_data:/data alpine` would clear a
1036
+ // literal `db_data` volume, NOT the project-prefixed one Compose uses.)
1037
+ await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose stop db`);
1038
+
1039
+ // 3. Write the restore script to the server (heredoc — no quoting risk) and
1040
+ // bind-mount it into the db container. The script body contains single
1041
+ // quotes (restore_command = 'wal-g wal-fetch ...') so it MUST go via a
1042
+ // file rather than a nested bash -c '...' invocation. The script clears
1043
+ // PGDATA, runs wal-g backup-fetch, and writes the archive-recovery config.
1044
+ sshRunScript(
1045
+ ip,
1046
+ sshKeyPath,
1047
+ [
1048
+ 'set -e',
1049
+ `cat > ${remoteDir}/restore-walg.sh <<'VC_RESTORE_EOF'`,
1050
+ scriptBody,
1051
+ 'VC_RESTORE_EOF',
1052
+ `chmod +x ${remoteDir}/restore-walg.sh`,
1053
+ ].join('\n'),
1054
+ { timeout: 30_000 },
1055
+ );
1006
1056
 
1007
- // Restore postgres database
1008
- await sshRunAsync(
1009
- ip,
1010
- sshKeyPath,
1011
- `cd ${remoteDir} && gunzip -c /tmp/vb-restore/postgres.sql.gz | docker compose exec -T db psql -U postgres -d postgres`,
1012
- { timeout: 300_000 },
1013
- );
1057
+ await sshRunAsync(
1058
+ ip,
1059
+ sshKeyPath,
1060
+ `cd ${remoteDir} && docker compose run --rm -v ${remoteDir}/restore-walg.sh:/restore.sh:ro db bash /restore.sh`,
1061
+ { timeout: 900_000 },
1062
+ );
1063
+
1064
+ // 4. Start the DB — postgres enters archive recovery, replays WAL, promotes
1065
+ await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose start db`);
1014
1066
 
1015
- // Restore optional service databases if present
1016
- for (const db of ['n8n', 'metabase']) {
1067
+ // 5. Poll until postgres has promoted (pg_isready + pg_is_in_recovery() = f).
1068
+ // WAL replay can take 1-2 minutes; deadline is 300s.
1069
+ const deadline = Date.now() + 300_000;
1070
+ let lastErr = '';
1071
+ for (;;) {
1072
+ try {
1073
+ // First check: postgres accepting connections
1017
1074
  await sshRunAsync(
1018
1075
  ip,
1019
1076
  sshKeyPath,
1020
- `test -f /tmp/vb-restore/${db}.sql.gz && cd ${remoteDir} && gunzip -c /tmp/vb-restore/${db}.sql.gz | docker compose exec -T db psql -U postgres -d ${db} || true`,
1021
- { timeout: 300_000 },
1077
+ `cd ${remoteDir} && docker compose exec -T db pg_isready -U postgres`,
1078
+ { timeout: 10_000 },
1022
1079
  );
1080
+ // Second check: promoted (no longer in recovery)
1081
+ const recovering = await sshRunAsync(
1082
+ ip,
1083
+ sshKeyPath,
1084
+ `cd ${remoteDir} && docker compose exec -T db psql -U postgres -At -c 'SELECT pg_is_in_recovery()'`,
1085
+ { timeout: 10_000 },
1086
+ );
1087
+ if (String(recovering).trim() === 'f') {
1088
+ break; // promoted — restore complete
1089
+ }
1090
+ lastErr = 'pg_is_in_recovery() still true';
1091
+ } catch (err) {
1092
+ lastErr = err instanceof Error ? err.message : String(err);
1023
1093
  }
1024
-
1025
- // Restore storage files if present
1026
- await sshRunAsync(
1027
- ip,
1028
- sshKeyPath,
1029
- `test -f /tmp/vb-restore/storage.tar.gz && cd ${remoteDir} && docker compose exec -T storage sh -c 'tar --no-xattrs --no-same-owner -xzf - -C /' < /tmp/vb-restore/storage.tar.gz || true`,
1030
- { timeout: 300_000 },
1031
- );
1032
-
1033
- // Cleanup
1034
- await sshRunAsync(ip, sshKeyPath, 'rm -rf /tmp/vb-restore');
1094
+ if (Date.now() >= deadline) {
1095
+ throw new Error(
1096
+ `restoreCompose: postgres did not promote within 300s after wal-g restore. ` +
1097
+ `Last error: ${lastErr}`,
1098
+ );
1099
+ }
1100
+ await new Promise((r) => setTimeout(r, 5_000));
1035
1101
  }
1036
1102
 
1037
- // Restart app
1103
+ // 6. Bring the app back up
1038
1104
  await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose start app`);
1039
1105
  }
1040
1106