@rebasepro/server-postgres 0.15.0 → 0.16.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.
- package/dist/PostgresBackendDriver.d.ts +1 -1
- package/dist/PostgresBootstrapper.d.ts +0 -11
- package/dist/auth/services.d.ts +2 -2
- package/dist/{auth-users-columns-JJ8ngvy5.js → auth-users-columns-CgyPWQ18.js} +3 -3
- package/dist/auth-users-columns-CgyPWQ18.js.map +1 -0
- package/dist/backup/backup-logic.d.ts +20 -0
- package/dist/backup/backup-service.d.ts +6 -0
- package/dist/backup/retention.d.ts +11 -0
- package/dist/{backup-service-czK-OAuG.js → backup-service-BZoixhVl.js} +56 -11
- package/dist/backup-service-BZoixhVl.js.map +1 -0
- package/dist/collections-schema-version-BMeu3cgv.js +81 -0
- package/dist/collections-schema-version-BMeu3cgv.js.map +1 -0
- package/dist/{ensure-collection-policies-D5PtQLyR.js → ensure-collection-policies-BVFb2olB.js} +4 -3
- package/dist/ensure-collection-policies-BVFb2olB.js.map +1 -0
- package/dist/{ensure-collection-tables-BHUjQ-z4.js → ensure-collection-tables-BY1pHRD_.js} +2 -2
- package/dist/{ensure-collection-tables-BHUjQ-z4.js.map → ensure-collection-tables-BY1pHRD_.js.map} +1 -1
- package/dist/index.es.js +65 -21
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-enforcement-BDBfuTD4.js → rls-enforcement-Ch0T6OwW.js} +7 -7
- package/dist/rls-enforcement-Ch0T6OwW.js.map +1 -0
- package/dist/schema/collections-schema-version.d.ts +32 -0
- package/dist/security/policy-drift.d.ts +2 -2
- package/dist/security/rls-enforcement.d.ts +6 -6
- package/dist/services/realtimeService.d.ts +3 -1
- package/dist/src-BBFsDaeA.js.map +1 -1
- package/dist/websocket-BVgDVO-V.js.map +1 -1
- package/package.json +6 -6
- package/src/PostgresAdapter.ts +14 -0
- package/src/PostgresBackendDriver.ts +3 -3
- package/src/PostgresBootstrapper.ts +82 -12
- package/src/auth/services.ts +2 -2
- package/src/backup/backup-cli.ts +49 -12
- package/src/backup/backup-logic.ts +31 -0
- package/src/backup/backup-service.ts +42 -8
- package/src/backup/pg-tools.ts +12 -1
- package/src/backup/retention.ts +11 -0
- package/src/schema/collections-schema-version.ts +103 -0
- package/src/security/policy-drift.test.ts +25 -6
- package/src/security/policy-drift.ts +14 -6
- package/src/security/rls-enforcement.ts +7 -7
- package/src/services/realtimeService.ts +9 -1
- package/dist/auth-users-columns-JJ8ngvy5.js.map +0 -1
- package/dist/backup-service-czK-OAuG.js.map +0 -1
- package/dist/ensure-collection-policies-D5PtQLyR.js.map +0 -1
- package/dist/rls-enforcement-BDBfuTD4.js.map +0 -1
|
@@ -238,6 +238,16 @@ export function isScaffoldedLocalDatabase(connectionString: string | undefined):
|
|
|
238
238
|
* });
|
|
239
239
|
* ```
|
|
240
240
|
*/
|
|
241
|
+
/**
|
|
242
|
+
* Where the collections schema stamp lives.
|
|
243
|
+
*
|
|
244
|
+
* The runtime's own internal schema, always — unlike the auth stamp, which
|
|
245
|
+
* follows the users collection. `rebase` and `auth` sit outside
|
|
246
|
+
* `introspectionSchema` by construction, so nothing here is ever served as a
|
|
247
|
+
* collection.
|
|
248
|
+
*/
|
|
249
|
+
const SCHEMA_META_SCHEMA = "rebase";
|
|
250
|
+
|
|
241
251
|
export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): BackendBootstrapper {
|
|
242
252
|
// Applied at construction rather than threaded through every read: the
|
|
243
253
|
// condition builder's static methods are reached from call sites that
|
|
@@ -262,6 +272,19 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
|
|
|
262
272
|
* builder, not to `execute(sql.raw(...))` — and every statement these hooks
|
|
263
273
|
* emit is schema-qualified DDL, so neither depends on `search_path`.
|
|
264
274
|
*/
|
|
275
|
+
/**
|
|
276
|
+
* The drizzle handle itself, for the statements that want parameters.
|
|
277
|
+
*
|
|
278
|
+
* `provisioningQueryable` below hands back a `query(text)` shim because the
|
|
279
|
+
* DDL it serves is built as text. The schema stamp writes a *value*, so it
|
|
280
|
+
* wants the parameterised form — building that string by hand would be one
|
|
281
|
+
* more place a quoted literal has to be got right for no benefit.
|
|
282
|
+
*/
|
|
283
|
+
const provisioningDb = (driverResult?: InitializedDriver) => {
|
|
284
|
+
const internals = driverResult?.internals as PostgresDriverInternals | undefined;
|
|
285
|
+
return internals?.db ?? (pgConfig.connection as PostgresDriverInternals["db"] | undefined);
|
|
286
|
+
};
|
|
287
|
+
|
|
265
288
|
const provisioningQueryable = (driverResult?: InitializedDriver) => {
|
|
266
289
|
const internals = driverResult?.internals as PostgresDriverInternals | undefined;
|
|
267
290
|
const db = internals?.db ?? (pgConfig.connection as PostgresDriverInternals["db"] | undefined);
|
|
@@ -287,13 +310,20 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
|
|
|
287
310
|
async initializeDriver(config: unknown): Promise<InitializedDriver> {
|
|
288
311
|
// config is passed from coordinator, we merge it with our internal pgConfig if needed
|
|
289
312
|
// Currently config from init.ts is `{ collections, collectionRegistry, mode }`
|
|
290
|
-
const { collections, collectionRegistry, introspectCollections, baas, schemaProvisioning } = config as {
|
|
313
|
+
const { collections, collectionRegistry, introspectCollections, baas, schemaProvisioning, realtime } = config as {
|
|
291
314
|
collections?: CollectionConfig[];
|
|
292
315
|
collectionRegistry?: unknown;
|
|
293
316
|
introspectCollections?: boolean;
|
|
294
317
|
baas?: { unprotectedTables?: "exclude" | "serve" };
|
|
295
318
|
schemaProvisioning?: { attempted: boolean; reason?: string };
|
|
319
|
+
realtime?: { subscribe: boolean; provision: boolean };
|
|
296
320
|
};
|
|
321
|
+
// Absent means a caller that predates the field, and every one of
|
|
322
|
+
// those is a single process that both serves websockets and owns the
|
|
323
|
+
// schema. Defaulting to false here would silently disable realtime
|
|
324
|
+
// for them — the failure this whole area is prone to.
|
|
325
|
+
const realtimeSubscribes = realtime?.subscribe ?? true;
|
|
326
|
+
const realtimeProvisions = realtime?.provision ?? true;
|
|
297
327
|
// Secure by default: a table with no RLS is not served.
|
|
298
328
|
const unprotectedTables = baas?.unprotectedTables ?? "exclude";
|
|
299
329
|
|
|
@@ -570,7 +600,10 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
|
|
|
570
600
|
// and leaves broadcast on its original fire-and-forget path, so
|
|
571
601
|
// presence-only apps pay nothing for it.
|
|
572
602
|
try {
|
|
573
|
-
await realtimeService.configureChannelHistory(
|
|
603
|
+
await realtimeService.configureChannelHistory(
|
|
604
|
+
pgConfig.realtime?.channels,
|
|
605
|
+
{ provision: realtimeProvisions }
|
|
606
|
+
);
|
|
574
607
|
} catch (err) {
|
|
575
608
|
logger.warn("⚠️ Could not initialize channel history tables — retained channels will not replay", { error: err });
|
|
576
609
|
}
|
|
@@ -622,7 +655,11 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
|
|
|
622
655
|
|
|
623
656
|
// `auto` tries CDC but treats "can't" as a normal outcome (info log);
|
|
624
657
|
// explicit trigger/wal was asked for, so a failure is worth a warning.
|
|
625
|
-
|
|
658
|
+
// A process that consumes nothing needs no capture *started*, and a
|
|
659
|
+
// process that does not own the schema installs no triggers. They
|
|
660
|
+
// come apart: the `api` in a split with an external migration Job
|
|
661
|
+
// subscribes without provisioning, and both answers are correct.
|
|
662
|
+
const wantsCdc = cdcMode !== "off" && (realtimeSubscribes || realtimeProvisions);
|
|
626
663
|
const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
|
|
627
664
|
let cdcEnabled = false;
|
|
628
665
|
let provisionCdcForTables: PostgresDriverInternals["provisionCdcForTables"];
|
|
@@ -664,17 +701,29 @@ table: link.table });
|
|
|
664
701
|
// Provisioning throws only when the connection can't create the
|
|
665
702
|
// trigger function (insufficient privilege); enableCdc throws when
|
|
666
703
|
// the LISTEN connection can't be established. Either → fall back.
|
|
667
|
-
await provisionTriggerCdc(cdcRunSql, cdcTables);
|
|
668
|
-
await realtimeService.enableCdc(directUrl);
|
|
704
|
+
if (realtimeProvisions) await provisionTriggerCdc(cdcRunSql, cdcTables);
|
|
705
|
+
if (realtimeSubscribes) await realtimeService.enableCdc(directUrl);
|
|
669
706
|
cdcEnabled = true;
|
|
670
707
|
// Boot steps that create their own tables (auth) run after
|
|
671
708
|
// this one and use it to instrument what they just created.
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
709
|
+
// Left undefined where this process installs nothing, so a
|
|
710
|
+
// later boot step cannot re-enter the DDL path by the side
|
|
711
|
+
// door — the callers already treat it as optional, because a
|
|
712
|
+
// driver without CDC never sets it either.
|
|
713
|
+
if (realtimeProvisions) {
|
|
714
|
+
provisionCdcForTables = async (tables) => {
|
|
715
|
+
await provisionTriggerCdc(cdcRunSql, tables);
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
// Say which half ran. "All writes now emit realtime events"
|
|
719
|
+
// is a claim about the database and stays true for a process
|
|
720
|
+
// that only installed the triggers; what changes is whether
|
|
721
|
+
// *this* process is listening, and an operator reading one
|
|
722
|
+
// pod's log should not have to infer that from its role.
|
|
675
723
|
logger.info(
|
|
676
724
|
`📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). ` +
|
|
677
|
-
`All writes now emit realtime events regardless of origin.`
|
|
725
|
+
`All writes now emit realtime events regardless of origin.` +
|
|
726
|
+
(realtimeSubscribes ? "" : " This process installs the capture but does not consume it.")
|
|
678
727
|
);
|
|
679
728
|
} catch (err) {
|
|
680
729
|
if (explicitCdc) {
|
|
@@ -691,7 +740,7 @@ table: link.table });
|
|
|
691
740
|
|
|
692
741
|
// Legacy cross-instance realtime (app-level). Skipped when CDC is
|
|
693
742
|
// active because CDC already spans instances.
|
|
694
|
-
if (!cdcEnabled && directUrl) {
|
|
743
|
+
if (!cdcEnabled && directUrl && realtimeSubscribes) {
|
|
695
744
|
try {
|
|
696
745
|
await realtimeService.startListening(directUrl);
|
|
697
746
|
} catch (err) {
|
|
@@ -981,8 +1030,8 @@ schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
|
|
|
981
1030
|
*
|
|
982
1031
|
* The companion to {@link ensureCollectionSchema}: that creates the
|
|
983
1032
|
* tables, this makes them servable. Boot runs it *after* auth
|
|
984
|
-
* initialization, because the generated policies call `
|
|
985
|
-
* `
|
|
1033
|
+
* initialization, because the generated policies call `rebase.uid()` /
|
|
1034
|
+
* `rebase.roles()`, and `CREATE POLICY` validates those functions exist.
|
|
986
1035
|
*
|
|
987
1036
|
* Runs through the same drizzle handle, one statement at a time (that
|
|
988
1037
|
* handle speaks the extended query protocol, which rejects multi-command
|
|
@@ -1062,6 +1111,27 @@ schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
|
|
|
1062
1111
|
return { applied: outcome.policiesApplied };
|
|
1063
1112
|
},
|
|
1064
1113
|
|
|
1114
|
+
/**
|
|
1115
|
+
* Read what the last provisioning boot recorded, or `null`.
|
|
1116
|
+
*
|
|
1117
|
+
* The meta schema is `rebase` rather than the auth stamp's — see
|
|
1118
|
+
* `schema/collections-schema-version.ts` for why the two can differ.
|
|
1119
|
+
*/
|
|
1120
|
+
async readCollectionsSchemaVersion(driverResult?: InitializedDriver): Promise<string | null> {
|
|
1121
|
+
const db = provisioningDb(driverResult);
|
|
1122
|
+
if (!db) return null;
|
|
1123
|
+
const { readCollectionsSchemaVersion } = await import("./schema/collections-schema-version");
|
|
1124
|
+
return readCollectionsSchemaVersion(db as never, SCHEMA_META_SCHEMA);
|
|
1125
|
+
},
|
|
1126
|
+
|
|
1127
|
+
/** Record what this process just applied. Only the provisioning process calls this. */
|
|
1128
|
+
async stampCollectionsSchemaVersion(version: string, driverResult?: InitializedDriver): Promise<void> {
|
|
1129
|
+
const db = provisioningDb(driverResult);
|
|
1130
|
+
if (!db) return;
|
|
1131
|
+
const { stampCollectionsSchemaVersion } = await import("./schema/collections-schema-version");
|
|
1132
|
+
await stampCollectionsSchemaVersion(db as never, SCHEMA_META_SCHEMA, version);
|
|
1133
|
+
},
|
|
1134
|
+
|
|
1065
1135
|
getAdmin(driverResult: InitializedDriver): DatabaseAdmin | undefined {
|
|
1066
1136
|
const internals = driverResult.internals as PostgresDriverInternals;
|
|
1067
1137
|
return internals.driver.admin;
|
package/src/auth/services.ts
CHANGED
|
@@ -112,7 +112,7 @@ export class UserService implements UserRepository {
|
|
|
112
112
|
* Run a privileged auth write with an explicitly cleared RLS context.
|
|
113
113
|
*
|
|
114
114
|
* The auth services run on the base/owner connection, which by design
|
|
115
|
-
* carries a NULL `app.uid` so the `
|
|
115
|
+
* carries a NULL `app.uid` so the `rebase.uid() IS NULL` server-escape
|
|
116
116
|
* in the default policies applies. That NULL is normally guaranteed by
|
|
117
117
|
* `set_config(..., is_local = true)` resetting at transaction end — but a
|
|
118
118
|
* GUC that survives on a pooled connection (or a connection role that
|
|
@@ -120,7 +120,7 @@ export class UserService implements UserRepository {
|
|
|
120
120
|
* turns the trusted write into an RLS-scoped one and denies it with
|
|
121
121
|
* SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
|
|
122
122
|
* single chokepoint, makes the server context deterministic instead of
|
|
123
|
-
* trusting whatever state the pool hands us. `
|
|
123
|
+
* trusting whatever state the pool hands us. `rebase.uid()` reads '' as
|
|
124
124
|
* NULL via NULLIF, so '' is the server context.
|
|
125
125
|
*/
|
|
126
126
|
private async withServerContext<T>(fn: (db: NodePgDatabase) => Promise<T>): Promise<T> {
|
package/src/backup/backup-cli.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
applyGlobals,
|
|
26
26
|
BackupToolError,
|
|
27
27
|
createDump,
|
|
28
|
+
discardPartialDump,
|
|
28
29
|
ensureDatabaseExists,
|
|
29
30
|
listBackups,
|
|
30
31
|
preflight,
|
|
@@ -285,18 +286,21 @@ export async function restoreCommand(rawArgs: string[]): Promise<void> {
|
|
|
285
286
|
process.exit(1);
|
|
286
287
|
}
|
|
287
288
|
|
|
288
|
-
//
|
|
289
|
-
if (args["--create-db"]) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
process.exit(1);
|
|
293
|
-
}
|
|
294
|
-
const created = await ensureDatabaseExists(baseConnection, targetDb);
|
|
295
|
-
print(chalk.gray(created ? ` ✓ Created database "${targetDb}".` : ` • Database "${targetDb}" already exists.`));
|
|
289
|
+
// `--create-db` needs a name before anything else can be decided.
|
|
290
|
+
if (args["--create-db"] && !targetDb) {
|
|
291
|
+
outError(chalk.red(" ✗ --create-db requires a resolvable target database name (use --target-db)."));
|
|
292
|
+
process.exit(1);
|
|
296
293
|
}
|
|
297
294
|
|
|
298
295
|
// Destructive-action gate. Restores overwrite data; never run without
|
|
299
296
|
// an explicit yes (interactive confirmation or --yes).
|
|
297
|
+
//
|
|
298
|
+
// Ahead of `--create-db`, not after it. Creating the database first
|
|
299
|
+
// meant an aborted run had already changed the cluster, while printing
|
|
300
|
+
// "No changes were made" — and it left an empty database behind that a
|
|
301
|
+
// second, confirmed run then reported as "already exists". The gate is
|
|
302
|
+
// now the first thing that can stop the command, so its own message is
|
|
303
|
+
// true whichever way the answer goes.
|
|
300
304
|
if (!args["--yes"]) {
|
|
301
305
|
outWarn(chalk.yellow(
|
|
302
306
|
` ⚠️ This will restore into "${targetDb ?? "the target database"}" and may overwrite existing data.`
|
|
@@ -308,6 +312,12 @@ export async function restoreCommand(rawArgs: string[]): Promise<void> {
|
|
|
308
312
|
}
|
|
309
313
|
}
|
|
310
314
|
|
|
315
|
+
// Create the target database when requested.
|
|
316
|
+
if (args["--create-db"]) {
|
|
317
|
+
const created = await ensureDatabaseExists(baseConnection, targetDb!);
|
|
318
|
+
print(chalk.gray(created ? ` ✓ Created database "${targetDb}".` : ` • Database "${targetDb}" already exists.`));
|
|
319
|
+
}
|
|
320
|
+
|
|
311
321
|
// Recreate cluster roles before restoring so GRANT/RLS statements in
|
|
312
322
|
// the dump apply. Best-effort and idempotent (see applyGlobals).
|
|
313
323
|
if (globalsSql) {
|
|
@@ -377,7 +387,23 @@ export async function backupsCommand(rawArgs: string[]): Promise<void> {
|
|
|
377
387
|
for (const b of backups) {
|
|
378
388
|
const when = b.createdAt ? b.createdAt.toISOString() : "unknown date";
|
|
379
389
|
const name = dest.kind === "local" ? path.basename(b.key) : b.key;
|
|
380
|
-
|
|
390
|
+
// An empty file is called out rather than listed as a peer of
|
|
391
|
+
// the real ones. Older failures could leave a 0-byte dump here
|
|
392
|
+
// (fixed at the source now), and retention protects the newest
|
|
393
|
+
// by date whatever they contain — so a corpse left in place can
|
|
394
|
+
// hold a `keepMinimum` slot against a backup that matters.
|
|
395
|
+
const empty = b.sizeBytes === 0;
|
|
396
|
+
const size = b.sizeBytes === undefined ? "" : ` — ${formatBytes(b.sizeBytes)}`;
|
|
397
|
+
if (empty) {
|
|
398
|
+
print(` ${chalk.red("○")} ${chalk.bold(name)} ${chalk.gray(`— ${when}`)}${chalk.red(" — EMPTY, not restorable")}`);
|
|
399
|
+
} else {
|
|
400
|
+
print(` ${chalk.green("●")} ${chalk.bold(name)} ${chalk.gray(`— ${when}${size}`)}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (backups.some(b => b.sizeBytes === 0)) {
|
|
404
|
+
print("");
|
|
405
|
+
print(chalk.yellow(" ⚠ Empty files above are leftovers from a failed backup. Delete them:"));
|
|
406
|
+
print(chalk.gray(" they count as recent backups for retention but restore nothing."));
|
|
381
407
|
}
|
|
382
408
|
}
|
|
383
409
|
print("");
|
|
@@ -387,13 +413,24 @@ export async function backupsCommand(rawArgs: string[]): Promise<void> {
|
|
|
387
413
|
}
|
|
388
414
|
}
|
|
389
415
|
|
|
390
|
-
/**
|
|
416
|
+
/**
|
|
417
|
+
* Verify a freshly written dump; abort the command if it looks corrupt.
|
|
418
|
+
*
|
|
419
|
+
* Discards the artifact on the way out. Refusing to *report* success was never
|
|
420
|
+
* enough on its own: the file stayed on disk, `rebase db backups list` showed
|
|
421
|
+
* it as an ordinary entry, and retention — which ranks by timestamp and never
|
|
422
|
+
* looks at size — would protect it as one of the `keepMinimum` newest while
|
|
423
|
+
* pruning a real backup underneath it. The roles sidecar goes too; the two are
|
|
424
|
+
* uploaded and pruned as a pair, so half a pair is not a backup either.
|
|
425
|
+
*/
|
|
391
426
|
async function assertDumpValid(localFile: string): Promise<void> {
|
|
392
427
|
const check = await validateDump(localFile);
|
|
393
428
|
if (!check.ok) {
|
|
429
|
+
discardPartialDump(globalsFileForDump(localFile));
|
|
430
|
+
discardPartialDump(localFile);
|
|
394
431
|
throw new BackupToolError(
|
|
395
|
-
`The backup failed validation and was
|
|
396
|
-
"The dump
|
|
432
|
+
`The backup failed validation and was discarded: ${check.reason}`,
|
|
433
|
+
"The dump was corrupt or truncated, so nothing was kept. Investigate before relying on this destination."
|
|
397
434
|
);
|
|
398
435
|
}
|
|
399
436
|
}
|
|
@@ -60,3 +60,34 @@ export async function pruneWith(
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Remove a dump artifact abandoned by a failed tool run.
|
|
66
|
+
*
|
|
67
|
+
* `pg_dump` creates its `--file=` target before it finishes connecting, so any
|
|
68
|
+
* failure — a URL libpq rejects, a dropped connection, a full disk — leaves a
|
|
69
|
+
* 0-byte file behind. That corpse is not inert: `rebase db backups list` used
|
|
70
|
+
* to show it as an ordinary entry, and `selectBackupsToPrune` ranks by
|
|
71
|
+
* timestamp alone, so it occupies a protected `keepMinimum` slot and can push
|
|
72
|
+
* a real backup out of retention.
|
|
73
|
+
*
|
|
74
|
+
* Best-effort on purpose: the caller is already throwing, and a failed unlink
|
|
75
|
+
* must not replace the real diagnosis with an ENOENT/EPERM from the cleanup.
|
|
76
|
+
*
|
|
77
|
+
* Lives here rather than in `backup-service.ts` for the reason at the top of
|
|
78
|
+
* this file — that module's top-level `execa` import makes it unloadable under
|
|
79
|
+
* jest, so anything placed there cannot be unit-tested.
|
|
80
|
+
*
|
|
81
|
+
* `remove` deletes one file and throws if it cannot; `exists` reports presence.
|
|
82
|
+
*/
|
|
83
|
+
export function discardPartialDumpWith(
|
|
84
|
+
exists: (file: string) => boolean,
|
|
85
|
+
remove: (file: string) => void,
|
|
86
|
+
file: string
|
|
87
|
+
): void {
|
|
88
|
+
try {
|
|
89
|
+
if (exists(file)) remove(file);
|
|
90
|
+
} catch {
|
|
91
|
+
// Ignored: see above.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -32,7 +32,20 @@ import {
|
|
|
32
32
|
VersionCompatibility
|
|
33
33
|
} from "./pg-tools";
|
|
34
34
|
import { BackupObject, RetentionOptions, selectBackupsToPrune } from "./retention";
|
|
35
|
-
import { applyGlobalsWith, pruneWith } from "./backup-logic";
|
|
35
|
+
import { applyGlobalsWith, discardPartialDumpWith, pruneWith } from "./backup-logic";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Remove a dump artifact abandoned by a failed tool run. See
|
|
39
|
+
* {@link discardPartialDumpWith} for why this exists and why the logic lives
|
|
40
|
+
* in the execa-free module.
|
|
41
|
+
*/
|
|
42
|
+
export function discardPartialDump(file: string): void {
|
|
43
|
+
discardPartialDumpWith(
|
|
44
|
+
(f) => fs.existsSync(f),
|
|
45
|
+
(f) => fs.unlinkSync(f),
|
|
46
|
+
file
|
|
47
|
+
);
|
|
48
|
+
}
|
|
36
49
|
|
|
37
50
|
export class BackupToolError extends Error {
|
|
38
51
|
constructor(message: string, readonly hint?: string) {
|
|
@@ -190,6 +203,15 @@ export async function createDump(opts: {
|
|
|
190
203
|
env: dumpEnv
|
|
191
204
|
});
|
|
192
205
|
} catch (error) {
|
|
206
|
+
// pg_dump creates its `--file=` target before it finishes connecting,
|
|
207
|
+
// so any failure here — a URL libpq rejects, a dropped connection, a
|
|
208
|
+
// full disk — leaves a 0-byte file behind that nothing else removed.
|
|
209
|
+
// That corpse is not inert: `rebase db backups list` shows it as an
|
|
210
|
+
// ordinary entry, and `selectBackupsToPrune` ranks by timestamp alone,
|
|
211
|
+
// so it occupies a protected `keepMinimum` slot and can push a real
|
|
212
|
+
// backup out of retention. A missing backup is honest; an empty file
|
|
213
|
+
// that reads as a backup is not.
|
|
214
|
+
discardPartialDump(localFile);
|
|
193
215
|
// The RLS failure names a table and no cause. Replace it with the
|
|
194
216
|
// cause and the two ways out; anything else is re-thrown untouched.
|
|
195
217
|
const diagnosis = diagnoseRowSecurityDumpFailure(error);
|
|
@@ -214,11 +236,22 @@ export async function createDump(opts: {
|
|
|
214
236
|
);
|
|
215
237
|
}
|
|
216
238
|
const globalsFile = globalsFileForDump(localFile);
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
239
|
+
try {
|
|
240
|
+
await execa(
|
|
241
|
+
dumpallBin,
|
|
242
|
+
buildPgDumpallGlobalsArgs({ connectionString: opts.connectionString, outFile: globalsFile }),
|
|
243
|
+
{ stdio: opts.inheritStdio ? "inherit" : "pipe", env: { ...(env as Record<string, string>) } }
|
|
244
|
+
);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
// Same reasoning as the pg_dump catch above, and the dump goes with
|
|
247
|
+
// it: the pair is uploaded and pruned together, so a dump whose
|
|
248
|
+
// roles sidecar is missing restores without the roles its GRANT and
|
|
249
|
+
// RLS statements need — the exact failure the sidecar exists to
|
|
250
|
+
// prevent. Leaving half a pair on disk would look like a backup.
|
|
251
|
+
discardPartialDump(globalsFile);
|
|
252
|
+
discardPartialDump(localFile);
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
222
255
|
result.globalsFile = globalsFile;
|
|
223
256
|
result.globalsSizeBytes = fs.existsSync(globalsFile) ? fs.statSync(globalsFile).size : 0;
|
|
224
257
|
}
|
|
@@ -398,8 +431,9 @@ export async function listBackups(
|
|
|
398
431
|
.filter((f) => f.endsWith(".dump"))
|
|
399
432
|
.map((f) => {
|
|
400
433
|
const full = path.join(dir, f);
|
|
401
|
-
const
|
|
402
|
-
|
|
434
|
+
const stats = fs.statSync(full);
|
|
435
|
+
const createdAt = parseBackupTimestamp(f) ?? stats.mtime;
|
|
436
|
+
return { key: full, createdAt, sizeBytes: stats.size };
|
|
403
437
|
})
|
|
404
438
|
.sort((a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0));
|
|
405
439
|
}
|
package/src/backup/pg-tools.ts
CHANGED
|
@@ -373,7 +373,18 @@ export function globalsFileForDump(dumpPath: string): string {
|
|
|
373
373
|
export function splitGlobalsStatements(sql: string): string[] {
|
|
374
374
|
const withoutComments = sql
|
|
375
375
|
.split("\n")
|
|
376
|
-
.
|
|
376
|
+
// `--` comments, and psql meta-commands. pg_dumpall 15+ wraps its output
|
|
377
|
+
// in `\restrict <token>` / `\unrestrict <token>`, which are instructions
|
|
378
|
+
// to psql, not SQL. This replay sends statements over a connection
|
|
379
|
+
// instead, so they arrived at the server as `\restrict …` and came back
|
|
380
|
+
// as `syntax error at or near "\"` — reported to the user as two skipped
|
|
381
|
+
// globals per restore, which reads like roles failed to apply when
|
|
382
|
+
// nothing did. They carry no state worth replaying: dropping them is
|
|
383
|
+
// exactly what a SQL-level consumer should do.
|
|
384
|
+
.filter((line) => {
|
|
385
|
+
const trimmed = line.trim();
|
|
386
|
+
return !trimmed.startsWith("--") && !trimmed.startsWith("\\");
|
|
387
|
+
})
|
|
377
388
|
.join("\n");
|
|
378
389
|
return withoutComments
|
|
379
390
|
.split(";")
|
package/src/backup/retention.ts
CHANGED
|
@@ -12,6 +12,17 @@ export interface BackupObject {
|
|
|
12
12
|
* timestamp encoded in the key by `buildBackupFilename`.
|
|
13
13
|
*/
|
|
14
14
|
createdAt?: Date;
|
|
15
|
+
/**
|
|
16
|
+
* Size on disk, when the lister knows it (local destinations always do).
|
|
17
|
+
*
|
|
18
|
+
* Reported so an empty artifact cannot masquerade as a backup in
|
|
19
|
+
* `rebase db backups list`. Deliberately NOT consulted by
|
|
20
|
+
* {@link selectBackupsToPrune}: retention decides by age, and a rule that
|
|
21
|
+
* silently skipped small files would make a genuinely tiny dump immortal.
|
|
22
|
+
* Visibility is the fix here; the artifacts themselves are now removed at
|
|
23
|
+
* the point of failure by `discardPartialDump`.
|
|
24
|
+
*/
|
|
25
|
+
sizeBytes?: number;
|
|
15
26
|
}
|
|
16
27
|
|
|
17
28
|
export interface RetentionOptions {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The collections schema version, stamped into the database.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately the same table shape, the same key/value convention and the same
|
|
5
|
+
* "unstamped is null, and null is not an error" rule as `auth/schema-version.ts`.
|
|
6
|
+
* They answer the same kind of question — "was this database provisioned by
|
|
7
|
+
* something that agrees with me" — and inventing a second convention for the
|
|
8
|
+
* second one would be a second thing to find, back up and reason about.
|
|
9
|
+
*
|
|
10
|
+
* It does *not* follow the auth stamp's schema. `resolveAuthSchema` tracks the
|
|
11
|
+
* users collection, so a project that puts its users in a custom schema moves
|
|
12
|
+
* the auth stamp with them — correct there, because that stamp describes those
|
|
13
|
+
* tables. This one describes the collections as a whole and belongs with the
|
|
14
|
+
* runtime's own internal state, which is always `rebase`. Usually they land in
|
|
15
|
+
* the same table anyway; when they do not, that is the reason.
|
|
16
|
+
*
|
|
17
|
+
* The value is opaque here on purpose. It is a hash produced by
|
|
18
|
+
* `computeSchemaVersion` in `@rebasepro/types`, and this module's only job is to
|
|
19
|
+
* put a string in and take the same string out. Comparing them, and deciding
|
|
20
|
+
* what a difference means, is `boot/schema-stamp.ts` in the runtime — where it
|
|
21
|
+
* can be tested without a database.
|
|
22
|
+
*
|
|
23
|
+
* Unlike the auth version, this is **not** ordered. The auth stamp is an integer
|
|
24
|
+
* whose comparison direction carries meaning (a database newer than the runtime
|
|
25
|
+
* is unrecoverable, older is the ordinary upgrade path). A schema hash has no
|
|
26
|
+
* order at all: it can say the two disagree and never which is ahead. That is a
|
|
27
|
+
* deliberate limit, and the reason the runtime's response to a mismatch is to
|
|
28
|
+
* describe it rather than to decide who is wrong.
|
|
29
|
+
*/
|
|
30
|
+
import { sql } from "drizzle-orm";
|
|
31
|
+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
32
|
+
|
|
33
|
+
/** Key under which the version is stored in the meta table. */
|
|
34
|
+
const VERSION_KEY = "collections_schema_version";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Anything that can run SQL for us.
|
|
38
|
+
*
|
|
39
|
+
* Narrower than `NodePgDatabase` so the provisioning handle — which is what the
|
|
40
|
+
* boot path actually has in hand — satisfies it without a cast at every call
|
|
41
|
+
* site.
|
|
42
|
+
*/
|
|
43
|
+
export interface SchemaMetaQueryable {
|
|
44
|
+
execute(query: unknown): Promise<{ rows: Record<string, unknown>[] }>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read the stamped version, or `null` when this database has never been stamped.
|
|
49
|
+
*
|
|
50
|
+
* `to_regclass` rather than a plain `SELECT` so a missing schema or table is a
|
|
51
|
+
* `null` instead of a thrown 42P01 — the overwhelmingly common case on a fresh
|
|
52
|
+
* database is that neither exists yet, and that is not news.
|
|
53
|
+
*/
|
|
54
|
+
export async function readCollectionsSchemaVersion(
|
|
55
|
+
db: SchemaMetaQueryable | NodePgDatabase,
|
|
56
|
+
metaSchema: string
|
|
57
|
+
): Promise<string | null> {
|
|
58
|
+
const qualified = `"${metaSchema}"."schema_meta"`;
|
|
59
|
+
const exists = await (db as SchemaMetaQueryable).execute(
|
|
60
|
+
sql`SELECT to_regclass(${qualified}) IS NOT NULL AS present`
|
|
61
|
+
);
|
|
62
|
+
if (!(exists.rows[0] as { present: boolean } | undefined)?.present) return null;
|
|
63
|
+
|
|
64
|
+
const result = await (db as SchemaMetaQueryable).execute(sql`
|
|
65
|
+
SELECT value FROM ${sql.raw(qualified)} WHERE key = ${VERSION_KEY}
|
|
66
|
+
`);
|
|
67
|
+
const raw = (result.rows[0] as { value: string } | undefined)?.value;
|
|
68
|
+
|
|
69
|
+
// An empty string is not a version anybody computed, and treating it as one
|
|
70
|
+
// would make every process disagree with the database forever. Unstamped.
|
|
71
|
+
return raw === undefined || raw.trim() === "" ? null : raw;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Record the version this runtime just applied.
|
|
76
|
+
*
|
|
77
|
+
* Creates the meta table if the auth stamp has not already — the two are
|
|
78
|
+
* independent halves of one boot and either may run first, so neither can assume
|
|
79
|
+
* the table is there. `CREATE TABLE IF NOT EXISTS` is not atomic against a
|
|
80
|
+
* concurrent identical statement, which is exactly the race a split deployment
|
|
81
|
+
* arranges; it is tolerated here for the same reason it is elsewhere in the boot
|
|
82
|
+
* path, and the caller treats any failure as a warning rather than a fatal.
|
|
83
|
+
*/
|
|
84
|
+
export async function stampCollectionsSchemaVersion(
|
|
85
|
+
db: SchemaMetaQueryable | NodePgDatabase,
|
|
86
|
+
metaSchema: string,
|
|
87
|
+
version: string
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
const qualified = `"${metaSchema}"."schema_meta"`;
|
|
90
|
+
await (db as SchemaMetaQueryable).execute(sql`CREATE SCHEMA IF NOT EXISTS ${sql.identifier(metaSchema)}`);
|
|
91
|
+
await (db as SchemaMetaQueryable).execute(sql`
|
|
92
|
+
CREATE TABLE IF NOT EXISTS ${sql.raw(qualified)} (
|
|
93
|
+
key TEXT PRIMARY KEY,
|
|
94
|
+
value TEXT NOT NULL,
|
|
95
|
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
|
96
|
+
)
|
|
97
|
+
`);
|
|
98
|
+
await (db as SchemaMetaQueryable).execute(sql`
|
|
99
|
+
INSERT INTO ${sql.raw(qualified)} (key, value)
|
|
100
|
+
VALUES (${VERSION_KEY}, ${version})
|
|
101
|
+
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
|
102
|
+
`);
|
|
103
|
+
}
|
|
@@ -244,15 +244,34 @@ describe("checkPolicyDrift", () => {
|
|
|
244
244
|
expect(formatPolicyDrift(drift)).toContain("db push");
|
|
245
245
|
});
|
|
246
246
|
|
|
247
|
+
it("flags the tautology under the post-1.0 schema name too", async () => {
|
|
248
|
+
// The helpers moved from `auth` to `rebase` in 1.0, and the compiler
|
|
249
|
+
// rewrites raw `securityRules` SQL on the way in — so `rebase.uid() IS
|
|
250
|
+
// NOT NULL` is the only spelling a current release can store. A checker
|
|
251
|
+
// anchored on the pre-1.0 name saw none of it.
|
|
252
|
+
const cols = [collection("posts")];
|
|
253
|
+
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
254
|
+
const live = expected.map((p) => liveRow(p, {
|
|
255
|
+
qual: p.hasUsing ? "(rebase.uid() IS NOT NULL)" : null
|
|
256
|
+
}));
|
|
257
|
+
|
|
258
|
+
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
259
|
+
|
|
260
|
+
expect(drift.insecure.length).toBeGreaterThan(0);
|
|
261
|
+
expect(drift.diverged).toHaveLength(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
247
264
|
it("clears the corrected expression, in either literal spelling", async () => {
|
|
248
265
|
const cols = [collection("posts")];
|
|
249
266
|
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
250
|
-
for (const
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
267
|
+
for (const fn of ["auth.uid()", "rebase.uid()"]) {
|
|
268
|
+
for (const guard of ["<> 'anonymous'::text", "<> 'anonymous'", "!= 'anonymous'"]) {
|
|
269
|
+
const live = expected.map((p) => liveRow(p, {
|
|
270
|
+
qual: p.hasUsing ? `((${fn} IS NOT NULL) AND ((${fn})::text ${guard}))` : null
|
|
271
|
+
}));
|
|
272
|
+
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
273
|
+
expect(drift.insecure).toHaveLength(0);
|
|
274
|
+
}
|
|
256
275
|
}
|
|
257
276
|
});
|
|
258
277
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* function `db push` uses to write `drizzle/policies.sql`, so this compares
|
|
14
14
|
* against exactly what would be applied rather than a reimplementation.
|
|
15
15
|
*/
|
|
16
|
-
import type
|
|
16
|
+
import { RLS_UID_SQL, type CollectionConfig } from "@rebasepro/types";
|
|
17
17
|
|
|
18
18
|
import { generatePostgresPoliciesDdl } from "../schema/generate-postgres-ddl-logic";
|
|
19
19
|
|
|
@@ -62,7 +62,7 @@ export interface PolicyDrift {
|
|
|
62
62
|
diverged: { expected: PolicyRef; actual: PolicyRef; differences: string[] }[];
|
|
63
63
|
/**
|
|
64
64
|
* A live policy whose expression is the known-permissive tautology
|
|
65
|
-
* `
|
|
65
|
+
* `rebase.uid() IS NOT NULL` — true for anonymous visitors too, because the
|
|
66
66
|
* user path coerces a blank id to the `'anonymous'` sentinel. This is what
|
|
67
67
|
* `policy.authenticated()` used to compile to, so a database pushed before
|
|
68
68
|
* that fix carries it, and neither the name, roles, command nor clause
|
|
@@ -243,18 +243,26 @@ async function readTablesWithRlsOff(
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
/**
|
|
246
|
-
* The permissive tautology `
|
|
246
|
+
* The permissive tautology `rebase.uid() IS NOT NULL`, without the
|
|
247
247
|
* `<> 'anonymous'` guard that makes it mean "signed in".
|
|
248
248
|
*
|
|
249
249
|
* Whitespace varies with Postgres's rewrite, so match on a collapsed form. The
|
|
250
250
|
* guard clause (`<> 'anonymous'`, in any spelling) is what distinguishes the
|
|
251
251
|
* corrected policy from the stale one, so its presence clears the text.
|
|
252
|
+
*
|
|
253
|
+
* Both schema spellings are matched. The helpers moved from `auth` to `rebase`
|
|
254
|
+
* in 1.0, and this check reads policies *as the database stored them*: a
|
|
255
|
+
* database pushed before the move still holds `auth.uid()` until it is
|
|
256
|
+
* recompiled, while everything written since — including raw `securityRules`
|
|
257
|
+
* SQL, which the compiler rewrites on the way in — stores `rebase.uid()`.
|
|
258
|
+
* Matching only the pre-1.0 name made the check blind to every policy a current
|
|
259
|
+
* release could write, which is precisely the set still worth scanning.
|
|
252
260
|
*/
|
|
253
261
|
function isPermissiveAuthTautology(clause: string | null | undefined): boolean {
|
|
254
262
|
if (!clause) return false;
|
|
255
263
|
const flat = clause.toLowerCase().replace(/\s+/g, " ");
|
|
256
|
-
if (
|
|
257
|
-
// The fix appends `AND
|
|
264
|
+
if (!/\b(?:rebase|auth)\.uid\s*\(\s*\)\s*is not null/.test(flat)) return false;
|
|
265
|
+
// The fix appends `AND rebase.uid() <> 'anonymous'`; Postgres may store the
|
|
258
266
|
// literal as `'anonymous'::text`. Either spelling means it is the corrected
|
|
259
267
|
// policy, not the tautology.
|
|
260
268
|
return !/<>\s*'anonymous'/.test(flat) && !/!=\s*'anonymous'/.test(flat);
|
|
@@ -315,7 +323,7 @@ export async function checkPolicyDrift(
|
|
|
315
323
|
if (clause) {
|
|
316
324
|
drift.insecure.push({
|
|
317
325
|
policy: p,
|
|
318
|
-
reason: `${clause} is
|
|
326
|
+
reason: `${clause} is \`${RLS_UID_SQL} IS NOT NULL\`, which is true for anonymous ` +
|
|
319
327
|
`visitors too — this grants access to signed-out requests. It predates the ` +
|
|
320
328
|
`\`policy.authenticated()\` fix; re-run \`rebase db push\` to tighten it.`
|
|
321
329
|
});
|