@rebasepro/server-postgres 0.12.1-canary.gdfba2a1 → 0.12.1-canary.ge1bb178

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.
Files changed (87) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/PostgresBootstrapper.d.ts +25 -1
  3. package/dist/auth/services.d.ts +6 -1
  4. package/dist/backup/backup-service.d.ts +10 -1
  5. package/dist/backup/pg-tools.d.ts +47 -0
  6. package/dist/{backup-service-DH9kPg-E.js → backup-service-CD8o_1Sl.js} +144 -11
  7. package/dist/backup-service-CD8o_1Sl.js.map +1 -0
  8. package/dist/cli-helpers.d.ts +39 -0
  9. package/dist/{connection-B5Wndbr1.js → connection-BuZ97wsr.js} +57 -3
  10. package/dist/connection-BuZ97wsr.js.map +1 -0
  11. package/dist/connection.d.ts +42 -0
  12. package/dist/ensure-collection-policies-BrUVgjz3.js +57 -0
  13. package/dist/ensure-collection-policies-BrUVgjz3.js.map +1 -0
  14. package/dist/ensure-collection-tables-Da2oGkX2.js +650 -0
  15. package/dist/ensure-collection-tables-Da2oGkX2.js.map +1 -0
  16. package/dist/index.es.js +529 -142
  17. package/dist/index.es.js.map +1 -1
  18. package/dist/policy-CeA1JcxP.js +105 -0
  19. package/dist/policy-CeA1JcxP.js.map +1 -0
  20. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  21. package/dist/schema/ensure-collection-tables.d.ts +44 -2
  22. package/dist/schema/generate-postgres-ddl-logic.d.ts +135 -1
  23. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  24. package/dist/schema/introspect-db-logic.d.ts +94 -5
  25. package/dist/schema/introspect-db-queries.d.ts +119 -0
  26. package/dist/schema/introspect-db-structure.d.ts +263 -0
  27. package/dist/schema/introspect-db-types.d.ts +11 -0
  28. package/dist/services/FetchService.d.ts +4 -1
  29. package/dist/services/RelationService.d.ts +24 -1
  30. package/dist/services/channel-bus/index.d.ts +1 -7
  31. package/dist/services/collection-helpers.d.ts +24 -1
  32. package/dist/services/dataService.d.ts +3 -1
  33. package/dist/services/row-pipeline.d.ts +1 -1
  34. package/dist/{src-DihrDFuP.js → src-CzbghKwf.js} +189 -101
  35. package/dist/src-CzbghKwf.js.map +1 -0
  36. package/dist/utils/connection-string.d.ts +29 -0
  37. package/dist/utils/drizzle-conditions.d.ts +5 -4
  38. package/dist/utils/pg-error-utils.d.ts +19 -0
  39. package/dist/{websocket-BKcGvILX.js → websocket-B2LsrINK.js} +8 -6
  40. package/dist/websocket-B2LsrINK.js.map +1 -0
  41. package/package.json +6 -6
  42. package/src/PostgresAdapter.ts +21 -2
  43. package/src/PostgresBackendDriver.ts +4 -0
  44. package/src/PostgresBootstrapper.ts +192 -33
  45. package/src/auth/services.ts +6 -3
  46. package/src/backup/backup-cli.ts +41 -2
  47. package/src/backup/backup-service.ts +38 -5
  48. package/src/backup/pg-tools.ts +96 -3
  49. package/src/cli-helpers.ts +70 -0
  50. package/src/cli.ts +44 -26
  51. package/src/collections/validate-relations.ts +15 -0
  52. package/src/connection.ts +73 -0
  53. package/src/data-transformer.ts +9 -3
  54. package/src/databasePoolManager.ts +5 -2
  55. package/src/schema/ensure-collection-policies.ts +105 -0
  56. package/src/schema/ensure-collection-tables.test.ts +105 -9
  57. package/src/schema/ensure-collection-tables.ts +220 -32
  58. package/src/schema/generate-drizzle-schema-logic.ts +16 -3
  59. package/src/schema/generate-postgres-ddl-logic.ts +375 -16
  60. package/src/schema/introspect-db-constraints.ts +385 -0
  61. package/src/schema/introspect-db-inference.ts +18 -8
  62. package/src/schema/introspect-db-logic.ts +385 -71
  63. package/src/schema/introspect-db-queries.ts +326 -0
  64. package/src/schema/introspect-db-structure.ts +670 -0
  65. package/src/schema/introspect-db-types.ts +56 -0
  66. package/src/schema/introspect-db.ts +37 -80
  67. package/src/schema/introspect-runtime.test.ts +56 -8
  68. package/src/schema/introspect-runtime.ts +31 -9
  69. package/src/security/policy-drift.test.ts +11 -3
  70. package/src/services/FetchService.ts +76 -14
  71. package/src/services/PersistService.ts +20 -6
  72. package/src/services/RelationService.ts +249 -48
  73. package/src/services/channel-bus/index.ts +0 -9
  74. package/src/services/collection-helpers.ts +40 -1
  75. package/src/services/dataService.ts +3 -1
  76. package/src/services/realtimeService.ts +3 -3
  77. package/src/services/row-pipeline.ts +1 -1
  78. package/src/utils/connection-string.ts +58 -0
  79. package/src/utils/drizzle-conditions.ts +31 -6
  80. package/src/utils/pg-error-utils.ts +46 -0
  81. package/src/websocket.ts +18 -9
  82. package/dist/backup-service-DH9kPg-E.js.map +0 -1
  83. package/dist/connection-B5Wndbr1.js.map +0 -1
  84. package/dist/ensure-collection-tables-CIHSH962.js +0 -304
  85. package/dist/ensure-collection-tables-CIHSH962.js.map +0 -1
  86. package/dist/src-DihrDFuP.js.map +0 -1
  87. package/dist/websocket-BKcGvILX.js.map +0 -1
