@rebasepro/server-postgres 0.11.1-canary.gfd39654 → 0.12.1-canary.g009ed95

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 (98) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/PostgresBootstrapper.d.ts +33 -1
  3. package/dist/auth/services.d.ts +21 -0
  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-CD8o_1Sl.js +8999 -0
  7. package/dist/backup-service-CD8o_1Sl.js.map +1 -0
  8. package/dist/cli-helpers.d.ts +39 -0
  9. package/dist/collections/buildRegistry.d.ts +1 -1
  10. package/dist/connection-BuZ97wsr.js +250 -0
  11. package/dist/connection-BuZ97wsr.js.map +1 -0
  12. package/dist/connection.d.ts +42 -0
  13. package/dist/ensure-collection-policies-BrUVgjz3.js +57 -0
  14. package/dist/ensure-collection-policies-BrUVgjz3.js.map +1 -0
  15. package/dist/ensure-collection-tables-Da2oGkX2.js +650 -0
  16. package/dist/ensure-collection-tables-Da2oGkX2.js.map +1 -0
  17. package/dist/history/HistoryService.d.ts +9 -29
  18. package/dist/index.es.js +1234 -9753
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/policy-CeA1JcxP.js +105 -0
  21. package/dist/policy-CeA1JcxP.js.map +1 -0
  22. package/dist/schema/auth-schema.d.ts +83 -144
  23. package/dist/schema/dynamic-tables.d.ts +1 -1
  24. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  25. package/dist/schema/ensure-collection-tables.d.ts +44 -2
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +135 -1
  27. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  28. package/dist/schema/introspect-db-logic.d.ts +94 -5
  29. package/dist/schema/introspect-db-queries.d.ts +119 -0
  30. package/dist/schema/introspect-db-structure.d.ts +263 -0
  31. package/dist/schema/introspect-db-types.d.ts +11 -0
  32. package/dist/schema/introspect-runtime.d.ts +1 -1
  33. package/dist/services/FetchService.d.ts +40 -2
  34. package/dist/services/RelationService.d.ts +24 -1
  35. package/dist/services/channel-bus/index.d.ts +1 -7
  36. package/dist/services/collection-helpers.d.ts +24 -1
  37. package/dist/services/dataService.d.ts +3 -1
  38. package/dist/services/row-pipeline.d.ts +4 -2
  39. package/dist/{src-3VmUJ8Xn.js → src-CzbghKwf.js} +464 -187
  40. package/dist/src-CzbghKwf.js.map +1 -0
  41. package/dist/{src-D5xBTl32.js → src-DoU9yPqq.js} +79 -189
  42. package/dist/src-DoU9yPqq.js.map +1 -0
  43. package/dist/utils/connection-string.d.ts +29 -0
  44. package/dist/utils/drizzle-conditions.d.ts +162 -7
  45. package/dist/utils/pg-error-utils.d.ts +25 -3
  46. package/dist/websocket-B2LsrINK.js +530 -0
  47. package/dist/websocket-B2LsrINK.js.map +1 -0
  48. package/package.json +14 -14
  49. package/src/PostgresAdapter.ts +21 -2
  50. package/src/PostgresBackendDriver.ts +4 -0
  51. package/src/PostgresBootstrapper.ts +212 -36
  52. package/src/auth/ensure-tables.ts +164 -9
  53. package/src/auth/services.ts +24 -2
  54. package/src/backup/backup-cli.ts +41 -2
  55. package/src/backup/backup-service.ts +38 -5
  56. package/src/backup/pg-tools.ts +96 -3
  57. package/src/cli-helpers.ts +70 -0
  58. package/src/cli.ts +44 -26
  59. package/src/collections/buildRegistry.ts +1 -1
  60. package/src/collections/validate-relations.ts +15 -0
  61. package/src/connection.ts +73 -0
  62. package/src/data-transformer.ts +9 -3
  63. package/src/databasePoolManager.ts +5 -2
  64. package/src/history/HistoryService.ts +13 -31
  65. package/src/schema/auth-schema.ts +30 -19
  66. package/src/schema/dynamic-tables.ts +1 -1
  67. package/src/schema/ensure-collection-policies.ts +105 -0
  68. package/src/schema/ensure-collection-tables.test.ts +105 -9
  69. package/src/schema/ensure-collection-tables.ts +220 -32
  70. package/src/schema/generate-drizzle-schema-logic.ts +33 -8
  71. package/src/schema/generate-postgres-ddl-logic.ts +382 -19
  72. package/src/schema/introspect-db-constraints.ts +385 -0
  73. package/src/schema/introspect-db-inference.ts +18 -8
  74. package/src/schema/introspect-db-logic.ts +385 -71
  75. package/src/schema/introspect-db-queries.ts +326 -0
  76. package/src/schema/introspect-db-structure.ts +670 -0
  77. package/src/schema/introspect-db-types.ts +56 -0
  78. package/src/schema/introspect-db.ts +37 -80
  79. package/src/schema/introspect-runtime.test.ts +56 -8
  80. package/src/schema/introspect-runtime.ts +32 -10
  81. package/src/security/policy-drift.test.ts +11 -3
  82. package/src/services/FetchService.ts +148 -18
  83. package/src/services/PersistService.ts +20 -6
  84. package/src/services/RelationService.ts +249 -48
  85. package/src/services/channel-bus/index.ts +0 -9
  86. package/src/services/collection-helpers.ts +40 -1
  87. package/src/services/dataService.ts +3 -1
  88. package/src/services/realtimeService.ts +3 -3
  89. package/src/services/row-pipeline.ts +4 -2
  90. package/src/utils/connection-string.ts +58 -0
  91. package/src/utils/drizzle-conditions.ts +539 -50
  92. package/src/utils/pg-error-utils.ts +98 -3
  93. package/src/websocket.ts +18 -9
  94. package/dist/chunk-DSJWtz9O.js +0 -40
  95. package/dist/ensure-collection-tables-DGMYK0fr.js +0 -304
  96. package/dist/ensure-collection-tables-DGMYK0fr.js.map +0 -1
  97. package/dist/src-3VmUJ8Xn.js.map +0 -1
  98. package/dist/src-D5xBTl32.js.map +0 -1
