@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.
- package/dist/auth/services.d.ts +43 -4
- package/dist/backup/backup-logic.d.ts +23 -0
- package/dist/backup/backup-service.d.ts +44 -2
- package/dist/backup/pg-tools.d.ts +41 -1
- package/dist/cli-helpers.d.ts +33 -1
- package/dist/index.es.js +439 -85
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-schema.d.ts +170 -0
- package/dist/schema/destructive-sql.d.ts +49 -0
- package/package.json +6 -6
- package/src/auth/ensure-tables.ts +70 -56
- package/src/auth/services.ts +186 -48
- package/src/backup/backup-cli.ts +60 -1
- package/src/backup/backup-cron.ts +24 -1
- package/src/backup/backup-logic.ts +62 -0
- package/src/backup/backup-service.ts +132 -13
- package/src/backup/pg-tools.ts +70 -2
- package/src/cli-helpers.ts +82 -27
- package/src/cli.ts +152 -6
- package/src/schema/auth-schema.ts +41 -3
- package/src/schema/destructive-sql.ts +94 -0
- package/src/services/realtimeService.ts +13 -3
package/src/auth/services.ts
CHANGED
|
@@ -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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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> {
|
package/src/backup/backup-cli.ts
CHANGED
|
@@ -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
|
+
}
|