@@ -102,6 +102,40 @@ export interface PostgresDriverInternals {
102
102
 
103
103
  // Re-export from shared CLI error utilities
104
104
  import { isEconnrefused } from "./cli-errors";
105
+ import { classifyConnectFailure } from "./utils/pg-error-utils";
106
+
107
+ /**
108
+ * Which table name the boot-time drift check should look for, for one collection.
109
+ *
110
+ * A declared `table` IS the table name, not a hint to be second-guessed. This
111
+ * used to ask the registry whether it had indexed the declared name and fall
112
+ * back to the SLUG when it had not — but "the registry does not know this table"
113
+ * is exactly the condition the drift check exists to report, so the fallback
114
+ * fired precisely when it was most harmful.
115
+ *
116
+ * A collection with `slug: "usage-daily", table: "usage_daily"` was reported as
117
+ * missing table `usage-daily`: a name that does not exist, should never exist,
118
+ * and that nobody can find by looking. Worse, the remediation the caller prints
119
+ * says to run `rebase db push` — which would then CREATE that invented table
120
+ * beside the correct one, the same "second copy" hazard the misplaced-schema
121
+ * branch further down exists to prevent. Seen in production, where a correctly
122
+ * migrated database reported drift on every boot.
123
+ *
124
+ * The slug is used only when nothing was declared, which is the config shape
125
+ * where the slug genuinely is the table name.
126
+ *
127
+ * Exported for its own test: the caller needs a live pool and a real database,
128
+ * and this is the part that was wrong.
129
+ */
130
+ export function resolveDriftCheckName(
131
+ col: CollectionConfig,
132
+ registeredTableNames: string[]
133
+ ): string {
134
+ const declaredTable = isRelationalCollectionConfig(col) ? col.table : undefined;
135
+ return declaredTable
136
+ ?? registeredTableNames.find((k) => k === col.slug)
137
+ ?? col.slug;
138
+ }
105
139
 
106
140
  /**
107
141
  * Default PostgreSQL bootstrapper.
@@ -260,9 +294,45 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
260
294
  throw new Error(`Cannot connect to PostgreSQL at ${hostInfo}: connection refused. Is the database running?`);
261
295
  }
262
296
 
263
- // For other errors (timeouts, auth failures, etc.) warn but continue —
264
- // the pool may recover on subsequent attempts.
265
- logger.error("❌ Failed to connect to PostgreSQL", { error: err });
297
+ /*
298
+ * Everything else.
299
+ *
300
+ * Two problems with what used to happen here. First, the logged
301
+ * error was Drizzle's wrapper — `Failed query: SELECT 1` and a
302
+ * stack through drizzle internals — while the sentence that
303
+ * actually says what is wrong ("password authentication failed
304
+ * for user …", "database … does not exist") sits in `.cause`
305
+ * and was never printed. A developer with a typo in their
306
+ * DATABASE_URL got two walls of stack trace and no cause.
307
+ *
308
+ * Second, "continuing… the pool may recover" is only true for
309
+ * a transient fault. A wrong password or a missing database is
310
+ * settled: the next query fails the same way, so the process
311
+ * died seconds later anyway — after printing a reassurance.
312
+ * Those now fail here, where the message can be about the
313
+ * cause rather than about whichever query ran next.
314
+ */
315
+ const { fatal, reason, code } = classifyConnectFailure(err);
316
+ const detail = code ? ` [${code}]` : "";
317
+
318
+ if (fatal) {
319
+ logger.error(
320
+ `\n` +
321
+ `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
322
+ ` ❌ PostgreSQL refused the connection\n` +
323
+ `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
324
+ `\n` +
325
+ ` ${reason}${detail}\n` +
326
+ `\n` +
327
+ ` The server is reachable, so this is the credentials or the\n` +
328
+ ` database name in DATABASE_URL — check them in your .env.\n` +
329
+ `\n` +
330
+ `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`
331
+ );
332
+ throw new Error(`PostgreSQL refused the connection: ${reason}${detail}`);
333
+ }
334
+
335
+ logger.error(`❌ Failed to connect to PostgreSQL: ${reason}${detail}`, { error: err });
266
336
  logger.warn("⚠️ Continuing without initial database verification. Drizzle/PG will attempt to connect on subsequent queries.");
267
337
  }
