@rebasepro/server-postgres 0.10.1-canary.18115ba → 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/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 +227 -14
- package/dist/index.es.js.map +1 -1
- package/dist/schema/destructive-sql.d.ts +49 -0
- package/package.json +6 -6
- 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/destructive-sql.ts +94 -0
- package/src/services/realtimeService.ts +13 -3
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay a `pg_dumpall --globals-only` script one statement at a time,
|
|
3
|
+
* tolerating per-statement failures. On a same-cluster restore the roles
|
|
4
|
+
* usually already exist (`CREATE ROLE` → "already exists") and on a managed
|
|
5
|
+
* provider an `ALTER ROLE <superuser>` may be refused; neither should abort
|
|
6
|
+
* recreation of the roles that *are* missing. Returns how many statements
|
|
7
|
+
* applied vs were skipped.
|
|
8
|
+
*
|
|
9
|
+
* `runStatement` executes one SQL statement and rejects on error.
|
|
10
|
+
*/
|
|
11
|
+
export declare function applyGlobalsWith(runStatement: (sql: string) => Promise<void>, globalsSql: string, log?: (message: string) => void): Promise<{
|
|
12
|
+
applied: number;
|
|
13
|
+
skipped: number;
|
|
14
|
+
}>;
|
|
15
|
+
/**
|
|
16
|
+
* Delete each pruned dump together with its `.globals.sql` roles sidecar, so
|
|
17
|
+
* pruning never orphans the roles file. The sidecar is best-effort — older
|
|
18
|
+
* backups predate it, so a missing-sidecar failure is swallowed while a
|
|
19
|
+
* failure deleting the dump itself propagates.
|
|
20
|
+
*
|
|
21
|
+
* `deleteObject` removes one key and rejects if it cannot (e.g. not found).
|
|
22
|
+
*/
|
|
23
|
+
export declare function pruneWith(keys: string[], deleteObject: (key: string) => Promise<void>): Promise<void>;
|
|
@@ -5,8 +5,8 @@ export declare class BackupToolError extends Error {
|
|
|
5
5
|
readonly hint?: string | undefined;
|
|
6
6
|
constructor(message: string, hint?: string | undefined);
|
|
7
7
|
}
|
|
8
|
-
/** Locate `pg_dump` / `pg_restore`, honouring an env override. */
|
|
9
|
-
export declare function resolvePgBinary(tool: "pg_dump" | "pg_restore", env?: Record<string, string | undefined>): string | null;
|
|
8
|
+
/** Locate `pg_dump` / `pg_restore` / `pg_dumpall`, honouring an env override. */
|
|
9
|
+
export declare function resolvePgBinary(tool: "pg_dump" | "pg_restore" | "pg_dumpall", env?: Record<string, string | undefined>): string | null;
|
|
10
10
|
/** Run `<bin> --version` and extract the major version. */
|
|
11
11
|
export declare function detectToolMajor(bin: string): Promise<number | null>;
|
|
12
12
|
/** Query the server for its major version via `server_version_num`. */
|
|
@@ -27,11 +27,22 @@ export interface BackupResult {
|
|
|
27
27
|
localFile: string;
|
|
28
28
|
fileName: string;
|
|
29
29
|
sizeBytes: number;
|
|
30
|
+
/**
|
|
31
|
+
* Absolute path of the `.globals.sql` sidecar holding cluster-wide roles
|
|
32
|
+
* (present unless globals capture was disabled or unavailable).
|
|
33
|
+
*/
|
|
34
|
+
globalsFile?: string;
|
|
35
|
+
globalsSizeBytes?: number;
|
|
30
36
|
}
|
|
31
37
|
/**
|
|
32
38
|
* Produce a custom-format dump on local disk. When `outDir` is omitted the
|
|
33
39
|
* file is written to the OS temp directory (used by the upload path, which
|
|
34
40
|
* cleans it up afterwards).
|
|
41
|
+
*
|
|
42
|
+
* Alongside the `-Fc` dump it writes a `<name>.globals.sql` sidecar via
|
|
43
|
+
* `pg_dumpall --globals-only` so the roles the dump's GRANT/RLS statements
|
|
44
|
+
* depend on can be recreated on restore. Set `includeGlobals: false` to skip
|
|
45
|
+
* it (e.g. when the caller has no privilege to read cluster globals).
|
|
35
46
|
*/
|
|
36
47
|
export declare function createDump(opts: {
|
|
37
48
|
connectionString: string;
|
|
@@ -41,19 +52,50 @@ export declare function createDump(opts: {
|
|
|
41
52
|
excludeSchemas?: string[];
|
|
42
53
|
noOwner?: boolean;
|
|
43
54
|
inheritStdio?: boolean;
|
|
55
|
+
includeGlobals?: boolean;
|
|
44
56
|
env?: Record<string, string | undefined>;
|
|
45
57
|
}): Promise<BackupResult>;
|
|
58
|
+
/**
|
|
59
|
+
* Cheap integrity check on a freshly written dump: it must be non-empty and
|
|
60
|
+
* `pg_restore --list` must parse its table of contents without error. Used
|
|
61
|
+
* before pruning older backups so a corrupt-but-exit-0 dump never becomes
|
|
62
|
+
* the reason the last good backup is deleted.
|
|
63
|
+
*/
|
|
64
|
+
export declare function validateDump(localFile: string, env?: Record<string, string | undefined>): Promise<{
|
|
65
|
+
ok: boolean;
|
|
66
|
+
reason?: string;
|
|
67
|
+
}>;
|
|
68
|
+
/**
|
|
69
|
+
* Replay a `pg_dumpall --globals-only` script to recreate cluster roles
|
|
70
|
+
* before a restore, so the dump's GRANT/RLS statements (which reference
|
|
71
|
+
* `rebase_user` and any owner roles) actually apply. Runs statement by
|
|
72
|
+
* statement and tolerates per-statement failures — on a same-cluster restore
|
|
73
|
+
* the roles usually already exist (`CREATE ROLE` → "already exists"), and on
|
|
74
|
+
* a managed provider an `ALTER ROLE <superuser>` may be refused; neither
|
|
75
|
+
* should abort role recreation. Returns how many statements applied vs were
|
|
76
|
+
* skipped.
|
|
77
|
+
*/
|
|
78
|
+
export declare function applyGlobals(connectionString: string, globalsSql: string, log?: (message: string) => void): Promise<{
|
|
79
|
+
applied: number;
|
|
80
|
+
skipped: number;
|
|
81
|
+
}>;
|
|
46
82
|
/**
|
|
47
83
|
* Restore a custom-format dump into the database named by
|
|
48
84
|
* `connectionString`. Destructive when `clean` is set (drops objects
|
|
49
85
|
* first). Never called automatically — the CLI gates it behind explicit
|
|
50
86
|
* confirmation.
|
|
87
|
+
*
|
|
88
|
+
* Runs with `--exit-on-error` by default: a restore that logs-and-continues
|
|
89
|
+
* past a failed GRANT (because a role was missing) reports success with RLS
|
|
90
|
+
* un-enforced. Callers should recreate roles first (see {@link applyGlobals})
|
|
91
|
+
* and only set `exitOnError: false` deliberately.
|
|
51
92
|
*/
|
|
52
93
|
export declare function restoreDump(opts: {
|
|
53
94
|
connectionString: string;
|
|
54
95
|
inputFile: string;
|
|
55
96
|
clean?: boolean;
|
|
56
97
|
noOwner?: boolean;
|
|
98
|
+
exitOnError?: boolean;
|
|
57
99
|
inheritStdio?: boolean;
|
|
58
100
|
env?: Record<string, string | undefined>;
|
|
59
101
|
}): Promise<void>;
|
|
@@ -99,10 +99,50 @@ export declare function buildPgRestoreArgs(opts: {
|
|
|
99
99
|
inputFile: string;
|
|
100
100
|
/** Drop objects before recreating them (destructive but idempotent). */
|
|
101
101
|
clean?: boolean;
|
|
102
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* Abort on the first error instead of logging and continuing. Defaults
|
|
104
|
+
* ON: a restore that silently skips failed GRANT/RLS statements (because
|
|
105
|
+
* a role is missing) "succeeds" with RLS un-enforced — a security hole.
|
|
106
|
+
* Fail loudly instead so the operator knows the restore is incomplete.
|
|
107
|
+
*/
|
|
103
108
|
exitOnError?: boolean;
|
|
104
109
|
noOwner?: boolean;
|
|
105
110
|
}): string[];
|
|
111
|
+
/**
|
|
112
|
+
* Assemble the `pg_restore --list` argument vector. Reading a dump's table
|
|
113
|
+
* of contents parses the whole archive without touching a database, so it is
|
|
114
|
+
* a cheap integrity check that the file isn't truncated or corrupt.
|
|
115
|
+
*/
|
|
116
|
+
export declare function buildPgRestoreListArgs(inputFile: string): string[];
|
|
117
|
+
/**
|
|
118
|
+
* Assemble the `pg_dumpall --globals-only` argument vector. Roles (and other
|
|
119
|
+
* cluster-wide objects) live outside any single database, so a per-database
|
|
120
|
+
* `pg_dump` omits them. Without the `rebase_user` role the RLS GRANT
|
|
121
|
+
* statements in the main dump fail on restore and RLS is silently lost — so
|
|
122
|
+
* every backup captures the globals into a sidecar `.globals.sql`.
|
|
123
|
+
*
|
|
124
|
+
* `--no-role-passwords` keeps role secrets out of the artifact (backups may
|
|
125
|
+
* be shipped off-box); roles are recreated password-less and re-secured by
|
|
126
|
+
* the operator.
|
|
127
|
+
*/
|
|
128
|
+
export declare function buildPgDumpallGlobalsArgs(opts: {
|
|
129
|
+
connectionString: string;
|
|
130
|
+
outFile: string;
|
|
131
|
+
}): string[];
|
|
132
|
+
/**
|
|
133
|
+
* Derive the globals sidecar path/key for a given `.dump` file. Keeps the
|
|
134
|
+
* two artifacts adjacent so listing, uploading and pruning can find one from
|
|
135
|
+
* the other. A name that doesn't end in `.dump` is returned unchanged with a
|
|
136
|
+
* `.globals.sql` suffix appended.
|
|
137
|
+
*/
|
|
138
|
+
export declare function globalsFileForDump(dumpPath: string): string;
|
|
139
|
+
/**
|
|
140
|
+
* Split a `pg_dumpall --globals-only` script into individual statements.
|
|
141
|
+
* Used when replaying globals on restore so each `CREATE ROLE` / `GRANT`
|
|
142
|
+
* can run independently and a benign "role already exists" on one doesn't
|
|
143
|
+
* abort the rest. Drops `--` comment lines and blank statements.
|
|
144
|
+
*/
|
|
145
|
+
export declare function splitGlobalsStatements(sql: string): string[];
|
|
106
146
|
/**
|
|
107
147
|
* Resolve the Postgres connection string the backup commands should use,
|
|
108
148
|
* mirroring the precedence the branch command already relies on.
|
package/dist/cli-helpers.d.ts
CHANGED
|
@@ -4,4 +4,36 @@ export declare function getTableIncludesFromCollections(collections: CollectionC
|
|
|
4
4
|
export declare function getTableIncludes(collectionsPath: string): Promise<string[]>;
|
|
5
5
|
export declare function getDevDatabaseUrl(databaseUrl: string): string;
|
|
6
6
|
export declare function ensureDevDatabaseExists(databaseUrl: string, devDatabaseUrl: string): Promise<void>;
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Query the live database for every user table/view outside the system
|
|
9
|
+
* catalogs. Separated from {@link getTableExcludes} so its failure mode can
|
|
10
|
+
* be handled explicitly (fail closed) and so tests can inject a stub.
|
|
11
|
+
*/
|
|
12
|
+
export declare function queryExistingTables(databaseUrl: string): Promise<string[]>;
|
|
13
|
+
/**
|
|
14
|
+
* Raised when the exclude list could not be built. `db push` MUST abort on
|
|
15
|
+
* this rather than continue: the exclude list is the only thing shielding
|
|
16
|
+
* non-collection (user/system) tables from the auto-approved declarative
|
|
17
|
+
* apply. A partial list — the old fail-open behaviour — meant a transient
|
|
18
|
+
* introspection hiccup dropped every table not present in `schema.sql`.
|
|
19
|
+
*/
|
|
20
|
+
export declare class ExcludeIntrospectionError extends Error {
|
|
21
|
+
readonly cause?: unknown | undefined;
|
|
22
|
+
constructor(message: string, cause?: unknown | undefined);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build the `--exclude` list that protects tables Rebase doesn't manage from
|
|
26
|
+
* the declarative apply. Anything not backing a collection (or its M2M
|
|
27
|
+
* junctions) is excluded so Atlas never drops it.
|
|
28
|
+
*
|
|
29
|
+
* Fails **closed**: if the database can't be introspected we cannot know
|
|
30
|
+
* which tables to protect, so we throw {@link ExcludeIntrospectionError}
|
|
31
|
+
* instead of returning a near-empty list and letting the caller drop
|
|
32
|
+
* everything.
|
|
33
|
+
*
|
|
34
|
+
* `deps` is injectable for tests; production uses the real pg-backed queries.
|
|
35
|
+
*/
|
|
36
|
+
export declare function getTableExcludes(databaseUrl: string, collectionsPath: string, deps?: {
|
|
37
|
+
queryExistingTables?: (databaseUrl: string) => Promise<string[]>;
|
|
38
|
+
getIncludes?: (collectionsPath: string) => Promise<string[]>;
|
|
39
|
+
}): Promise<string[]>;
|
package/dist/index.es.js
CHANGED
|
@@ -518,6 +518,25 @@ function getDataSourceCapabilities(engine) {
|
|
|
518
518
|
if (!engine) return POSTGRES_CAPABILITIES;
|
|
519
519
|
return CAPABILITIES_REGISTRY[engine] ?? DEFAULT_CAPABILITIES;
|
|
520
520
|
}
|
|
521
|
+
/**
|
|
522
|
+
* Resolve a client-supplied list `limit` into a safe, always-defined value.
|
|
523
|
+
*
|
|
524
|
+
* - A provided limit is coerced to an integer and clamped to `[1, maxLimit]`,
|
|
525
|
+
* so `0`, negatives, and absurd values can never bypass the cap.
|
|
526
|
+
* - An absent / blank / non-numeric limit falls back to the mode default:
|
|
527
|
+
* `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
|
|
528
|
+
*
|
|
529
|
+
* The return is never `undefined` — no ingress that routes its client limit
|
|
530
|
+
* through this can produce an unbounded read.
|
|
531
|
+
*/
|
|
532
|
+
function resolveClientListLimit(rawLimit, opts = {}) {
|
|
533
|
+
const maxLimit = opts.maxLimit ?? 1e3;
|
|
534
|
+
if (rawLimit != null && String(rawLimit).trim() !== "") {
|
|
535
|
+
const parsed = typeof rawLimit === "number" ? rawLimit : parseInt(String(rawLimit), 10);
|
|
536
|
+
if (Number.isFinite(parsed)) return Math.min(Math.max(1, Math.floor(parsed)), maxLimit);
|
|
537
|
+
}
|
|
538
|
+
return opts.vectorSearch ? opts.vectorDefaultLimit ?? 10 : opts.defaultLimit ?? 50;
|
|
539
|
+
}
|
|
521
540
|
var snakeCaseRegex = /[A-Z]{2,}(?=[A-Z][a-z]|\b)|[A-Z]?[a-z]+|[0-9]+(?:[a-z](?![a-z]))?|[A-Z]/g;
|
|
522
541
|
var toSnakeCase = (str) => {
|
|
523
542
|
if (!str || typeof str !== "string") return "";
|
|
@@ -10857,6 +10876,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10857
10876
|
this.sendError(clientId, msg, subscriptionId);
|
|
10858
10877
|
return;
|
|
10859
10878
|
}
|
|
10879
|
+
const boundedLimit = resolveClientListLimit(request.limit, { vectorSearch: !!request.vectorSearch });
|
|
10860
10880
|
this._subscriptions.set(subscriptionId, {
|
|
10861
10881
|
clientId,
|
|
10862
10882
|
type: "collection",
|
|
@@ -10865,7 +10885,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10865
10885
|
filter: request.filter,
|
|
10866
10886
|
orderBy: request.orderBy,
|
|
10867
10887
|
order: request.order,
|
|
10868
|
-
limit:
|
|
10888
|
+
limit: boundedLimit,
|
|
10869
10889
|
startAfter: request.startAfter,
|
|
10870
10890
|
databaseId: request.collection?.databaseId,
|
|
10871
10891
|
searchString: request.searchString
|
|
@@ -10876,7 +10896,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10876
10896
|
filter: request.filter,
|
|
10877
10897
|
orderBy: request.orderBy,
|
|
10878
10898
|
order: request.order,
|
|
10879
|
-
limit:
|
|
10899
|
+
limit: boundedLimit,
|
|
10880
10900
|
startAfter: request.startAfter,
|
|
10881
10901
|
searchString: request.searchString
|
|
10882
10902
|
}, authContext);
|
|
@@ -12501,11 +12521,57 @@ function buildPgRestoreArgs(opts) {
|
|
|
12501
12521
|
];
|
|
12502
12522
|
if (opts.clean) args.push("--clean", "--if-exists");
|
|
12503
12523
|
if (opts.noOwner) args.push("--no-owner");
|
|
12504
|
-
if (opts.exitOnError) args.push("--exit-on-error");
|
|
12524
|
+
if (opts.exitOnError !== false) args.push("--exit-on-error");
|
|
12505
12525
|
args.push(opts.inputFile);
|
|
12506
12526
|
return args;
|
|
12507
12527
|
}
|
|
12508
12528
|
/**
|
|
12529
|
+
* Assemble the `pg_restore --list` argument vector. Reading a dump's table
|
|
12530
|
+
* of contents parses the whole archive without touching a database, so it is
|
|
12531
|
+
* a cheap integrity check that the file isn't truncated or corrupt.
|
|
12532
|
+
*/
|
|
12533
|
+
function buildPgRestoreListArgs(inputFile) {
|
|
12534
|
+
return ["--list", inputFile];
|
|
12535
|
+
}
|
|
12536
|
+
/**
|
|
12537
|
+
* Assemble the `pg_dumpall --globals-only` argument vector. Roles (and other
|
|
12538
|
+
* cluster-wide objects) live outside any single database, so a per-database
|
|
12539
|
+
* `pg_dump` omits them. Without the `rebase_user` role the RLS GRANT
|
|
12540
|
+
* statements in the main dump fail on restore and RLS is silently lost — so
|
|
12541
|
+
* every backup captures the globals into a sidecar `.globals.sql`.
|
|
12542
|
+
*
|
|
12543
|
+
* `--no-role-passwords` keeps role secrets out of the artifact (backups may
|
|
12544
|
+
* be shipped off-box); roles are recreated password-less and re-secured by
|
|
12545
|
+
* the operator.
|
|
12546
|
+
*/
|
|
12547
|
+
function buildPgDumpallGlobalsArgs(opts) {
|
|
12548
|
+
return [
|
|
12549
|
+
"--globals-only",
|
|
12550
|
+
"--no-role-passwords",
|
|
12551
|
+
"--no-password",
|
|
12552
|
+
`--file=${opts.outFile}`,
|
|
12553
|
+
`--dbname=${opts.connectionString}`
|
|
12554
|
+
];
|
|
12555
|
+
}
|
|
12556
|
+
/**
|
|
12557
|
+
* Derive the globals sidecar path/key for a given `.dump` file. Keeps the
|
|
12558
|
+
* two artifacts adjacent so listing, uploading and pruning can find one from
|
|
12559
|
+
* the other. A name that doesn't end in `.dump` is returned unchanged with a
|
|
12560
|
+
* `.globals.sql` suffix appended.
|
|
12561
|
+
*/
|
|
12562
|
+
function globalsFileForDump(dumpPath) {
|
|
12563
|
+
return dumpPath.endsWith(".dump") ? dumpPath.slice(0, -5) + ".globals.sql" : dumpPath + ".globals.sql";
|
|
12564
|
+
}
|
|
12565
|
+
/**
|
|
12566
|
+
* Split a `pg_dumpall --globals-only` script into individual statements.
|
|
12567
|
+
* Used when replaying globals on restore so each `CREATE ROLE` / `GRANT`
|
|
12568
|
+
* can run independently and a benign "role already exists" on one doesn't
|
|
12569
|
+
* abort the rest. Drops `--` comment lines and blank statements.
|
|
12570
|
+
*/
|
|
12571
|
+
function splitGlobalsStatements(sql) {
|
|
12572
|
+
return sql.split("\n").filter((line) => !line.trim().startsWith("--")).join("\n").split(";").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
12573
|
+
}
|
|
12574
|
+
/**
|
|
12509
12575
|
* Resolve the Postgres connection string the backup commands should use,
|
|
12510
12576
|
* mirroring the precedence the branch command already relies on.
|
|
12511
12577
|
*/
|
|
@@ -20794,6 +20860,58 @@ function resolveLocalBin(binName) {
|
|
|
20794
20860
|
return null;
|
|
20795
20861
|
}
|
|
20796
20862
|
//#endregion
|
|
20863
|
+
//#region src/backup/backup-logic.ts
|
|
20864
|
+
/**
|
|
20865
|
+
* Pure-ish orchestration for the backup/restore paths, kept free of `execa`
|
|
20866
|
+
* and `pg` value imports so it can be unit-tested under jest (the impure
|
|
20867
|
+
* edges — spawning processes, opening connections — live in
|
|
20868
|
+
* `backup-service.ts`, which is vitest/runtime only).
|
|
20869
|
+
*
|
|
20870
|
+
* The functions here take their side-effecting dependency as an argument
|
|
20871
|
+
* (a statement runner, an object deleter) so tests can inject fakes.
|
|
20872
|
+
*/
|
|
20873
|
+
/**
|
|
20874
|
+
* Replay a `pg_dumpall --globals-only` script one statement at a time,
|
|
20875
|
+
* tolerating per-statement failures. On a same-cluster restore the roles
|
|
20876
|
+
* usually already exist (`CREATE ROLE` → "already exists") and on a managed
|
|
20877
|
+
* provider an `ALTER ROLE <superuser>` may be refused; neither should abort
|
|
20878
|
+
* recreation of the roles that *are* missing. Returns how many statements
|
|
20879
|
+
* applied vs were skipped.
|
|
20880
|
+
*
|
|
20881
|
+
* `runStatement` executes one SQL statement and rejects on error.
|
|
20882
|
+
*/
|
|
20883
|
+
async function applyGlobalsWith(runStatement, globalsSql, log = () => {}) {
|
|
20884
|
+
let applied = 0;
|
|
20885
|
+
let skipped = 0;
|
|
20886
|
+
for (const statement of splitGlobalsStatements(globalsSql)) try {
|
|
20887
|
+
await runStatement(statement);
|
|
20888
|
+
applied++;
|
|
20889
|
+
} catch (err) {
|
|
20890
|
+
skipped++;
|
|
20891
|
+
log(` • Skipped global: ${statement.split("\n")[0].slice(0, 80)} (${err instanceof Error ? err.message : String(err)})`);
|
|
20892
|
+
}
|
|
20893
|
+
return {
|
|
20894
|
+
applied,
|
|
20895
|
+
skipped
|
|
20896
|
+
};
|
|
20897
|
+
}
|
|
20898
|
+
/**
|
|
20899
|
+
* Delete each pruned dump together with its `.globals.sql` roles sidecar, so
|
|
20900
|
+
* pruning never orphans the roles file. The sidecar is best-effort — older
|
|
20901
|
+
* backups predate it, so a missing-sidecar failure is swallowed while a
|
|
20902
|
+
* failure deleting the dump itself propagates.
|
|
20903
|
+
*
|
|
20904
|
+
* `deleteObject` removes one key and rejects if it cannot (e.g. not found).
|
|
20905
|
+
*/
|
|
20906
|
+
async function pruneWith(keys, deleteObject) {
|
|
20907
|
+
for (const key of keys) {
|
|
20908
|
+
await deleteObject(key);
|
|
20909
|
+
try {
|
|
20910
|
+
await deleteObject(globalsFileForDump(key));
|
|
20911
|
+
} catch {}
|
|
20912
|
+
}
|
|
20913
|
+
}
|
|
20914
|
+
//#endregion
|
|
20797
20915
|
//#region src/backup/backup-service.ts
|
|
20798
20916
|
/**
|
|
20799
20917
|
* Backup / restore orchestration: thin, well-typed wrappers around
|
|
@@ -20806,6 +20924,7 @@ function resolveLocalBin(binName) {
|
|
|
20806
20924
|
*/
|
|
20807
20925
|
var backup_service_exports = /* @__PURE__ */ __exportAll({
|
|
20808
20926
|
BackupToolError: () => BackupToolError,
|
|
20927
|
+
applyGlobals: () => applyGlobals,
|
|
20809
20928
|
createDump: () => createDump,
|
|
20810
20929
|
detectToolMajor: () => detectToolMajor,
|
|
20811
20930
|
ensureDatabaseExists: () => ensureDatabaseExists,
|
|
@@ -20815,7 +20934,8 @@ var backup_service_exports = /* @__PURE__ */ __exportAll({
|
|
|
20815
20934
|
pruneBackups: () => pruneBackups,
|
|
20816
20935
|
resolvePgBinary: () => resolvePgBinary,
|
|
20817
20936
|
restoreDump: () => restoreDump,
|
|
20818
|
-
uploadBackup: () => uploadBackup
|
|
20937
|
+
uploadBackup: () => uploadBackup,
|
|
20938
|
+
validateDump: () => validateDump
|
|
20819
20939
|
});
|
|
20820
20940
|
var BackupToolError = class extends Error {
|
|
20821
20941
|
hint;
|
|
@@ -20825,10 +20945,10 @@ var BackupToolError = class extends Error {
|
|
|
20825
20945
|
this.name = "BackupToolError";
|
|
20826
20946
|
}
|
|
20827
20947
|
};
|
|
20828
|
-
/** Locate `pg_dump` / `pg_restore`, honouring an env override. */
|
|
20948
|
+
/** Locate `pg_dump` / `pg_restore` / `pg_dumpall`, honouring an env override. */
|
|
20829
20949
|
function resolvePgBinary(tool, env = process.env) {
|
|
20830
|
-
const
|
|
20831
|
-
if (
|
|
20950
|
+
const overrideVar = tool === "pg_dump" ? env.PG_DUMP_PATH : tool === "pg_restore" ? env.PG_RESTORE_PATH : env.PG_DUMPALL_PATH;
|
|
20951
|
+
if (overrideVar && fs.existsSync(overrideVar)) return overrideVar;
|
|
20832
20952
|
return resolveLocalBin(tool);
|
|
20833
20953
|
}
|
|
20834
20954
|
/** Run `<bin> --version` and extract the major version. */
|
|
@@ -20872,6 +20992,11 @@ async function preflight(tool, connectionString, env = process.env) {
|
|
|
20872
20992
|
* Produce a custom-format dump on local disk. When `outDir` is omitted the
|
|
20873
20993
|
* file is written to the OS temp directory (used by the upload path, which
|
|
20874
20994
|
* cleans it up afterwards).
|
|
20995
|
+
*
|
|
20996
|
+
* Alongside the `-Fc` dump it writes a `<name>.globals.sql` sidecar via
|
|
20997
|
+
* `pg_dumpall --globals-only` so the roles the dump's GRANT/RLS statements
|
|
20998
|
+
* depend on can be recreated on restore. Set `includeGlobals: false` to skip
|
|
20999
|
+
* it (e.g. when the caller has no privilege to read cluster globals).
|
|
20875
21000
|
*/
|
|
20876
21001
|
async function createDump(opts) {
|
|
20877
21002
|
const env = opts.env ?? process.env;
|
|
@@ -20890,17 +21015,92 @@ async function createDump(opts) {
|
|
|
20890
21015
|
stdio: opts.inheritStdio ? "inherit" : "pipe",
|
|
20891
21016
|
env: { ...env }
|
|
20892
21017
|
});
|
|
20893
|
-
|
|
21018
|
+
const result = {
|
|
20894
21019
|
localFile,
|
|
20895
21020
|
fileName,
|
|
20896
21021
|
sizeBytes: fs.existsSync(localFile) ? fs.statSync(localFile).size : 0
|
|
20897
21022
|
};
|
|
21023
|
+
if (opts.includeGlobals !== false) {
|
|
21024
|
+
const dumpallBin = resolvePgBinary("pg_dumpall", env);
|
|
21025
|
+
if (!dumpallBin) throw new BackupToolError("Could not find the 'pg_dumpall' binary needed to capture cluster roles.", "Install the PostgreSQL client tools or set PG_DUMPALL_PATH. To take a role-incomplete backup anyway, pass includeGlobals: false.");
|
|
21026
|
+
const globalsFile = globalsFileForDump(localFile);
|
|
21027
|
+
await execa(dumpallBin, buildPgDumpallGlobalsArgs({
|
|
21028
|
+
connectionString: opts.connectionString,
|
|
21029
|
+
outFile: globalsFile
|
|
21030
|
+
}), {
|
|
21031
|
+
stdio: opts.inheritStdio ? "inherit" : "pipe",
|
|
21032
|
+
env: { ...env }
|
|
21033
|
+
});
|
|
21034
|
+
result.globalsFile = globalsFile;
|
|
21035
|
+
result.globalsSizeBytes = fs.existsSync(globalsFile) ? fs.statSync(globalsFile).size : 0;
|
|
21036
|
+
}
|
|
21037
|
+
return result;
|
|
21038
|
+
}
|
|
21039
|
+
/**
|
|
21040
|
+
* Cheap integrity check on a freshly written dump: it must be non-empty and
|
|
21041
|
+
* `pg_restore --list` must parse its table of contents without error. Used
|
|
21042
|
+
* before pruning older backups so a corrupt-but-exit-0 dump never becomes
|
|
21043
|
+
* the reason the last good backup is deleted.
|
|
21044
|
+
*/
|
|
21045
|
+
async function validateDump(localFile, env = process.env) {
|
|
21046
|
+
if (!fs.existsSync(localFile)) return {
|
|
21047
|
+
ok: false,
|
|
21048
|
+
reason: `Dump file does not exist: ${localFile}`
|
|
21049
|
+
};
|
|
21050
|
+
if (fs.statSync(localFile).size === 0) return {
|
|
21051
|
+
ok: false,
|
|
21052
|
+
reason: "Dump file is empty (0 bytes)."
|
|
21053
|
+
};
|
|
21054
|
+
const bin = resolvePgBinary("pg_restore", env);
|
|
21055
|
+
if (!bin) return {
|
|
21056
|
+
ok: false,
|
|
21057
|
+
reason: "Could not find 'pg_restore' to verify the dump."
|
|
21058
|
+
};
|
|
21059
|
+
try {
|
|
21060
|
+
await execa(bin, buildPgRestoreListArgs(localFile), {
|
|
21061
|
+
stdio: "pipe",
|
|
21062
|
+
env: { ...env }
|
|
21063
|
+
});
|
|
21064
|
+
return { ok: true };
|
|
21065
|
+
} catch (err) {
|
|
21066
|
+
return {
|
|
21067
|
+
ok: false,
|
|
21068
|
+
reason: `pg_restore --list failed: ${err instanceof Error ? err.message : String(err)}`
|
|
21069
|
+
};
|
|
21070
|
+
}
|
|
21071
|
+
}
|
|
21072
|
+
/**
|
|
21073
|
+
* Replay a `pg_dumpall --globals-only` script to recreate cluster roles
|
|
21074
|
+
* before a restore, so the dump's GRANT/RLS statements (which reference
|
|
21075
|
+
* `rebase_user` and any owner roles) actually apply. Runs statement by
|
|
21076
|
+
* statement and tolerates per-statement failures — on a same-cluster restore
|
|
21077
|
+
* the roles usually already exist (`CREATE ROLE` → "already exists"), and on
|
|
21078
|
+
* a managed provider an `ALTER ROLE <superuser>` may be refused; neither
|
|
21079
|
+
* should abort role recreation. Returns how many statements applied vs were
|
|
21080
|
+
* skipped.
|
|
21081
|
+
*/
|
|
21082
|
+
async function applyGlobals(connectionString, globalsSql, log = () => {}) {
|
|
21083
|
+
const { Client } = await import("pg");
|
|
21084
|
+
const client = new Client({ connectionString });
|
|
21085
|
+
await client.connect();
|
|
21086
|
+
try {
|
|
21087
|
+
return await applyGlobalsWith(async (sql) => {
|
|
21088
|
+
await client.query(sql);
|
|
21089
|
+
}, globalsSql, log);
|
|
21090
|
+
} finally {
|
|
21091
|
+
await client.end();
|
|
21092
|
+
}
|
|
20898
21093
|
}
|
|
20899
21094
|
/**
|
|
20900
21095
|
* Restore a custom-format dump into the database named by
|
|
20901
21096
|
* `connectionString`. Destructive when `clean` is set (drops objects
|
|
20902
21097
|
* first). Never called automatically — the CLI gates it behind explicit
|
|
20903
21098
|
* confirmation.
|
|
21099
|
+
*
|
|
21100
|
+
* Runs with `--exit-on-error` by default: a restore that logs-and-continues
|
|
21101
|
+
* past a failed GRANT (because a role was missing) reports success with RLS
|
|
21102
|
+
* un-enforced. Callers should recreate roles first (see {@link applyGlobals})
|
|
21103
|
+
* and only set `exitOnError: false` deliberately.
|
|
20904
21104
|
*/
|
|
20905
21105
|
async function restoreDump(opts) {
|
|
20906
21106
|
const env = opts.env ?? process.env;
|
|
@@ -20910,7 +21110,8 @@ async function restoreDump(opts) {
|
|
|
20910
21110
|
connectionString: opts.connectionString,
|
|
20911
21111
|
inputFile: opts.inputFile,
|
|
20912
21112
|
clean: opts.clean,
|
|
20913
|
-
noOwner: opts.noOwner
|
|
21113
|
+
noOwner: opts.noOwner,
|
|
21114
|
+
exitOnError: opts.exitOnError
|
|
20914
21115
|
}), {
|
|
20915
21116
|
stdio: opts.inheritStdio ? "inherit" : "pipe",
|
|
20916
21117
|
env: { ...env }
|
|
@@ -20987,9 +21188,13 @@ async function listBackups(dest, storage) {
|
|
|
20987
21188
|
*/
|
|
20988
21189
|
async function pruneBackups(dest, options, storage) {
|
|
20989
21190
|
const toDelete = selectBackupsToPrune(await listBackups(dest, storage), options);
|
|
20990
|
-
|
|
20991
|
-
if (fs.existsSync(key))
|
|
20992
|
-
|
|
21191
|
+
await pruneWith(toDelete, dest.kind === "local" ? async (key) => {
|
|
21192
|
+
if (!fs.existsSync(key)) throw new Error(`not found: ${key}`);
|
|
21193
|
+
fs.unlinkSync(key);
|
|
21194
|
+
} : async (key) => {
|
|
21195
|
+
if (!storage) throw new BackupToolError("Storage backend required to prune object backups.");
|
|
21196
|
+
await storage.deleteObject(key, dest.bucket);
|
|
21197
|
+
});
|
|
20993
21198
|
return toDelete;
|
|
20994
21199
|
}
|
|
20995
21200
|
//#endregion
|
|
@@ -21054,7 +21259,7 @@ function createBackupCron(config) {
|
|
|
21054
21259
|
enabled: config.enabled ?? true,
|
|
21055
21260
|
timeoutSeconds: 3600,
|
|
21056
21261
|
async handler({ log }) {
|
|
21057
|
-
const { createDump, pruneBackups, uploadBackup } = await Promise.resolve().then(() => backup_service_exports);
|
|
21262
|
+
const { createDump, pruneBackups, uploadBackup, validateDump } = await Promise.resolve().then(() => backup_service_exports);
|
|
21058
21263
|
const { destination } = config;
|
|
21059
21264
|
if (destination.kind !== "local" && !config.storage) throw new Error(`Backup destination is ${destination.kind} but no storage controller was provided. Pass the backend's configured StorageController to createBackupCron({ storage }).`);
|
|
21060
21265
|
log(`Starting backup of "${dbName}"…`);
|
|
@@ -21066,15 +21271,23 @@ function createBackupCron(config) {
|
|
|
21066
21271
|
excludeSchemas
|
|
21067
21272
|
});
|
|
21068
21273
|
log(`Dump created: ${dump.fileName} (${formatBytes(dump.sizeBytes)})`);
|
|
21274
|
+
const check = await validateDump(dump.localFile);
|
|
21275
|
+
if (!check.ok) {
|
|
21276
|
+
if (destination.kind !== "local" && fs.existsSync(dump.localFile)) fs.unlinkSync(dump.localFile);
|
|
21277
|
+
if (dump.globalsFile && destination.kind !== "local" && fs.existsSync(dump.globalsFile)) fs.unlinkSync(dump.globalsFile);
|
|
21278
|
+
throw new Error(`New backup failed validation — skipping upload and pruning to protect existing backups. ${check.reason}`);
|
|
21279
|
+
}
|
|
21069
21280
|
let storedKey = dump.localFile;
|
|
21070
21281
|
try {
|
|
21071
21282
|
if (destination.kind !== "local") {
|
|
21072
21283
|
const uploaded = await uploadBackup(config.storage, dump.localFile, destination);
|
|
21073
21284
|
storedKey = uploaded.storageUrl;
|
|
21074
21285
|
log(`Uploaded to ${uploaded.storageUrl}`);
|
|
21286
|
+
if (dump.globalsFile && fs.existsSync(dump.globalsFile)) log(`Uploaded roles sidecar to ${(await uploadBackup(config.storage, dump.globalsFile, destination)).storageUrl}`);
|
|
21075
21287
|
}
|
|
21076
21288
|
} finally {
|
|
21077
21289
|
if (destination.kind !== "local" && fs.existsSync(dump.localFile)) fs.unlinkSync(dump.localFile);
|
|
21290
|
+
if (dump.globalsFile && destination.kind !== "local" && fs.existsSync(dump.globalsFile)) fs.unlinkSync(dump.globalsFile);
|
|
21078
21291
|
}
|
|
21079
21292
|
let pruned = [];
|
|
21080
21293
|
if (config.retentionDays && config.retentionDays > 0) {
|
|
@@ -23657,6 +23870,6 @@ function createPostgresAdapter(pgConfig) {
|
|
|
23657
23870
|
};
|
|
23658
23871
|
}
|
|
23659
23872
|
//#endregion
|
|
23660
|
-
export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgRestoreArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, guardPoolAgainstDirtyRelease, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, withDatabaseName };
|
|
23873
|
+
export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, globalsFileForDump, guardPoolAgainstDirtyRelease, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
|
|
23661
23874
|
|
|
23662
23875
|
//# sourceMappingURL=index.es.js.map
|