@rebasepro/server-postgres 0.10.1-canary.31c773c → 0.10.1-canary.6f89f77

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.
@@ -14,6 +14,7 @@ import {
14
14
  RoleData,
15
15
  CreateRoleData,
16
16
  RefreshTokenInfo,
17
+ RefreshTokenSession,
17
18
  PasswordResetTokenInfo,
18
19
  MagicLinkTokenInfo,
19
20
  UserIdentityData,
@@ -529,6 +530,7 @@ export class UserService implements UserRepository {
529
530
 
530
531
  export class RefreshTokenService {
531
532
  private refreshTokensTable: RebasePgTable;
533
+ private usersTable: RebasePgTable | null;
532
534
 
533
535
  constructor(
534
536
  private db: NodePgDatabase,
@@ -536,55 +538,159 @@ export class RefreshTokenService {
536
538
  ) {
537
539
  if (tableOrTables && ((tableOrTables as Partial<AuthSchemaTables>).refreshTokens || (tableOrTables as Partial<AuthSchemaTables>).users)) {
538
540
  this.refreshTokensTable = ((tableOrTables as Partial<AuthSchemaTables>).refreshTokens || refreshTokens) as RebasePgTable;
541
+ this.usersTable = ((tableOrTables as Partial<AuthSchemaTables>).users || users) as RebasePgTable;
539
542
  } else {
540
543
  this.refreshTokensTable = (tableOrTables as RebasePgTable) || (refreshTokens as unknown as RebasePgTable);
544
+ this.usersTable = users as unknown as RebasePgTable;
541
545
  }
542
546
  }
543
547
 
544
- async createToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string): Promise<void> {
545
- // Fallback to empty string because UNIQUE constraints treat NULLs as strictly distinct in standard Postgres.
546
- // We want (uid, NULL, NULL) to collide and overwrite, so we map undefined/null to empty strings.
548
+ /**
549
+ * Whether the table actually carries a column, so a host application that
550
+ * supplied its own `refresh_tokens` table — one that predates session
551
+ * grouping — degrades instead of throwing on every sign-in.
552
+ */
553
+ private has(column: string): boolean {
554
+ return Boolean((this.refreshTokensTable as unknown as Record<string, unknown>)[column]);
555
+ }
556
+
557
+ private col(column: string) {
558
+ return (this.refreshTokensTable as unknown as Record<string, never>)[column];
559
+ }
560
+
561
+ /** The columns to read back, narrowed to the ones this table has. */
562
+ private selection() {
563
+ const selection: Record<string, never> = {
564
+ id: this.refreshTokensTable.id,
565
+ uid: this.refreshTokensTable.uid,
566
+ tokenHash: this.refreshTokensTable.tokenHash,
567
+ expiresAt: this.refreshTokensTable.expiresAt,
568
+ createdAt: this.refreshTokensTable.createdAt,
569
+ userAgent: this.refreshTokensTable.userAgent,
570
+ ipAddress: this.refreshTokensTable.ipAddress
571
+ } as unknown as Record<string, never>;
572
+ for (const optional of ["sessionId", "rotatedAt", "revoked", "sessionStartedAt"]) {
573
+ if (this.has(optional)) selection[optional] = this.col(optional);
574
+ }
575
+ return selection;
576
+ }
577
+
578
+ async createToken(
579
+ uid: string,
580
+ tokenHash: string,
581
+ expiresAt: Date,
582
+ userAgent?: string,
583
+ ipAddress?: string,
584
+ session?: RefreshTokenSession
585
+ ): Promise<void> {
586
+ // Empty strings rather than NULLs: the device-session UNIQUE constraint
587
+ // that needed them is gone, but sessions-list UIs already render "" as
588
+ // "unknown device" and would start showing blanks otherwise.
547
589
  const safeUserAgent = userAgent || "";
548
590
  const safeIpAddress = ipAddress || "";
549
591
 
550
- // Atomic upsert keyed on the device session (uid, user_agent, ip_address).
551
- // A DELETE-then-INSERT races under concurrent refreshes — cookie-mode boot can
552
- // fire two /refresh calls at once (both carrying the same refresh cookie): both
553
- // DELETE, then the second INSERT violates `unique_device_session` and 500s.
554
- // A single INSERT ... ON CONFLICT DO UPDATE rotates the token atomically.
555
- await this.db.insert(this.refreshTokensTable)
556
- .values({
557
- uid,
558
- tokenHash,
559
- expiresAt,
560
- userAgent: safeUserAgent,
561
- ipAddress: safeIpAddress
562
- })
563
- .onConflictDoUpdate({
564
- target: [
565
- this.refreshTokensTable.uid,
566
- this.refreshTokensTable.userAgent,
567
- this.refreshTokensTable.ipAddress
568
- ],
569
- set: { tokenHash, expiresAt }
570
- });
592
+ // A plain INSERT. Rotation ADDS a token; it does not replace a device's
593
+ // row. Two refreshes racing on the same session therefore both succeed
594
+ // and both end holding a usable token, where the previous upsert had
595
+ // them overwrite each other and logged one of the two tabs out.
596
+ const values: Record<string, unknown> = {
597
+ uid,
598
+ tokenHash,
599
+ expiresAt,
600
+ userAgent: safeUserAgent,
601
+ ipAddress: safeIpAddress
602
+ };
603
+ if (session && this.has("sessionId")) values.sessionId = session.id;
604
+ if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
605
+
606
+ await this.db.insert(this.refreshTokensTable).values(values);
571
607
  }
572
608
 
573
609
  async findByHash(tokenHash: string): Promise<RefreshTokenInfo | null> {
574
610
  const [token] = await this.db
575
- .select({
576
- id: this.refreshTokensTable.id,
577
- uid: this.refreshTokensTable.uid,
578
- tokenHash: this.refreshTokensTable.tokenHash,
579
- expiresAt: this.refreshTokensTable.expiresAt,
580
- createdAt: this.refreshTokensTable.createdAt,
581
- userAgent: this.refreshTokensTable.userAgent,
582
- ipAddress: this.refreshTokensTable.ipAddress
583
- })
611
+ .select(this.selection())
584
612
  .from(this.refreshTokensTable)
585
613
  .where(eq(this.refreshTokensTable.tokenHash, tokenHash));
586
614
 
587
- return (token as RefreshTokenInfo) || null;
615
+ return (token as unknown as RefreshTokenInfo) || null;
616
+ }
617
+
618
+ /**
619
+ * Record that a token was rotated away, keeping the row.
620
+ *
621
+ * The row is what lets `/auth/refresh` distinguish "you already used this,
622
+ * here is a fresh one" from "no idea what this is". Deleting it — which is
623
+ * what this used to do — collapsed both into a 401 and signed the user out
624
+ * for the crime of losing a response.
625
+ */
626
+ async markRotated(tokenHash: string): Promise<void> {
627
+ if (!this.has("rotatedAt")) {
628
+ await this.deleteByHash(tokenHash);
629
+ return;
630
+ }
631
+ await this.db
632
+ .update(this.refreshTokensTable)
633
+ .set({ rotatedAt: new Date() })
634
+ .where(eq(this.refreshTokensTable.tokenHash, tokenHash));
635
+ }
636
+
637
+ /** Final kill of one sign-in: logout, or revoking a device remotely. */
638
+ async revokeSession(sessionId: string): Promise<void> {
639
+ if (!this.has("sessionId")) return;
640
+ if (this.has("revoked")) {
641
+ await this.db
642
+ .update(this.refreshTokensTable)
643
+ .set({ revoked: true, ...(this.has("rotatedAt") ? { rotatedAt: new Date() } : {}) })
644
+ .where(eq(this.col("sessionId"), sessionId));
645
+ return;
646
+ }
647
+ await this.db.delete(this.refreshTokensTable).where(eq(this.col("sessionId"), sessionId));
648
+ }
649
+
650
+ /**
651
+ * Housekeeping: rotation would otherwise leave a row per refresh forever.
652
+ * Superseded rows are only needed for as long as a straggler might still
653
+ * present them, and expired ones are dead weight everywhere.
654
+ */
655
+ async prune(uid: string, sessionId: string, supersededBefore: Date): Promise<void> {
656
+ const uidCol = this.refreshTokensTable.uid;
657
+ const expiresCol = this.refreshTokensTable.expiresAt;
658
+ if (!this.has("rotatedAt") || !this.has("sessionId")) {
659
+ await this.db.delete(this.refreshTokensTable)
660
+ .where(sql`${uidCol} = ${uid} AND ${expiresCol} < NOW()`);
661
+ return;
662
+ }
663
+ const rotatedCol = this.col("rotatedAt");
664
+ const sessionCol = this.col("sessionId");
665
+ await this.db.delete(this.refreshTokensTable).where(sql`
666
+ ${uidCol} = ${uid}
667
+ AND (
668
+ ${expiresCol} < NOW()
669
+ OR (
670
+ ${sessionCol} = ${sessionId}
671
+ AND ${rotatedCol} IS NOT NULL
672
+ AND ${rotatedCol} < ${supersededBefore}
673
+ )
674
+ )
675
+ `);
676
+ }
677
+
678
+ async getTokensValidAfter(uid: string): Promise<Date | null> {
679
+ if (!this.usersTable || !(this.usersTable as unknown as Record<string, unknown>).tokensValidAfter) return null;
680
+ const [row] = await this.db
681
+ .select({ tokensValidAfter: (this.usersTable as unknown as Record<string, never>).tokensValidAfter })
682
+ .from(this.usersTable)
683
+ .where(eq(this.usersTable.id, uid));
684
+ const value = (row as { tokensValidAfter?: Date | string | null } | undefined)?.tokensValidAfter;
685
+ return value ? new Date(value) : null;
686
+ }
687
+
688
+ async setTokensValidAfter(uid: string, at: Date): Promise<void> {
689
+ if (!this.usersTable || !(this.usersTable as unknown as Record<string, unknown>).tokensValidAfter) return;
690
+ await this.db
691
+ .update(this.usersTable)
692
+ .set({ tokensValidAfter: at })
693
+ .where(eq(this.usersTable.id, uid));
588
694
  }
589
695
 
590
696
  async deleteByHash(tokenHash: string): Promise<void> {
@@ -597,20 +703,12 @@ export class RefreshTokenService {
597
703
 
598
704
  async listForUser(uid: string): Promise<RefreshTokenInfo[]> {
599
705
  const tokens = await this.db
600
- .select({
601
- id: this.refreshTokensTable.id,
602
- uid: this.refreshTokensTable.uid,
603
- tokenHash: this.refreshTokensTable.tokenHash,
604
- expiresAt: this.refreshTokensTable.expiresAt,
605
- createdAt: this.refreshTokensTable.createdAt,
606
- userAgent: this.refreshTokensTable.userAgent,
607
- ipAddress: this.refreshTokensTable.ipAddress
608
- })
706
+ .select(this.selection())
609
707
  .from(this.refreshTokensTable)
610
708
  .where(eq(this.refreshTokensTable.uid, uid))
611
709
  .orderBy(this.refreshTokensTable.createdAt);
612
710
 
613
- return tokens as RefreshTokenInfo[];
711
+ return tokens as unknown as RefreshTokenInfo[];
614
712
  }
615
713
 
616
714
  async deleteById(id: string, uid: string): Promise<void> {
@@ -804,8 +902,28 @@ export class PostgresTokenRepository implements TokenRepository {
804
902
 
805
903
  // Refresh token operations
806
904
 
807
- async createRefreshToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string): Promise<void> {
808
- await this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress);
905
+ async createRefreshToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string, session?: RefreshTokenSession): Promise<void> {
906
+ await this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
907
+ }
908
+
909
+ async markRefreshTokenRotated(tokenHash: string): Promise<void> {
910
+ await this.refreshTokenService.markRotated(tokenHash);
911
+ }
912
+
913
+ async revokeRefreshTokenSession(sessionId: string): Promise<void> {
914
+ await this.refreshTokenService.revokeSession(sessionId);
915
+ }
916
+
917
+ async pruneRefreshTokens(uid: string, sessionId: string, supersededBefore: Date): Promise<void> {
918
+ await this.refreshTokenService.prune(uid, sessionId, supersededBefore);
919
+ }
920
+
921
+ async getTokensValidAfter(uid: string): Promise<Date | null> {
922
+ return this.refreshTokenService.getTokensValidAfter(uid);
923
+ }
924
+
925
+ async setTokensValidAfter(uid: string, at: Date): Promise<void> {
926
+ await this.refreshTokenService.setTokensValidAfter(uid, at);
809
927
  }
810
928
 
811
929
  async findRefreshTokenByHash(tokenHash: string): Promise<RefreshTokenInfo | null> {
@@ -1018,8 +1136,28 @@ collectionPermissions: null }
1018
1136
 
1019
1137
  // Token operations (delegate to PostgresTokenRepository)
1020
1138
 
1021
- async createRefreshToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string): Promise<void> {
1022
- await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress);
1139
+ async createRefreshToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string, session?: RefreshTokenSession): Promise<void> {
1140
+ await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
1141
+ }
1142
+
1143
+ async markRefreshTokenRotated(tokenHash: string): Promise<void> {
1144
+ await this.tokenRepository.markRefreshTokenRotated(tokenHash);
1145
+ }
1146
+
1147
+ async revokeRefreshTokenSession(sessionId: string): Promise<void> {
1148
+ await this.tokenRepository.revokeRefreshTokenSession(sessionId);
1149
+ }
1150
+
1151
+ async pruneRefreshTokens(uid: string, sessionId: string, supersededBefore: Date): Promise<void> {
1152
+ await this.tokenRepository.pruneRefreshTokens(uid, sessionId, supersededBefore);
1153
+ }
1154
+
1155
+ async getTokensValidAfter(uid: string): Promise<Date | null> {
1156
+ return this.tokenRepository.getTokensValidAfter(uid);
1157
+ }
1158
+
1159
+ async setTokensValidAfter(uid: string, at: Date): Promise<void> {
1160
+ await this.tokenRepository.setTokensValidAfter(uid, at);
1023
1161
  }
1024
1162
 
1025
1163
  async findRefreshTokenByHash(tokenHash: string): Promise<RefreshTokenInfo | null> {
@@ -13,19 +13,22 @@ import { logger } from "@rebasepro/server";
13
13
  import type { StorageController } from "@rebasepro/server";
14
14
  import {
15
15
  BackupDestination,
16
+ globalsFileForDump,
16
17
  parseBackupDestination,
17
18
  parseDbNameFromUrl,
18
19
  resolveConnectionString,
19
20
  withDatabaseName
20
21
  } from "./pg-tools";
21
22
  import {
23
+ applyGlobals,
22
24
  BackupToolError,
23
25
  createDump,
24
26
  ensureDatabaseExists,
25
27
  listBackups,
26
28
  preflight,
27
29
  restoreDump,
28
- uploadBackup
30
+ uploadBackup,
31
+ validateDump
29
32
  } from "./backup-service";
30
33
 
31
34
  function formatBytes(bytes: number): string {
@@ -140,8 +143,12 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
140
143
  noOwner: args["--no-owner"],
141
144
  inheritStdio: true
142
145
  });
146
+ await assertDumpValid(dump.localFile);
143
147
  logger.info("");
144
148
  logger.info(chalk.green(` ✓ Backup written to ${dump.localFile} (${formatBytes(dump.sizeBytes)})`));
149
+ if (dump.globalsFile) {
150
+ logger.info(chalk.gray(` ✓ Roles captured to ${dump.globalsFile} (needed so RLS survives a restore).`));
151
+ }
145
152
  } else {
146
153
  const storage = await resolveStorageForDestination(dest, process.env);
147
154
  const dump = await createDump({
@@ -152,12 +159,20 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
152
159
  inheritStdio: true
153
160
  });
154
161
  try {
162
+ await assertDumpValid(dump.localFile);
155
163
  const uploaded = await uploadBackup(storage!, dump.localFile, dest);
156
164
  logger.info("");
157
165
  logger.info(chalk.green(` ✓ Backup uploaded to ${uploaded.storageUrl} (${formatBytes(dump.sizeBytes)})`));
166
+ // Upload the roles sidecar next to the dump so a restore can
167
+ // recreate roles the dump's GRANT/RLS statements depend on.
168
+ if (dump.globalsFile && fs.existsSync(dump.globalsFile)) {
169
+ const globalsUpload = await uploadBackup(storage!, dump.globalsFile, dest);
170
+ logger.info(chalk.gray(` ✓ Roles uploaded to ${globalsUpload.storageUrl} (needed so RLS survives a restore).`));
171
+ }
158
172
  logger.info(chalk.gray(" Ensure this bucket is private — backups may contain secrets and PII."));
159
173
  } finally {
160
174
  if (fs.existsSync(dump.localFile)) fs.unlinkSync(dump.localFile);
175
+ if (dump.globalsFile && fs.existsSync(dump.globalsFile)) fs.unlinkSync(dump.globalsFile);
161
176
  }
162
177
  }
163
178
  logger.info("");
@@ -177,6 +192,7 @@ export async function restoreCommand(rawArgs: string[]): Promise<void> {
177
192
  "--create-db": Boolean,
178
193
  "--clean": Boolean,
179
194
  "--no-owner": Boolean,
195
+ "--continue-on-error": Boolean,
180
196
  "--yes": Boolean,
181
197
  "-y": "--yes"
182
198
  },
@@ -206,7 +222,11 @@ export async function restoreCommand(rawArgs: string[]): Promise<void> {
206
222
  logger.info("");
207
223
 
208
224
  // Resolve the local file to restore from (download object-storage keys).
225
+ // Also resolve the `.globals.sql` roles sidecar so cluster roles can be
226
+ // recreated before the restore — without them the dump's GRANT/RLS
227
+ // statements fail and RLS is silently lost.
209
228
  let localFile: string;
229
+ let globalsSql: string | null = null;
210
230
  let cleanupTemp = false;
211
231
  try {
212
232
  if (/^(s3|gs):\/\//.test(backupArg)) {
@@ -221,12 +241,16 @@ export async function restoreCommand(rawArgs: string[]): Promise<void> {
221
241
  localFile = path.join(tmpDir, path.basename(key));
222
242
  fs.writeFileSync(localFile, Buffer.from(await file.arrayBuffer()));
223
243
  cleanupTemp = true;
244
+ const globalsObj = await storage.getObject(globalsFileForDump(key), bucket);
245
+ if (globalsObj) globalsSql = Buffer.from(await globalsObj.arrayBuffer()).toString("utf-8");
224
246
  } else {
225
247
  localFile = path.resolve(backupArg);
226
248
  if (!fs.existsSync(localFile)) {
227
249
  logger.error(chalk.red(` ✗ Backup file not found: ${localFile}`));
228
250
  process.exit(1);
229
251
  }
252
+ const globalsPath = globalsFileForDump(localFile);
253
+ if (fs.existsSync(globalsPath)) globalsSql = fs.readFileSync(globalsPath, "utf-8");
230
254
  }
231
255
 
232
256
  // Version pre-flight. Check against the base connection — the server
@@ -261,11 +285,29 @@ export async function restoreCommand(rawArgs: string[]): Promise<void> {
261
285
  }
262
286
  }
263
287
 
288
+ // Recreate cluster roles before restoring so GRANT/RLS statements in
289
+ // the dump apply. Best-effort and idempotent (see applyGlobals).
290
+ if (globalsSql) {
291
+ logger.info(chalk.gray(" Recreating cluster roles from the backup's roles sidecar…"));
292
+ const { applied, skipped } = await applyGlobals(
293
+ targetConnection,
294
+ globalsSql,
295
+ (m) => logger.info(chalk.gray(m))
296
+ );
297
+ logger.info(chalk.gray(` ✓ Roles: ${applied} applied, ${skipped} skipped (already present or not permitted).`));
298
+ } else {
299
+ logger.warn(chalk.yellow(" ⚠️ No roles sidecar (.globals.sql) accompanies this backup."));
300
+ logger.warn(chalk.yellow(" If the dump grants to roles that don't exist (e.g. rebase_user), the restore"));
301
+ logger.warn(chalk.yellow(" will fail — recreate those roles first, or use a backup that includes its globals."));
302
+ }
303
+
264
304
  await restoreDump({
265
305
  connectionString: targetConnection,
266
306
  inputFile: localFile,
267
307
  clean: args["--clean"],
268
308
  noOwner: args["--no-owner"],
309
+ // Fail loudly by default so a skipped GRANT never leaves RLS off.
310
+ exitOnError: !args["--continue-on-error"],
269
311
  inheritStdio: true
270
312
  });
271
313
 
@@ -322,6 +364,17 @@ export async function backupsCommand(rawArgs: string[]): Promise<void> {
322
364
  }
323
365
  }
324
366
 
367
+ /** Verify a freshly written dump; abort the command if it looks corrupt. */
368
+ async function assertDumpValid(localFile: string): Promise<void> {
369
+ const check = await validateDump(localFile);
370
+ if (!check.ok) {
371
+ throw new BackupToolError(
372
+ `The backup failed validation and was not trusted: ${check.reason}`,
373
+ "The dump may be corrupt or truncated. Investigate before relying on it."
374
+ );
375
+ }
376
+ }
377
+
325
378
  function reportError(err: unknown): void {
326
379
  if (err instanceof BackupToolError) {
327
380
  logger.error(chalk.red(` ✗ ${err.message}`));
@@ -364,12 +417,18 @@ ${chalk.green.bold("Options")}
364
417
  ${chalk.blue("--create-db")} Create the target database first if it doesn't exist
365
418
  ${chalk.blue("--clean")} Drop existing objects before recreating them
366
419
  ${chalk.blue("--no-owner")} Ignore ownership from the dump
420
+ ${chalk.blue("--continue-on-error")} Log and continue past errors ${chalk.red("(may leave RLS un-enforced!)")}
367
421
  ${chalk.blue("--yes, -y")} Skip the interactive confirmation ${chalk.red("(destructive!)")}
368
422
 
369
423
  ${chalk.red.bold("Warning")}
370
424
  Restore is destructive and never runs automatically. Without --yes it
371
425
  requires an interactive 'yes'. Prefer --create-db/--target-db to restore
372
426
  into a fresh database rather than overwriting a live one.
427
+
428
+ By default the restore aborts on the first error (--exit-on-error) so a
429
+ skipped GRANT never silently leaves RLS un-enforced. Roles are recreated
430
+ from the backup's .globals.sql sidecar first; keep that file next to the
431
+ dump. Use --continue-on-error only when you understand the consequences.
373
432
  `);
374
433
  }
375
434
 
@@ -125,7 +125,7 @@ export function createBackupCron(config: BackupCronConfig): CronJobDefinition {
125
125
  // Backups of a large database can take a while; allow up to an hour.
126
126
  timeoutSeconds: 3600,
127
127
  async handler({ log }) {
128
- const { createDump, pruneBackups, uploadBackup } = await import("./backup-service");
128
+ const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service");
129
129
  const { destination } = config;
130
130
 
131
131
  if (destination.kind !== "local" && !config.storage) {
@@ -145,12 +145,32 @@ export function createBackupCron(config: BackupCronConfig): CronJobDefinition {
145
145
  });
146
146
  log(`Dump created: ${dump.fileName} (${formatBytes(dump.sizeBytes)})`);
147
147
 
148
+ // Validate BEFORE pruning: a corrupt-but-exit-0 dump must never be
149
+ // the reason the last good backup gets deleted.
150
+ const check = await validateDump(dump.localFile);
151
+ if (!check.ok) {
152
+ // Clean up the bad temp file for object destinations.
153
+ if (destination.kind !== "local" && fs.existsSync(dump.localFile)) {
154
+ fs.unlinkSync(dump.localFile);
155
+ }
156
+ if (dump.globalsFile && destination.kind !== "local" && fs.existsSync(dump.globalsFile)) {
157
+ fs.unlinkSync(dump.globalsFile);
158
+ }
159
+ throw new Error(`New backup failed validation — skipping upload and pruning to protect existing backups. ${check.reason}`);
160
+ }
161
+
148
162
  let storedKey = dump.localFile;
149
163
  try {
150
164
  if (destination.kind !== "local") {
151
165
  const uploaded = await uploadBackup(config.storage!, dump.localFile, destination);
152
166
  storedKey = uploaded.storageUrl;
153
167
  log(`Uploaded to ${uploaded.storageUrl}`);
168
+ // Upload the roles sidecar so a restore can recreate the
169
+ // roles the dump's GRANT/RLS statements depend on.
170
+ if (dump.globalsFile && fs.existsSync(dump.globalsFile)) {
171
+ const g = await uploadBackup(config.storage!, dump.globalsFile, destination);
172
+ log(`Uploaded roles sidecar to ${g.storageUrl}`);
173
+ }
154
174
  }
155
175
  } finally {
156
176
  // For object-storage destinations the local dump was a temp
@@ -158,6 +178,9 @@ export function createBackupCron(config: BackupCronConfig): CronJobDefinition {
158
178
  if (destination.kind !== "local" && fs.existsSync(dump.localFile)) {
159
179
  fs.unlinkSync(dump.localFile);
160
180
  }
181
+ if (dump.globalsFile && destination.kind !== "local" && fs.existsSync(dump.globalsFile)) {
182
+ fs.unlinkSync(dump.globalsFile);
183
+ }
161
184
  }
162
185
 
163
186
  let pruned: string[] = [];
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Pure-ish orchestration for the backup/restore paths, kept free of `execa`
3
+ * and `pg` value imports so it can be unit-tested under jest (the impure
4
+ * edges — spawning processes, opening connections — live in
5
+ * `backup-service.ts`, which is vitest/runtime only).
6
+ *
7
+ * The functions here take their side-effecting dependency as an argument
8
+ * (a statement runner, an object deleter) so tests can inject fakes.
9
+ */
10
+ import { globalsFileForDump, splitGlobalsStatements } from "./pg-tools";
11
+
12
+ /**
13
+ * Replay a `pg_dumpall --globals-only` script one statement at a time,
14
+ * tolerating per-statement failures. On a same-cluster restore the roles
15
+ * usually already exist (`CREATE ROLE` → "already exists") and on a managed
16
+ * provider an `ALTER ROLE <superuser>` may be refused; neither should abort
17
+ * recreation of the roles that *are* missing. Returns how many statements
18
+ * applied vs were skipped.
19
+ *
20
+ * `runStatement` executes one SQL statement and rejects on error.
21
+ */
22
+ export async function applyGlobalsWith(
23
+ runStatement: (sql: string) => Promise<void>,
24
+ globalsSql: string,
25
+ log: (message: string) => void = () => {}
26
+ ): Promise<{ applied: number; skipped: number }> {
27
+ let applied = 0;
28
+ let skipped = 0;
29
+ for (const statement of splitGlobalsStatements(globalsSql)) {
30
+ try {
31
+ await runStatement(statement);
32
+ applied++;
33
+ } catch (err) {
34
+ skipped++;
35
+ const firstLine = statement.split("\n")[0].slice(0, 80);
36
+ log(` • Skipped global: ${firstLine} (${err instanceof Error ? err.message : String(err)})`);
37
+ }
38
+ }
39
+ return { applied, skipped };
40
+ }
41
+
42
+ /**
43
+ * Delete each pruned dump together with its `.globals.sql` roles sidecar, so
44
+ * pruning never orphans the roles file. The sidecar is best-effort — older
45
+ * backups predate it, so a missing-sidecar failure is swallowed while a
46
+ * failure deleting the dump itself propagates.
47
+ *
48
+ * `deleteObject` removes one key and rejects if it cannot (e.g. not found).
49
+ */
50
+ export async function pruneWith(
51
+ keys: string[],
52
+ deleteObject: (key: string) => Promise<void>
53
+ ): Promise<void> {
54
+ for (const key of keys) {
55
+ await deleteObject(key);
56
+ try {
57
+ await deleteObject(globalsFileForDump(key));
58
+ } catch {
59
+ // Sidecar may not exist for older backups — ignore.
60
+ }
61
+ }
62
+ }