268
338
 
@@ -487,22 +557,28 @@ table: link.table });
487
557
  try {
488
558
  const registeredCollections = registry.getCollections();
489
559
  if (registeredCollections.length > 0) {
490
- const schemasToCheck = Array.from(new Set(
491
- registeredCollections.map(c => "schema" in c && c.schema ? c.schema : "public")
492
- ));
493
- const schemasList = schemasToCheck.map(s => `'${s}'`).join(",");
560
+ // Deliberately unfiltered by schema: a table that is missing
561
+ // from where the collection says it lives is very often
562
+ // sitting in another schema entirely (see the misplaced-table
563
+ // report below), and knowing *which* is the whole answer.
494
564
  const result = await schemaAwareDb.execute(sql.raw(`
495
565
  SELECT table_name, table_schema
496
566
  FROM information_schema.tables
497
- WHERE table_schema IN (${schemasList})
567
+ WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
498
568
  AND table_type = 'BASE TABLE'
499
569
  `));
570
+ const tablesByName = new Map<string, string[]>();
571
+ for (const row of result.rows as Array<{ table_name: string; table_schema: string }>) {
572
+ const schemas = tablesByName.get(row.table_name) ?? [];
573
+ schemas.push(row.table_schema);
574
+ tablesByName.set(row.table_name, schemas);
575
+ }
500
576
  const dbTables = new Set(
501
577
  (result.rows as Array<{ table_name: string; table_schema: string }>).map(r =>
502
578
  r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`
503
579
  )
504
580
  );
505
- const missing: Array<{ slug: string; table: string }> = [];
581
+ const missing: Array<{ slug: string; table: string; foundIn: string[] }> = [];
506
582
  for (const col of registeredCollections) {
507
583
  // Auth owns its table and creates it later in this same
508
584
  // boot (initializeAuth → ensureAuthTablesExist), so it is
@@ -513,41 +589,72 @@ table: link.table });
513
589
  if ((col as { auth?: { enabled?: boolean } }).auth?.enabled) continue;
514
590
 
515
591
  const schemaName = "schema" in col && col.schema ? col.schema : "public";
516
- const declaredTable = isRelationalCollectionConfig(col) ? col.table : undefined;
517
- const tableName = registry.hasTableForCollection(
518
- declaredTable ?? col.slug
519
- )
520
- ? (declaredTable ?? col.slug)
521
- : col.slug;
522
- // Resolve the actual table name the registry stored
523
- const resolvedTable = registry.getTableNames().find((k) =>
524
- k === tableName ||
525
- k === col.slug
526
- );
527
- const checkName = resolvedTable ?? tableName;
592
+ const checkName = resolveDriftCheckName(col, registry.getTableNames());
528
593
  const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
529
594
  if (!dbTables.has(fullCheckName)) {
530
595
  // Report what was actually looked up: an unqualified
531
596
  // "users" sends people hunting for public.users.
532
597
  missing.push({ slug: col.slug,
533
- table: fullCheckName });
598
+ table: fullCheckName,
599
+ foundIn: (tablesByName.get(checkName) ?? []).filter(s => s !== schemaName) });
534
600
  }
535
601
  }
536
602
  if (missing.length > 0) {
537
603
  const lines = missing.map(
538
- m => ` • collection "${m.slug}" → table "${m.table}"`
604
+ m => ` • collection "${m.slug}" → table "${m.table}"` +
605
+ (m.foundIn.length > 0
606
+ ? ` — but a table of that name exists in ${m.foundIn.map(s => `"${s}"`).join(", ")}`
607
+ : "")
539
608
  );
609
+ // A table that exists under the same name in another
610
+ // schema is not ordinary drift: it is almost always this
611
+ // framework's own `search_path` hazard. Postgres resolves
612
+ // unqualified SQL through `"$user", public`, and this
613
+ // project creates a schema named `rebase` while every
614
+ // template names the database role `rebase` too — so an
615
+ // unqualified CREATE TABLE (a hand-written migration, the
616
+ // SQL editor, drizzle-kit) lands in `rebase`, and the
617
+ // runtime, which now pins `search_path=public`, cannot see
618
+ // it. Say so, because "missing table" sends people to
619
+ // re-run a push that will create a *second* copy.
620
+ const misplaced = missing.filter(m => m.foundIn.length > 0);
621
+ const misplacedHelp = misplaced.length === 0 ? [] : [
622
+ " Those tables exist — in the wrong schema. Unqualified SQL run by a",
623
+ " role whose name matches a schema (`rebase` is both, by default)",
624
+ " resolves through search_path's \"$user\" and creates there. Move them:",
625
+ ...misplaced.map(m =>
626
+ ` ALTER TABLE "${m.foundIn[0]}"."${m.table.split(".").pop()}" SET SCHEMA "${m.table.includes(".") ? m.table.split(".")[0] : "public"}";`
627
+ ),
628
+ " and qualify the SQL that created them, or pin search_path in DATABASE_URL",
629
+ " (`?options=-c%20search_path%3Dpublic`).",
630
+ ""
631
+ ];
632
+ // This runtime creates collection tables (and their RLS)
633
+ // at boot unless REBASE_MIGRATE_ON_BOOT=none, so a drift
634
+ // this late means that step was disabled, could not run,
635
+ // or failed — the guidance names a path for both a managed
636
+ // tenant (whose in-cluster database `pnpm db:push` cannot
637
+ // reach) and a self-host. Naming only the pnpm scripts, as
638
+ // this once did, told a managed operator to run a command
639
+ // that structurally cannot touch their database.
540
640
  logger.warn([
541
641
  "",
542
- "┌──────────────────────────────────────────────────────────────┐",
543
- "│ ⚠️ SCHEMA DRIFT — Missing tables in database │",
544
- "├──────────────────────────────────────────────────────────────┤",
545
- ...lines.map(l => `│ ${l.padEnd(60)}│`),
546
- "├──────────────────────────────────────────────────────────────┤",
547
- "│ Run one of: │",
548
- "│ pnpm db:push (dev — fast, no migration files) │",
549
- "│ pnpm db:migrate (prod — creates migration files) │",
550
- "└──────────────────────────────────────────────────────────────┘",
642
+ "⚠️ SCHEMA DRIFT — the database is missing tables this backend serves:",
643
+ ...lines,
644
+ "",
645
+ ...misplacedHelp,
646
+ " This runtime applies the collection schema at boot unless",
647
+ " REBASE_MIGRATE_ON_BOOT=none. Check the \"Collection schema\" / \"policies\"",
648
+ " log lines above — this drift means that step was off, skipped, or failed.",
649
+ " • Managed cloud: redeploy with REBASE_MIGRATE_ON_BOOT unset or",
650
+ " \"ensure\"; the runtime applies the schema to the tenant DB.",
651
+ " If the log above says the driver does not implement collection-table",
652
+ " creation, THIS driver is too old to do it. A driver is installed from",
653
+ " your bundle's dependencies, not supplied by the platform image, so a",
654
+ " newer runtime will not update it: bump \"@rebasepro/server-postgres\"",
655
+ " in your project's package.json and redeploy.",
656
+ " • Self-host: run `rebase db push` (dev) or `rebase db migrate` (prod)",
657
+ " against DATABASE_URL.",
551
658
  ""
552
659
  ].join("\n"));
553
660
  }
@@ -711,7 +818,59 @@ schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
711
818
  collections as Parameters<typeof ensureCollectionTables>[1],
712
819
  log
713
820
  );
714
- return { applied: plan.actions.length };
821
+ for (const failure of plan.failures) {
822
+ logger.warn(
823
+ `🔗 [schema] Could not add foreign key "${failure.target}" — the column exists and the ` +
824
+ `collection still serves, but rows are not policed by this constraint: ${failure.error}`
825
+ );
826
+ }
827
+ return { applied: plan.actions.length - plan.failures.length };
828
+ },
829
+
830
+ /**
831
+ * Apply the collections' RLS policies — ENABLE ROW LEVEL SECURITY and the
832
+ * `securityRules` compiled to `CREATE POLICY` — so a freshly provisioned
833
+ * database serves data instead of denying every user-context read.
834
+ *
835
+ * The companion to {@link ensureCollectionSchema}: that creates the
836
+ * tables, this makes them servable. Boot runs it *after* auth
837
+ * initialization, because the generated policies call `auth.uid()` /
838
+ * `auth.roles()`, and `CREATE POLICY` validates those functions exist.
839
+ *
840
+ * Runs through the same drizzle handle, one statement at a time (that
841
+ * handle speaks the extended query protocol, which rejects multi-command
842
+ * strings). Failures are per-table and non-fatal: a table left un-policed
843
+ * stays RLS-enabled, so it denies rather than leaks.
844
+ */
845
+ async ensureCollectionPolicies(
846
+ collections: unknown[],
847
+ driverResult: InitializedDriver,
848
+ log?: (message: string) => void
849
+ ): Promise<{ applied: number }> {
850
+ const internals = driverResult.internals as PostgresDriverInternals;
851
+ const { ensureCollectionPolicies } = await import("./schema/ensure-collection-policies");
852
+ const queryable = {
853
+ async query<T>(text: string): Promise<{ rows: T[] }> {
854
+ const result = await internals.db.execute(sql.raw(text));
855
+ const rows = (result as unknown as { rows?: T[] }).rows;
856
+ return { rows: rows ?? (Array.isArray(result) ? (result as T[]) : []) };
857
+ }
858
+ };
859
+ const outcome = await ensureCollectionPolicies(
860
+ queryable,
861
+ collections as CollectionConfig[],
862
+ log
863
+ );
864
+
865
+ for (const skip of outcome.skipped) {
866
+ logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
867
+ }
868
+ for (const failure of outcome.failures) {
869
+ logger.warn(
870
+ `🔐 [rls] Could not fully apply policies to "${failure.table}" — it stays locked (denies) until this is resolved: ${failure.error}`
871
+ );
872
+ }
873
+ return { applied: outcome.policiesApplied };
715
874
  },
716
875
 
717
876
  getAdmin(driverResult: InitializedDriver): DatabaseAdmin | undefined {
@@ -68,10 +68,13 @@ function getColumn(table: RebasePgTable | undefined, ...keys: string[]): RebaseP
68
68
  *
69
69
  * Whitespace goes too: a trailing space survives the fold and reproduces the
70
70
  * problem exactly.
71
+ *
72
+ * Re-exported rather than defined here: `@rebasepro/server` and
73
+ * `@rebasepro/server-mongo` write this column too, and a second copy of this
74
+ * rule is the defect it exists to prevent.
71
75
  */
72
- export function normalizeEmail<T>(email: T): T | string {
73
- return typeof email === "string" ? email.trim().toLowerCase() : email;
74
- }
76
+ import { normalizeEmail } from "@rebasepro/common";
77
+ export { normalizeEmail };
75
78
 
76
79
  /**
77
80
  * PostgreSQL implementation of UserRepository.
@@ -100,6 +100,8 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
100
100
  "--out": String,
101
101
  "--exclude-schema": [String],
102
102
  "--no-owner": Boolean,
103
+ "--enable-row-security": Boolean,
104
+ "--row-security-role": String,
103
105
  "-o": "--out"
104
106
  },
105
107
  { argv: rawArgs.slice(2), permissive: true }
@@ -129,6 +131,23 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
129
131
  }
130
132
  logger.info(chalk.gray(` Using pg_dump ${pf.toolMajor} against server ${pf.serverMajor}.`));
131
133
 
134
+ // Opt-in, and loud. With row security on, pg_dump stops refusing to read
135
+ // rows it cannot see and starts leaving them out — an exit-0 backup that
136
+ // is quietly short. Anyone choosing that should know they chose it.
137
+ const rowSecurity = args["--enable-row-security"]
138
+ ? { uid: "rebase-db-backup", roles: [args["--row-security-role"] || "admin"] }
139
+ : undefined;
140
+
141
+ if (rowSecurity) {
142
+ logger.warn("");
143
+ logger.warn(chalk.yellow(" ⚠ Dumping with row-level security ON."));
144
+ logger.warn(chalk.gray(` Reading as roles [${rowSecurity.roles.join(", ")}], which satisfies the generated`));
145
+ logger.warn(chalk.gray(" `admin_full_access` policy. This backup contains exactly the rows those"));
146
+ logger.warn(chalk.gray(" policies admit — a table whose policies have no admin rule comes out short,"));
147
+ logger.warn(chalk.gray(" and pg_dump will not say so. Prefer granting the dumping role BYPASSRLS."));
148
+ logger.warn("");
149
+ }
150
+
132
151
  try {
133
152
  if (dest.kind === "local") {
134
153
  // Honour an explicit `…/name.dump` path; otherwise treat it as a
@@ -141,7 +160,8 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
141
160
  fileName: explicitFile ? path.basename(dest.path) : undefined,
142
161
  excludeSchemas: args["--exclude-schema"],
143
162
  noOwner: args["--no-owner"],
144
- inheritStdio: true
163
+ inheritStdio: true,
164
+ rowSecurity
145
165
  });
146
166
  await assertDumpValid(dump.localFile);
147
167
  logger.info("");
@@ -156,7 +176,8 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
156
176
  dbName,
157
177
  excludeSchemas: args["--exclude-schema"],
158
178
  noOwner: args["--no-owner"],
159
- inheritStdio: true
179
+ inheritStdio: true,
180
+ rowSecurity
160
181
  });
161
182
  try {
162
183
  await assertDumpValid(dump.localFile);
@@ -395,6 +416,24 @@ ${chalk.green.bold("Options")}
395
416
  ${chalk.blue("--out, -o")} <dest> Local path or s3://…/gs://… URL (default: ./backups)
396
417
  ${chalk.blue("--exclude-schema")} <s> Exclude a schema (repeatable)
397
418
  ${chalk.blue("--no-owner")} Omit ownership commands from the dump
419
+ ${chalk.blue("--enable-row-security")} Dump as an admin subject instead of failing on RLS
420
+ ${chalk.red("(may produce a partial dump — see below)")}
421
+ ${chalk.blue("--row-security-role")} <r> Role to read as with the flag above (default: admin)
422
+
423
+ ${chalk.green.bold("Row-level security")}
424
+ On a managed Postgres the dumping role usually owns nothing and has no
425
+ BYPASSRLS, so pg_dump refuses:
426
+
427
+ ERROR: query would be affected by row-level security policy for table "..."
428
+
429
+ That refusal is the safe behaviour. --enable-row-security replaces it by
430
+ reading as an admin subject: Rebase sets app.uid/app.user_roles so the
431
+ generated admin_full_access policy admits the dump. The dump then contains
432
+ exactly what those policies admit ${chalk.red("and no error is raised for what they do not")} —
433
+ a table whose policies lack an admin rule comes out short, silently.
434
+
435
+ Granting the dumping role BYPASSRLS is the option that keeps a backup
436
+ meaning "every row".
398
437
 
399
438
  ${chalk.green.bold("Notes")}
400
439
  Backups may contain secrets and PII. Use private storage destinations and
@@ -18,6 +18,9 @@ import {
18
18
  buildBackupFilename,
19
19
  buildPgDumpArgs,
20
20
  buildPgDumpallGlobalsArgs,
21
+ buildRowSecurityPgOptions,
22
+ diagnoseRowSecurityDumpFailure,
23
+ type RowSecurityIdentity,
21
24
  buildPgRestoreArgs,
22
25
  buildPgRestoreListArgs,
23
26
  checkToolServerCompatibility,
@@ -140,6 +143,15 @@ export async function createDump(opts: {
140
143
  inheritStdio?: boolean;
141
144
  includeGlobals?: boolean;
142
145
  env?: Record<string, string | undefined>;
146
+ /**
147
+ * Dump with row security left on, reading as this identity.
148
+ *
149
+ * The escape hatch for a managed Postgres, where the dumping role owns
150
+ * nothing and has no `BYPASSRLS`. Off by default, and deliberately so: with
151
+ * row security on, `pg_dump` stops erroring on rows it cannot see and
152
+ * simply omits them. See {@link RowSecurityIdentity}.
153
+ */
154
+ rowSecurity?: RowSecurityIdentity;
143
155
  }): Promise<BackupResult> {
144
156
  const env = opts.env ?? process.env;
145
157
  const bin = resolvePgBinary("pg_dump", env);
@@ -159,13 +171,34 @@ export async function createDump(opts: {
159
171
  connectionString: opts.connectionString,
160
172
  outFile: localFile,
161
173
  excludeSchemas: opts.excludeSchemas,
162
- noOwner: opts.noOwner
174
+ noOwner: opts.noOwner,
175
+ rowSecurity: opts.rowSecurity
163
176
  });
164
177
 
165
- await execa(bin, args, {
166
- stdio: opts.inheritStdio ? "inherit" : "pipe",
167
- env: { ...(env as Record<string, string>) }
168
- });
178
+ // `PGOPTIONS` is the only way to set a GUC on a tool that takes no SQL.
179
+ // Built from the same object that added `--enable-row-security`, so the
180
+ // flag cannot travel without the identity that makes it safe.
181
+ const dumpEnv: Record<string, string> = { ...(env as Record<string, string>) };
182
+ if (opts.rowSecurity) {
183
+ dumpEnv.PGOPTIONS = [env.PGOPTIONS, buildRowSecurityPgOptions(opts.rowSecurity)]
184
+ .filter(Boolean).join(" ");
185
+ }
186
+
187
+ try {
188
+ await execa(bin, args, {
189
+ stdio: opts.inheritStdio ? "inherit" : "pipe",
190
+ env: dumpEnv
191
+ });
192
+ } catch (error) {
193
+ // The RLS failure names a table and no cause. Replace it with the
194
+ // cause and the two ways out; anything else is re-thrown untouched.
195
+ const diagnosis = diagnoseRowSecurityDumpFailure(error);
196
+ if (!diagnosis) throw error;
197
+ throw new BackupToolError(
198
+ diagnosis,
199
+ "Run `rebase db backup --help` for the flag, and read what it says about partial dumps."
200
+ );
201
+ }
169
202
 
170
203
  const sizeBytes = fs.existsSync(localFile) ? fs.statSync(localFile).size : 0;
171
204
 
@@ -6,6 +6,8 @@
6
6
  * not require a database.
7
7
  */
8
8
 
9
+ import { forLibpq } from "../utils/connection-string";
10
+
9
11
  /**
10
12
  * A parsed backup destination. `--out` (and the scheduled-backup config)
11
13
  * accepts either a local filesystem path or an object-storage URL.
@@ -171,6 +173,45 @@ export function joinStorageKey(prefix: string, fileName: string): string {
171
173
  return clean.length > 0 ? `${clean}/${fileName}` : fileName;
172
174
  }
173
175
 
176
+ /**
177
+ * The identity `pg_dump` reads rows as, when row security is left on.
178
+ *
179
+ * Not optional, and that is the whole design. `pg_dump --enable-row-security`
180
+ * on its own is the dangerous command in this file: it turns the "query would
181
+ * be affected by row-level security policy" *error* into a dump that exits 0
182
+ * and is silently missing every row the dumping role's policies exclude. A
183
+ * backup that looks fine and restores most of your data is worse than one that
184
+ * refused to run.
185
+ *
186
+ * So the flag is unreachable without a subject to evaluate the policies
187
+ * against. Rebase's generated policies read `app.uid` and `app.user_roles`;
188
+ * supplying an admin role satisfies the `admin_full_access` rule and the dump
189
+ * sees everything that rule sees.
190
+ */
191
+ export interface RowSecurityIdentity {
192
+ /** Written to `app.uid`. Any non-empty value — it is only an audit trail. */
193
+ uid: string;
194
+ /** Written to `app.user_roles`. Must include a role the policies admit. */
195
+ roles: string[];
196
+ }
197
+
198
+ /**
199
+ * `PGOPTIONS` carrying an identity, for a libpq tool that has no other way to
200
+ * set a GUC.
201
+ *
202
+ * A backslash escape rather than quoting, which is what libpq's `-c` parsing
203
+ * takes: a space inside a value ends the option otherwise, so a role list is
204
+ * comma-joined and never spaced.
205
+ */
206
+ export function buildRowSecurityPgOptions(identity: RowSecurityIdentity): string {
207
+ const escape = (value: string) => value.replace(/([\\ ])/g, "\\$1");
208
+ return [
209
+ `-c app.uid=${escape(identity.uid)}`,
210
+ `-c app.user_id=${escape(identity.uid)}`,
211
+ `-c app.user_roles=${escape(identity.roles.join(","))}`
212
+ ].join(" ");
213
+ }
214
+
174
215
  /**
175
216
  * Assemble the `pg_dump` argument vector. Uses the custom format (`-Fc`),
176
217
  * which is compressed and restorable selectively via `pg_restore`.
@@ -182,18 +223,70 @@ export function buildPgDumpArgs(opts: {
182
223
  excludeSchemas?: string[];
183
224
  /** Number of parallel jobs (directory format only; ignored for -Fc). */
184
225
  noOwner?: boolean;
226
+ /**
227
+ * Dump with row security on, as this identity. Omit — which is the default
228
+ * — and `pg_dump` errors rather than skipping rows it cannot see.
229
+ */
230
+ rowSecurity?: RowSecurityIdentity;
185
231
  }): string[] {
186
232
  const args = ["--format=custom", "--no-password", `--file=${opts.outFile}`];
187
233
  if (opts.noOwner) {
188
234
  args.push("--no-owner");
189
235
  }
236
+ if (opts.rowSecurity) {
237
+ args.push("--enable-row-security");
238
+ }
190
239
  for (const schema of opts.excludeSchemas ?? []) {
191
240
  args.push(`--exclude-schema=${schema}`);
192
241
  }
193
- args.push(opts.connectionString);
242
+ args.push(forLibpq(opts.connectionString));
194
243
  return args;
195
244
  }
196
245
 
246
+ /**
247
+ * Whether a `pg_dump` failure is the row-security one, and what to do about it.
248
+ *
249
+ * The error text names the table and nothing else, so the first read of it is
250
+ * "why would a backup be affected by RLS at all?" — the answer being that the
251
+ * dumping role is not the tables' owner and has no `BYPASSRLS`, which is the
252
+ * normal state of the `postgres` user on Cloud SQL, RDS and every other managed
253
+ * Postgres. Nothing about that is visible from the message.
254
+ *
255
+ * Returns `null` for any other failure, so the caller reports it unchanged.
256
+ */
257
+ export function diagnoseRowSecurityDumpFailure(error: unknown): string | null {
258
+ const text = [
259
+ (error as { stderr?: unknown })?.stderr,
260
+ (error as { message?: unknown })?.message
261
+ ].map(part => (typeof part === "string" ? part : "")).join("\n");
262
+
263
+ if (!/row-level security policy/i.test(text)) return null;
264
+
265
+ const table = text.match(/for table "([^"]+)"/)?.[1];
266
+
267
+ return [
268
+ `pg_dump cannot read ${table ? `"${table}"` : "one of the tables"} because row-level security applies to it.`,
269
+ "",
270
+ " The dumping role is neither the table's owner nor `BYPASSRLS`, which is the normal",
271
+ " state of the `postgres` user on Cloud SQL, RDS and other managed Postgres — there is",
272
+ " no superuser to hand out.",
273
+ "",
274
+ " Two ways out:",
275
+ "",
276
+ " • Grant the dumping role BYPASSRLS, or make it the owner, and run this again. The",
277
+ " dump then contains every row, which is what a backup should mean.",
278
+ "",
279
+ " • Re-run with --enable-row-security to dump as an admin subject instead. Rebase",
280
+ " sets `app.uid`/`app.user_roles` so the generated `admin_full_access` policy",
281
+ " admits the dump. Read the warning it prints: the result contains exactly the",
282
+ " rows those policies admit, and any table whose policies do not include an",
283
+ " admin rule comes out short — with no error.",
284
+ "",
285
+ " Do not reach for a bare `pg_dump --enable-row-security` by hand. Without the",
286
+ " settings above it succeeds and silently omits rows."
287
+ ].join("\n");
288
+ }
289
+
197
290
  /**
198
291
  * Assemble the `pg_restore` argument vector for a custom-format dump.
199
292
  */
@@ -211,7 +304,7 @@ export function buildPgRestoreArgs(opts: {
211
304
  exitOnError?: boolean;
212
305
  noOwner?: boolean;
213
306
  }): string[] {
214
- const args = ["--format=custom", "--no-password", `--dbname=${opts.connectionString}`];
307
+ const args = ["--format=custom", "--no-password", `--dbname=${forLibpq(opts.connectionString)}`];
215
308
  if (opts.clean) {
216
309
  args.push("--clean", "--if-exists");
217
310
  }
@@ -255,7 +348,7 @@ export function buildPgDumpallGlobalsArgs(opts: {
255
348
  "--no-role-passwords",
256
349
  "--no-password",
257
350
  `--file=${opts.outFile}`,
258
- `--dbname=${opts.connectionString}`
351
+ `--dbname=${forLibpq(opts.connectionString)}`
259
352
  ];
260
353
  }
261
354