@@ -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
 
@@ -2,6 +2,8 @@ import { isManyToMany } from "@rebasepro/types";
2
2
  import path from "path";
3
3
  import fs from "fs";
4
4
  import { execSync } from "child_process";
5
+ import { createRequire } from "module";
6
+ import readline from "readline";
5
7
  import { pathToFileURL } from "url";
6
8
  import chalk from "chalk";
7
9
  import { logger } from "@rebasepro/server";
@@ -10,6 +12,49 @@ import { moduleDir as __helpersDirname } from "./module-dir";
10
12
 
11
13
 
12
14
 
15
+ /**
16
+ * Why is a dependency's binary missing — never installed, or installed with its
17
+ * build script blocked?
18
+ *
19
+ * These need opposite advice, and getting it wrong is not a cosmetic miss. pnpm
20
+ * 10+ refuses to run a dependency's lifecycle scripts unless it is allowlisted
21
+ * (`pnpm.onlyBuiltDependencies`, or `allowBuilds` in `pnpm-workspace.yaml`).
22
+ * `@ariga/atlas` downloads its platform binary in `preinstall`, so a blocked
23
+ * script leaves a state that looks like a successful install: the package is on
24
+ * disk with its `install.js` and `package.json`, `node_modules/.bin` is empty,
25
+ * the install exits 0, and the only signal is `ERR_PNPM_IGNORED_BUILDS` several
26
+ * screens up.
27
+ *
28
+ * Telling somebody in that state to install the package again sends them round
29
+ * the same loop forever — the add succeeds, the script is blocked again,
30
+ * nothing changes. Verified by doing it: `pnpm add @ariga/atlas` into a bare
31
+ * project yields exactly this, three "Failed to create bin … ENOENT" warnings
32
+ * and no binary.
33
+ *
34
+ * Resolution is attempted from the user's project first and this package
35
+ * second, matching the order {@link resolveLocalBin} searches — the driver may
36
+ * be installed a level up from where the command runs.
37
+ */
38
+ export function diagnoseMissingBin(packageName: string): "not-installed" | "build-script-blocked" {
39
+ const bases = [
40
+ pathToFileURL(path.join(process.cwd(), "package.json")),
41
+ pathToFileURL(path.join(__helpersDirname, "package.json"))
42
+ ];
43
+
44
+ for (const base of bases) {
45
+ try {
46
+ // `package.json` rather than the package root: a package with an
47
+ // `exports` map that omits `.` is unresolvable by name even when it
48
+ // is perfectly installed, which would misreport it as absent.
49
+ createRequire(base).resolve(`${packageName}/package.json`);
50
+ return "build-script-blocked";
51
+ } catch {
52
+ // Try the next base.
53
+ }
54
+ }
55
+ return "not-installed";
56
+ }
57
+
13
58
  export function resolveLocalBin(binName: string): string | null {
14
59
  // Try to find node_modules/.bin upwards from __helpersDirname first (package-relative)
15
60
  let dir = __helpersDirname;
@@ -232,3 +277,28 @@ export async function getTableExcludes(
232
277
  return excludes;
233
278
  }
234
279
 
280
+
281
+ /**
282
+ * Ask a yes/no question on an interactive terminal.
283
+ *
284
+ * The `isTTY` guard is the contract, not an optimisation: non-interactive
285
+ * shells (CI, pipes, agents) can never answer, and `readline` on a
286
+ * non-TTY stdin resolves with whatever the pipe happens to contain — or never
287
+ * resolves at all. Returning false there is what makes an unattended
288
+ * `db push` abort instead of auto-confirming a destructive change. Callers
289
+ * should already have gone through {@link decidePushSafety}, which decides
290
+ * whether interactive confirmation is even possible; this is the backstop.
291
+ *
292
+ * Lives here rather than in cli.ts so it is reachable from a test — cli.ts uses
293
+ * `import.meta` and cannot be imported by the jest runner.
294
+ */
295
+ export async function promptConfirm(question: string): Promise<boolean> {
296
+ if (!process.stdin.isTTY) return false;
297
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
298
+ try {
299
+ const answer: string = await new Promise((resolve) => rl.question(question, resolve));
300
+ return /^y(es)?$/i.test(answer.trim());
301
+ } finally {
302
+ rl.close();
303
+ }
304
+ }
package/src/cli.ts CHANGED
@@ -6,17 +6,19 @@ import fs from "fs";
6
6
  import { fileURLToPath } from "url";
7
7
  import { logger } from "@rebasepro/server";
8
8
  import {
9
+ diagnoseMissingBin,
9
10
  resolveLocalBin,
10
11
  getTableIncludes,
11
12
  getDevDatabaseUrl,
12
13
  ensureDevDatabaseExists,
13
14
  getTableExcludes,
14
- ExcludeIntrospectionError
15
+ ExcludeIntrospectionError,
16
+ promptConfirm
15
17
  } from "./cli-helpers";
16
18
  import { checkDatabaseConnectivity, diagnoseDbError } from "./cli-errors";
19
+ import { forLibpq } from "./utils/connection-string";
17
20
  import { AUTH_BOOTSTRAP_SQL } from "./schema/auth-bootstrap-sql";
18
21
  import { detectDestructiveStatements, decidePushSafety } from "./schema/destructive-sql";
19
- import readline from "readline";
20
22
 
21
23
  const __cliDirname = path.dirname(fileURLToPath(import.meta.url));
22
24
 
@@ -610,23 +612,6 @@ function timeAgo(date: Date): string {
610
612
 
611
613
 
612
614
 
613
- /**
614
- * Ask a yes/no question on an interactive terminal. Non-interactive shells
615
- * (CI, pipes, agents) can't answer, so this must only be reached after
616
- * {@link decidePushSafety} has already ruled that interactive confirmation is
617
- * possible.
618
- */
619
- async function promptConfirm(question: string): Promise<boolean> {
620
- if (!process.stdin.isTTY) return false;
621
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
622
- try {
623
- const answer: string = await new Promise((resolve) => rl.question(question, resolve));
624
- return /^y(es)?$/i.test(answer.trim());
625
- } finally {
626
- rl.close();
627
- }
628
- }
629
-
630
615
  async function runAtlas(
631
616
  domain: "schema" | "migrate",
632
617
  args: string[],
@@ -635,9 +620,36 @@ async function runAtlas(
635
620
  ): Promise<string> {
636
621
  const atlasBin = resolveLocalBin("atlas");
637
622
  if (!atlasBin) {
638
- logger.error(chalk.red("✗ Could not find atlas binary."));
639
- const installCmd = "pnpm add -D @ariga/atlas";
640
- logger.error(chalk.gray(` Install it with: ${installCmd}`));
623
+ // Two very different causes, and the advice for one is a loop for the
624
+ // other — see `diagnoseMissingBin`. This used to say "Install it with:
625
+ // pnpm add -D @ariga/atlas" unconditionally, which is the exact command
626
+ // that produces the far more common of the two states.
627
+ logger.error(chalk.red("\n✗ The atlas binary is missing, so the schema cannot be applied.\n"));
628
+
629
+ if (diagnoseMissingBin("@ariga/atlas") === "build-script-blocked") {
630
+ logger.error(chalk.yellow(" @ariga/atlas IS installed — only its binary is missing.\n"));
631
+ logger.error(chalk.gray(
632
+ " It downloads that binary in a `preinstall` script, and pnpm 10+ does not\n" +
633
+ " run a dependency's scripts unless you allow it. The install still exits 0,\n" +
634
+ " so the only sign is `Ignored build scripts: @ariga/atlas` in its output.\n"
635
+ ));
636
+ logger.error(" Fix it with either:\n");
637
+ logger.error(chalk.bold(" pnpm approve-builds\n"));
638
+ logger.error(" or, to record it in the project (what `rebase init` scaffolds):\n");
639
+ logger.error(chalk.bold(
640
+ " // package.json\n" +
641
+ " \"pnpm\": { \"onlyBuiltDependencies\": [\"@ariga/atlas\"] }\n"
642
+ ));
643
+ logger.error(chalk.gray(" Then re-run `pnpm install`.\n"));
644
+ } else {
645
+ logger.error(chalk.gray(" It is not installed in this project.\n"));
646
+ logger.error(" Install it with:\n");
647
+ logger.error(chalk.bold(" pnpm add -D @ariga/atlas\n"));
648
+ logger.error(chalk.gray(
649
+ " If pnpm then reports `Ignored build scripts`, also run `pnpm approve-builds` —\n" +
650
+ " the package carries a `preinstall` script that fetches the binary.\n"
651
+ ));
652
+ }
641
653
  process.exit(1);
642
654
  }
643
655
 
@@ -682,19 +694,25 @@ async function runAtlas(
682
694
  const devDatabaseUrl = getDevDatabaseUrl(databaseUrl);
683
695
  await ensureDevDatabaseExists(databaseUrl, devDatabaseUrl);
684
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
+
685
703
  const atlasArgs = [domain, ...args];
686
704
 
687
705
  if (domain === "schema") {
688
706
  if (args.includes("apply")) {
689
- atlasArgs.push("--url", databaseUrl, "--dev-url", devDatabaseUrl);
707
+ atlasArgs.push("--url", atlasUrl, "--dev-url", atlasDevUrl);
690
708
  } else if (args.includes("clean") || args.includes("inspect")) {
691
- atlasArgs.push("--url", databaseUrl);
709
+ atlasArgs.push("--url", atlasUrl);
692
710
  }
693
711
  } else if (domain === "migrate") {
694
712
  if (args.includes("diff")) {
695
- atlasArgs.push("--dev-url", devDatabaseUrl);
713
+ atlasArgs.push("--dev-url", atlasDevUrl);
696
714
  } else if (args.includes("apply") || args.includes("status")) {
697
- atlasArgs.push("--url", databaseUrl, "--revisions-schema", "rebase");
715
+ atlasArgs.push("--url", atlasUrl, "--revisions-schema", "rebase");
698
716
  if (args.includes("apply")) {
699
717
  atlasArgs.push("--allow-dirty");
700
718
  }
@@ -9,7 +9,7 @@ import { assertRelationsResolve } from "./validate-relations";
9
9
  /**
10
10
  * Everything a registry is built from: the collections, and the drizzle schema
11
11
  * they are backed by. In BaaS mode all of it is introspected from the live
12
- * database; in CMS mode it comes from the config and the generated schema.
12
+ * database; when collections are declared it comes from the config and the generated schema.
13
13
  */
14
14
  export interface RegistrySchema {
15
15
  collections?: CollectionConfig[];
@@ -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/connection.ts CHANGED
@@ -21,10 +21,19 @@ export interface PostgresPoolConfig {
21
21
  statementTimeout?: number;
22
22
  /** Enable TCP keep-alive (default: true) */
23
23
  keepAlive?: boolean;
24
+ /**
25
+ * `search_path` pinned on every connection (default: `"public"`).
26
+ *
27
+ * Pass `false` to send no `search_path` at all and inherit whatever the
28
+ * server/role defaults to. See {@link pinSearchPath} for why the default
29
+ * is not "inherit".
30
+ */
31
+ searchPath?: string | false;
24
32
  }
25
33
 
26
34
  const DEFAULT_POOL: Required<PostgresPoolConfig> = {
27
35
  max: 20,
36
+ searchPath: "public",
28
37
  idleTimeoutMillis: 30_000,
29
38
  connectionTimeoutMillis: 10_000,
30
39
  // The client-side read timeout MUST be comfortably above the server-side
@@ -43,6 +52,67 @@ const DEFAULT_POOL: Required<PostgresPoolConfig> = {
43
52
  /** ReadyForQuery status byte: `I` idle, `T` in transaction, `E` failed transaction. */
44
53
  const TX_IDLE = "I";
45
54
 
55
+ /**
56
+ * Pin `search_path` into a connection string, so unqualified SQL resolves to a
57
+ * schema this framework chose rather than to one Postgres inferred.
58
+ *
59
+ * Postgres defaults `search_path` to `"$user", public`: the *first* candidate
60
+ * is a schema named after the connecting role. Rebase creates a schema called
61
+ * `rebase` (auth, history, api keys), and every template, compose file and
62
+ * deployment doc names the database role `rebase` too — so `$user` resolves to
63
+ * a schema that exists, and every unqualified statement lands there instead of
64
+ * in `public`. The generated Drizzle schema emits bare `pgTable("posts", …)`
65
+ * for any collection without an explicit `schema`, which makes the *runtime's*
66
+ * own reads and writes unqualified; a developer's raw `rebase.sql(...)`, the
67
+ * Studio SQL editor and any hand-written migration are unqualified too. The
68
+ * result is collection tables created in, and served from, `rebase`.
69
+ *
70
+ * Drizzle cannot express the fix on its side: `pgSchema("public")` throws by
71
+ * design ("just use pgTable() instead"), so there is no way to emit a
72
+ * public-qualified table from the generator. The pin has to live on the
73
+ * connection.
74
+ *
75
+ * Precedence is deliberate and verified against node-postgres: `options` in
76
+ * the connection string wins over the `options` field passed to `Pool`, so
77
+ * rewriting the URL — rather than setting the field — is what makes this
78
+ * authoritative. Two escape hatches survive it:
79
+ *
80
+ * - an `options` that already mentions `search_path` is left untouched, so a
81
+ * deployment that deliberately pins something else keeps it;
82
+ * - `searchPath: false` (or an unparseable, non-URL connection string) sends
83
+ * nothing and inherits the server default.
84
+ *
85
+ * Anything else in `options` (a `statement_timeout`, say) is preserved and the
86
+ * `search_path` flag is appended to it.
87
+ */
88
+ export function pinSearchPath(connectionString: string, searchPath: string | false = "public"): string {
89
+ if (searchPath === false) return connectionString;
90
+
91
+ let url: URL;
92
+ try {
93
+ url = new URL(connectionString);
94
+ } catch {
95
+ // Key/value DSNs and anything else we cannot parse are returned as
96
+ // given: a connection that works unpinned beats one we corrupted.
97
+ return connectionString;
98
+ }
99
+ if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") return connectionString;
100
+
101
+ const existing = url.searchParams.get("options");
102
+ if (existing && /(^|\s)-c\s*search_path\s*=/.test(existing)) return connectionString;
103
+
104
+ const flag = `-c search_path=${searchPath}`;
105
+ url.searchParams.set("options", existing ? `${existing} ${flag}` : flag);
106
+ // Re-serialize by hand. `URLSearchParams` writes a space as `+`, which
107
+ // node-postgres happens to decode but libpq does not — and this same string
108
+ // is handed to `pg_dump`/`psql` for backups. Percent-encoding is the form
109
+ // both agree on, and is what the scaffolded `.env` already ships.
110
+ url.search = Array.from(url.searchParams.entries())
111
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
112
+ .join("&");
113
+ return url.toString();
114
+ }
115
+
46
116
  /**
47
117
  * Destroy pool clients that are released while still inside a transaction.
48
118
  *
@@ -108,6 +178,7 @@ export function createPostgresDatabaseConnection(
108
178
  ) {
109
179
  const opts = { ...DEFAULT_POOL,
110
180
  ...poolConfig };
181
+ connectionString = pinSearchPath(connectionString, opts.searchPath);
111
182
 
112
183
  const pgPoolConfig: PoolConfig = {
113
184
  connectionString,
@@ -159,6 +230,7 @@ export function createDirectDatabaseConnection(
159
230
  max: 5,
160
231
  ...poolConfig
161
232
  };
233
+ connectionString = pinSearchPath(connectionString, opts.searchPath);
162
234
 
163
235
  const pgPoolConfig: PoolConfig = {
164
236
  connectionString,
@@ -199,6 +271,7 @@ export function createReadReplicaConnection(
199
271
  max: 10,
200
272
  ...poolConfig
201
273
  };
274
+ connectionString = pinSearchPath(connectionString, opts.searchPath);
202
275
 
203
276
  const pgPoolConfig: PoolConfig = {
204
277
  connectionString,
@@ -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
- const currentId = buildCompositeId(data, pks);
332
-
333
- if (targetTable && currentId) {
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