@rebasepro/server-postgres 0.12.1-canary.g4e7bcbf → 0.12.1-canary.g52d71ee
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/services.d.ts +6 -1
- package/dist/backup/backup-service.d.ts +10 -1
- package/dist/backup/pg-tools.d.ts +47 -0
- package/dist/{backup-service-DLb2drIH.js → backup-service-CD8o_1Sl.js} +136 -10
- package/dist/backup-service-CD8o_1Sl.js.map +1 -0
- package/dist/{ensure-collection-policies-Dv21KDMJ.js → ensure-collection-policies-ViG8XiPn.js} +2 -2
- package/dist/{ensure-collection-policies-Dv21KDMJ.js.map → ensure-collection-policies-ViG8XiPn.js.map} +1 -1
- package/dist/{ensure-collection-tables-CT6xHB1d.js → ensure-collection-tables-CBQdOETu.js} +2 -2
- package/dist/{ensure-collection-tables-CT6xHB1d.js.map → ensure-collection-tables-CBQdOETu.js.map} +1 -1
- package/dist/index.es.js +768 -537
- package/dist/index.es.js.map +1 -1
- package/dist/schema/introspect-db-constraints.d.ts +57 -0
- package/dist/schema/introspect-db-logic.d.ts +94 -5
- package/dist/schema/introspect-db-queries.d.ts +119 -0
- package/dist/schema/introspect-db-structure.d.ts +263 -0
- package/dist/schema/introspect-db-types.d.ts +11 -0
- package/dist/services/RelationService.d.ts +24 -1
- package/dist/services/channel-bus/index.d.ts +1 -7
- package/dist/services/collection-helpers.d.ts +36 -2
- package/dist/{src-BkdpiQdw.js → src-DlPBctw_.js} +72 -6
- package/dist/src-DlPBctw_.js.map +1 -0
- package/dist/utils/connection-string.d.ts +29 -0
- package/dist/utils/drizzle-conditions.d.ts +5 -4
- package/dist/utils/pg-error-utils.d.ts +16 -0
- package/package.json +6 -6
- package/src/auth/services.ts +6 -3
- package/src/backup/backup-cli.ts +41 -2
- package/src/backup/backup-service.ts +38 -5
- package/src/backup/pg-tools.ts +96 -3
- package/src/cli.ts +11 -4
- package/src/collections/validate-relations.ts +15 -0
- package/src/data-transformer.ts +9 -3
- package/src/schema/generate-drizzle-schema-logic.ts +26 -1
- package/src/schema/introspect-db-constraints.ts +385 -0
- package/src/schema/introspect-db-inference.ts +18 -8
- package/src/schema/introspect-db-logic.ts +364 -68
- package/src/schema/introspect-db-queries.ts +326 -0
- package/src/schema/introspect-db-structure.ts +670 -0
- package/src/schema/introspect-db-types.ts +56 -0
- package/src/schema/introspect-db.ts +37 -80
- package/src/services/BranchService.ts +66 -28
- package/src/services/FetchService.ts +14 -0
- package/src/services/PersistService.ts +20 -6
- package/src/services/RelationService.ts +211 -45
- package/src/services/channel-bus/index.ts +0 -9
- package/src/services/collection-helpers.ts +69 -3
- package/src/utils/connection-string.ts +58 -0
- package/src/utils/drizzle-conditions.ts +31 -6
- package/src/utils/pg-error-utils.ts +19 -0
- package/dist/backup-service-DLb2drIH.js.map +0 -1
- package/dist/src-BkdpiQdw.js.map +0 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection-string rewrites for the tools Rebase shells out to.
|
|
3
|
+
*
|
|
4
|
+
* Pure and dependency-free: `pg-tools` builds argument vectors without a live
|
|
5
|
+
* server, and these have to be usable from there.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Make a connection string safe to hand to a libpq program.
|
|
9
|
+
*
|
|
10
|
+
* `sslmode=no-verify` is a node-postgres convention — encrypt, but do not check
|
|
11
|
+
* the certificate — and libpq does not have it. It does not degrade or warn:
|
|
12
|
+
*
|
|
13
|
+
* psql: error: invalid sslmode value: "no-verify"
|
|
14
|
+
*
|
|
15
|
+
* which means a `DATABASE_URL` that works perfectly for the app makes `psql`,
|
|
16
|
+
* `pg_dump`, `pg_restore` and Atlas all refuse to start, with an error that
|
|
17
|
+
* points at the value rather than at the convention it comes from. It has cost
|
|
18
|
+
* this project time more than once.
|
|
19
|
+
*
|
|
20
|
+
* `require` is the honest translation: libpq's `require` encrypts and does not
|
|
21
|
+
* verify the certificate either, which is exactly what `no-verify` asks for.
|
|
22
|
+
* Nothing is relaxed by the rewrite — `verify-ca` and `verify-full` are left
|
|
23
|
+
* alone, so a connection string that asked for verification still gets it.
|
|
24
|
+
*
|
|
25
|
+
* Only `sslmode` is touched, and only when it is a value libpq would reject.
|
|
26
|
+
* Anything unparseable is returned as given: a connection that works
|
|
27
|
+
* unverified beats one this corrupted.
|
|
28
|
+
*/
|
|
29
|
+
export declare function forLibpq(connectionString: string): string;
|
|
@@ -87,10 +87,11 @@ export declare class DrizzleConditionBuilder {
|
|
|
87
87
|
*/
|
|
88
88
|
static buildRelationScopeCondition(relation: ResolvedRelation,
|
|
89
89
|
/**
|
|
90
|
-
* Lazy:
|
|
91
|
-
*
|
|
92
|
-
* the parent's *id* alone, and
|
|
93
|
-
* a child listing fail on a
|
|
90
|
+
* Lazy: `via`, `belongsTo`, and a foreign key that points at a
|
|
91
|
+
* `sourceKey` need the parent's own table. A junction and a plain
|
|
92
|
+
* foreign key are expressible from the parent's *id* alone, and
|
|
93
|
+
* requiring the table for them would make a child listing fail on a
|
|
94
|
+
* parent whose table isn't registered.
|
|
94
95
|
*/
|
|
95
96
|
parent: () => {
|
|
96
97
|
table: PgTable<any>;
|
|
@@ -24,6 +24,22 @@ export interface PostgresError extends Error {
|
|
|
24
24
|
* error code (5-char alphanumeric, e.g. `42P01`).
|
|
25
25
|
*/
|
|
26
26
|
export declare function extractPgError(error: unknown): PostgresError | null;
|
|
27
|
+
/**
|
|
28
|
+
* Whether the failure came back from Postgres rather than from building the
|
|
29
|
+
* query — which decides whether a fallback query is worth issuing.
|
|
30
|
+
*
|
|
31
|
+
* Reads here run inside a transaction (that is where `SET LOCAL ROLE` binds
|
|
32
|
+
* RLS). Once a statement raises, that transaction is aborted, and every later
|
|
33
|
+
* statement on it returns `25P02` — "current transaction is aborted, commands
|
|
34
|
+
* ignored until end of transaction block". So a retry after a database error
|
|
35
|
+
* cannot succeed, and it replaces a precise diagnosis ("invalid input syntax
|
|
36
|
+
* for type uuid") with a generic one. Rethrow instead.
|
|
37
|
+
*
|
|
38
|
+
* A query the driver could not even build — a missing reciprocal relation, say
|
|
39
|
+
* — never reached Postgres, leaves the transaction usable, and is exactly what
|
|
40
|
+
* the fallback paths exist for.
|
|
41
|
+
*/
|
|
42
|
+
export declare function reachedDatabase(error: unknown): boolean;
|
|
27
43
|
/**
|
|
28
44
|
* Walk the error cause chain and return the deepest meaningful message.
|
|
29
45
|
*/
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/server-postgres",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.12.1-canary.
|
|
4
|
+
"version": "0.12.1-canary.g52d71ee",
|
|
5
5
|
"description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
"execa": "^9.6.1",
|
|
48
48
|
"pg": "^8.22.0",
|
|
49
49
|
"ws": "^8.21.1",
|
|
50
|
-
"@rebasepro/
|
|
51
|
-
"@rebasepro/
|
|
52
|
-
"@rebasepro/
|
|
53
|
-
"@rebasepro/
|
|
54
|
-
"@rebasepro/
|
|
50
|
+
"@rebasepro/server": "0.12.1-canary.g52d71ee",
|
|
51
|
+
"@rebasepro/utils": "0.12.1-canary.g52d71ee",
|
|
52
|
+
"@rebasepro/codegen": "0.12.1-canary.g52d71ee",
|
|
53
|
+
"@rebasepro/common": "0.12.1-canary.g52d71ee",
|
|
54
|
+
"@rebasepro/types": "0.12.1-canary.g52d71ee"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@hono/node-server": "^2.0.12",
|
package/src/auth/services.ts
CHANGED
|
@@ -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
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
76
|
+
import { normalizeEmail } from "@rebasepro/common";
|
|
77
|
+
export { normalizeEmail };
|
|
75
78
|
|
|
76
79
|
/**
|
|
77
80
|
* PostgreSQL implementation of UserRepository.
|
package/src/backup/backup-cli.ts
CHANGED
|
@@ -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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
package/src/backup/pg-tools.ts
CHANGED
|
@@ -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
|
|
package/src/cli.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
promptConfirm
|
|
17
17
|
} from "./cli-helpers";
|
|
18
18
|
import { checkDatabaseConnectivity, diagnoseDbError } from "./cli-errors";
|
|
19
|
+
import { forLibpq } from "./utils/connection-string";
|
|
19
20
|
import { AUTH_BOOTSTRAP_SQL } from "./schema/auth-bootstrap-sql";
|
|
20
21
|
import { detectDestructiveStatements, decidePushSafety } from "./schema/destructive-sql";
|
|
21
22
|
|
|
@@ -693,19 +694,25 @@ async function runAtlas(
|
|
|
693
694
|
const devDatabaseUrl = getDevDatabaseUrl(databaseUrl);
|
|
694
695
|
await ensureDevDatabaseExists(databaseUrl, devDatabaseUrl);
|
|
695
696
|
|
|
697
|
+
// Atlas speaks libpq, which rejects the `sslmode=no-verify` that
|
|
698
|
+
// node-postgres accepts — see `forLibpq`. Rewritten only for the argv, so
|
|
699
|
+
// everything above still connects with the URL as configured.
|
|
700
|
+
const atlasUrl = forLibpq(databaseUrl);
|
|
701
|
+
const atlasDevUrl = forLibpq(devDatabaseUrl);
|
|
702
|
+
|
|
696
703
|
const atlasArgs = [domain, ...args];
|
|
697
704
|
|
|
698
705
|
if (domain === "schema") {
|
|
699
706
|
if (args.includes("apply")) {
|
|
700
|
-
atlasArgs.push("--url",
|
|
707
|
+
atlasArgs.push("--url", atlasUrl, "--dev-url", atlasDevUrl);
|
|
701
708
|
} else if (args.includes("clean") || args.includes("inspect")) {
|
|
702
|
-
atlasArgs.push("--url",
|
|
709
|
+
atlasArgs.push("--url", atlasUrl);
|
|
703
710
|
}
|
|
704
711
|
} else if (domain === "migrate") {
|
|
705
712
|
if (args.includes("diff")) {
|
|
706
|
-
atlasArgs.push("--dev-url",
|
|
713
|
+
atlasArgs.push("--dev-url", atlasDevUrl);
|
|
707
714
|
} else if (args.includes("apply") || args.includes("status")) {
|
|
708
|
-
atlasArgs.push("--url",
|
|
715
|
+
atlasArgs.push("--url", atlasUrl, "--revisions-schema", "rebase");
|
|
709
716
|
if (args.includes("apply")) {
|
|
710
717
|
atlasArgs.push("--allow-dirty");
|
|
711
718
|
}
|
|
@@ -135,6 +135,21 @@ kind: relation.kind };
|
|
|
135
135
|
fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
|
|
136
136
|
});
|
|
137
137
|
}
|
|
138
|
+
// `sourceKey` is the easiest of the two to put on the wrong
|
|
139
|
+
// side — it is the only column in a `hasMany` that lives
|
|
140
|
+
// here rather than on the target, and naming a target column
|
|
141
|
+
// reads perfectly well right next to `foreignKeyOnTarget`.
|
|
142
|
+
if (relation.sourceKey && !sourceColumns.has(relation.sourceKey)) {
|
|
143
|
+
defects.push({
|
|
144
|
+
...at,
|
|
145
|
+
problem: `\`sourceKey: "${relation.sourceKey}"\` is not a column on \`${sourceTableName}\``,
|
|
146
|
+
fix: targetColumns.has(relation.sourceKey)
|
|
147
|
+
? `it is a column on the *target* table \`${targetTableName}\` — \`sourceKey\` names ` +
|
|
148
|
+
"the column on this collection that the target's foreign key points at, so it " +
|
|
149
|
+
`must be one of: ${quote(sourceColumns)}`
|
|
150
|
+
: `add the column, or set \`sourceKey\` to one of: ${quote(sourceColumns)}`
|
|
151
|
+
});
|
|
152
|
+
}
|
|
138
153
|
break;
|
|
139
154
|
}
|
|
140
155
|
|
package/src/data-transformer.ts
CHANGED
|
@@ -328,9 +328,15 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
|
328
328
|
const targetCollection = relation.target();
|
|
329
329
|
const targetTable = registry.getTable(getTableName(targetCollection));
|
|
330
330
|
const pks = getPrimaryKeys(collection, registry!);
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
331
|
+
// What the target's foreign key holds. Ordinarily this
|
|
332
|
+
// row's id; for a link on a natural key, the value of
|
|
333
|
+
// the column it points at — which is on the row already,
|
|
334
|
+
// so this costs nothing extra.
|
|
335
|
+
const currentId = relation.sourceKey
|
|
336
|
+
? (data as Record<string, unknown>)[relation.sourceKey] as string | number | undefined
|
|
337
|
+
: buildCompositeId(data, pks);
|
|
338
|
+
|
|
339
|
+
if (targetTable && currentId !== undefined && currentId !== null && currentId !== "") {
|
|
334
340
|
const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget as keyof typeof targetTable] as AnyPgColumn;
|
|
335
341
|
if (foreignKeyColumn) {
|
|
336
342
|
// Query the target table to find row that references this row
|
|
@@ -137,6 +137,25 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
|
|
|
137
137
|
let baseType = (numProp.validation?.integer || isId) ? `integer("${colName}")` : `numeric("${colName}")`;
|
|
138
138
|
if (numProp.columnType) {
|
|
139
139
|
if (numProp.columnType === "double precision") baseType = `doublePrecision("${colName}")`;
|
|
140
|
+
// `bigint` and `bigserial` are the only pg-core builders that
|
|
141
|
+
// *require* a config argument: without `mode`, drizzle cannot
|
|
142
|
+
// know whether to hand back a `number` or a `bigint`, and the
|
|
143
|
+
// emitted call does not typecheck.
|
|
144
|
+
//
|
|
145
|
+
// This is why `schema.generated.ts` drifted. Regenerating it
|
|
146
|
+
// produced a file that would not compile, so the bigint lines
|
|
147
|
+
// were hand-patched — and every regeneration after that looked
|
|
148
|
+
// like a large, alarming diff nobody wanted to ship. The file
|
|
149
|
+
// then sat stale for long enough that a security fix to two RLS
|
|
150
|
+
// policies never reached production.
|
|
151
|
+
//
|
|
152
|
+
// `number` rather than `bigint`: these are counters and byte
|
|
153
|
+
// totals that every caller already treats as numbers, and
|
|
154
|
+
// switching the runtime type would be a breaking change to
|
|
155
|
+
// every consumer of the generated schema.
|
|
156
|
+
else if (numProp.columnType === "bigint" || numProp.columnType === "bigserial") {
|
|
157
|
+
baseType = `${numProp.columnType}("${colName}", { mode: "number" })`;
|
|
158
|
+
}
|
|
140
159
|
else baseType = `${numProp.columnType}("${colName}")`;
|
|
141
160
|
}
|
|
142
161
|
|
|
@@ -785,8 +804,14 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
|
|
|
785
804
|
// name (e.g. "client_id") may differ from the property key
|
|
786
805
|
// (e.g. "clientId") when `columnName` is set.
|
|
787
806
|
const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
|
|
807
|
+
// The column the far side points at: its
|
|
808
|
+
// primary key, unless the link names another
|
|
809
|
+
// one with `sourceKey`.
|
|
810
|
+
const referencedKey = otherRel.sourceKey
|
|
811
|
+
? resolvePropertyKeyForColumn(otherCollection, otherRel.sourceKey)
|
|
812
|
+
: getPrimaryKeyName(otherCollection);
|
|
788
813
|
const synthKey = `_synth_${otherTableVar}_${drizzleFieldKey}`;
|
|
789
|
-
tableRelations.push(` "${synthKey}": one(${otherTableVar}, {\n fields: [${tableVarName}.${drizzleFieldKey}],\n references: [${otherTableVar}.${
|
|
814
|
+
tableRelations.push(` "${synthKey}": one(${otherTableVar}, {\n fields: [${tableVarName}.${drizzleFieldKey}],\n references: [${otherTableVar}.${referencedKey}],\n relationName: \"${drizzleRelationName}\"\n })`);
|
|
790
815
|
emittedRelationNames.add(deduplicationKey);
|
|
791
816
|
}
|
|
792
817
|
}
|