@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
@@ -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
  }
@@ -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
@@ -2,7 +2,7 @@ import { Pool } from "pg";
2
2
  import { drizzle } from "drizzle-orm/node-postgres";
3
3
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
4
  import { logger } from "@rebasepro/server";
5
- import { guardPoolAgainstDirtyRelease } from "./connection";
5
+ import { guardPoolAgainstDirtyRelease, pinSearchPath } from "./connection";
6
6
 
7
7
  export class DatabasePoolManager {
8
8
  private pools: Map<string, Pool> = new Map();
@@ -41,7 +41,10 @@ export class DatabasePoolManager {
41
41
  url.pathname = `/${databaseName}`;
42
42
 
43
43
  const pool = new Pool({
44
- connectionString: url.toString(),
44
+ // Same pin as the primary pool: these are branch/multi-database
45
+ // connections to the *same* server, so they inherit the same
46
+ // `"$user"` hazard. See `pinSearchPath`.
47
+ connectionString: pinSearchPath(url.toString()),
45
48
  max: 10, // Default sensible limit, can be tuned later
46
49
  idleTimeoutMillis: 10000, // Reduced from 30000 for aggressive cleanup
47
50
  allowExitOnIdle: true // Prevent idle clients from hanging the Node.js process
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Applying a bundle's RLS policies to a database at boot, idempotently.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * {@link ensureCollectionTables} creates the collection *tables* a managed
7
+ * runtime boots against, but a table with row-level security disabled and no
8
+ * policies is not servable: authenticated requests run as the restricted
9
+ * `rebase_user` role, so a read with no `SELECT` policy returns nothing (a
10
+ * public collection answered 401) and a write with no `INSERT`/`UPDATE` policy
11
+ * is denied. The policies live in the collections' `securityRules`; nothing at
12
+ * boot applied them. `rebase db push` does — but it drives Atlas against a
13
+ * local `DATABASE_URL`, and a managed tenant's database is reachable only from
14
+ * inside the cluster, by the runtime that is already connected to it. So the
15
+ * runtime is the only thing that *can* apply them, and this is where it does.
16
+ *
17
+ * ## Why this is safe to run on every boot
18
+ *
19
+ * Every statement is idempotent: `ENABLE ROW LEVEL SECURITY` is a no-op once
20
+ * enabled, and each policy is a `DROP POLICY IF EXISTS` immediately followed by
21
+ * a `CREATE POLICY`, so re-applying asserts exactly the declared state. It adds
22
+ * and replaces; it never drops data. (It does not *reconcile* — a policy a
23
+ * previous push left behind under an old name is not removed here; that stays a
24
+ * `db push` / `db migrate` concern, alongside destructive schema changes.)
25
+ *
26
+ * Unlike table creation, a failure here is not fatal: RLS stays enabled, so a
27
+ * table whose policies could not be applied fails **closed** (denies) rather
28
+ * than leaking rows. One collection's policy failing (e.g. a rule that
29
+ * references a table a real migration has not created yet) must not crash-loop
30
+ * the whole deployment and take the other collections' working routes down with
31
+ * it. Failures are reported loudly and per-table so the operator can see
32
+ * exactly which collection is not yet servable and why.
33
+ */
34
+ import { type CollectionConfig } from "@rebasepro/types";
35
+ import { planCollectionPolicies } from "./generate-postgres-ddl-logic";
36
+ import { readExistingSchema, type Queryable } from "./ensure-collection-tables";
37
+
38
+ export interface PolicyEnsureResult {
39
+ /** `CREATE POLICY` statements that ran successfully. */
40
+ policiesApplied: number;
41
+ /** Tables that had RLS enabled. */
42
+ tablesSecured: number;
43
+ /** Declared tables absent from the database — left to a real migration. */
44
+ skipped: { table: string; reason: string }[];
45
+ /** Tables whose RLS could not be fully applied (fail closed). */
46
+ failures: { table: string; error: string }[];
47
+ }
48
+
49
+ const isCreatePolicy = (statement: string): boolean => /^\s*CREATE POLICY/i.test(statement);
50
+
51
+ /**
52
+ * Bring the declared collections' RLS policies up to date. Returns what it did.
53
+ *
54
+ * Only tables that already exist are touched: the boot-time table creator runs
55
+ * first, so anything still missing is a table this additive path is not allowed
56
+ * to create (a junction, or a relation left to a migration). Enabling RLS on a
57
+ * non-existent table would error, so those are recorded as skipped, not failed.
58
+ */
59
+ export async function ensureCollectionPolicies(
60
+ client: Queryable,
61
+ collections: CollectionConfig[],
62
+ log?: (message: string) => void
63
+ ): Promise<PolicyEnsureResult> {
64
+ const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [] };
65
+
66
+ const plans = planCollectionPolicies(collections);
67
+ if (plans.length === 0) return result;
68
+
69
+ const schemas = Array.from(new Set(plans.map(p => p.schema)));
70
+ const existing = await readExistingSchema(client, schemas);
71
+
72
+ for (const plan of plans) {
73
+ if (!existing.tables.has(plan.qualified)) {
74
+ result.skipped.push({
75
+ table: plan.qualified,
76
+ reason: "table is not present in the database; create it with `rebase db push` / `rebase db migrate`"
77
+ });
78
+ continue;
79
+ }
80
+
81
+ try {
82
+ // Enable first: if a later policy statement fails, the table is left
83
+ // locked (deny-all for the user role) rather than open.
84
+ await client.query(plan.enableRls);
85
+ result.tablesSecured++;
86
+
87
+ let created = 0;
88
+ for (const statement of plan.policyStatements) {
89
+ await client.query(statement);
90
+ if (isCreatePolicy(statement)) {
91
+ result.policiesApplied++;
92
+ created++;
93
+ }
94
+ }
95
+ log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied`);
96
+ } catch (err) {
97
+ result.failures.push({
98
+ table: plan.qualified,
99
+ error: err instanceof Error ? err.message : String(err)
100
+ });
101
+ }
102
+ }
103
+
104
+ return result;
105
+ }
@@ -87,16 +87,112 @@ describe("planning an additive schema ensure", () => {
87
87
  expect(sql).not.toMatch(/NOT NULL/i);
88
88
  });
89
89
 
90
- it("leaves relation columns to a real migration rather than adding them without their key", () => {
91
- const withRelation = {
92
- ...posts,
93
- properties: {
94
- ...(posts as unknown as { properties: Record<string, unknown> }).properties,
95
- author: { name: "Author", type: "reference", target: () => posts }
90
+ // ── Relations ────────────────────────────────────────────────────────────
91
+ //
92
+ // These columns were once skipped outright, on the reasoning that a bare
93
+ // column without its foreign key would disagree with what `db push` later
94
+ // generated. On a managed tenant nothing pushes afterwards, so the table
95
+ // arrived without the column its own collection reads: every insert 400ed
96
+ // with `column "author_id" does not exist`. The answer is to emit the key
97
+ // too, from the same planner `db push` uses.
98
+
99
+ const authors = {
100
+ name: "Authors",
101
+ slug: "authors",
102
+ properties: {
103
+ id: { name: "ID", type: "string", isId: "uuid" },
104
+ name: { name: "Name", type: "string" }
105
+ }
106
+ } as unknown as CollectionConfig;
107
+
108
+ const postsWithAuthor = {
109
+ ...posts,
110
+ properties: {
111
+ ...(posts as unknown as { properties: Record<string, unknown> }).properties,
112
+ author: { name: "Author", type: "reference", path: "authors" }
113
+ }
114
+ } as unknown as CollectionConfig;
115
+
116
+ const tags = {
117
+ name: "Tags",
118
+ slug: "tags",
119
+ properties: {
120
+ id: { name: "ID", type: "number", isId: "increment" },
121
+ name: { name: "Name", type: "string" }
122
+ }
123
+ } as unknown as CollectionConfig;
124
+
125
+ const postsWithTags = {
126
+ ...posts,
127
+ properties: {
128
+ ...(posts as unknown as { properties: Record<string, unknown> }).properties,
129
+ tags: {
130
+ name: "Tags",
131
+ type: "relation",
132
+ relation: { kind: "manyToMany", target: () => tags, relationName: "tags" }
96
133
  }
97
- } as unknown as CollectionConfig;
98
- const plan = planCollectionSchemaEnsure([withRelation], withTable("public.posts", ["id"]));
99
- expect(plan.actions.some(a => a.target.endsWith(".author"))).toBe(false);
134
+ }
135
+ } as unknown as CollectionConfig;
136
+
137
+ it("adds a reference column, and the foreign key that makes it one", () => {
138
+ const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], withTable("public.posts", ["id"]));
139
+
140
+ expect(plan.actions.some(a => a.kind === "add-column" && a.target === "public.posts.author")).toBe(true);
141
+ const fk = plan.actions.find(a => a.kind === "add-constraint");
142
+ expect(fk?.target).toBe("public.posts.posts_author_fkey");
143
+ expect(fk?.sql).toMatch(/REFERENCES "public"\."authors" \("id"\)/);
144
+ });
145
+
146
+ it("orders every constraint after the columns and tables it depends on", () => {
147
+ const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], empty());
148
+ const kinds = plan.actions.map(a => a.kind);
149
+ expect(kinds.lastIndexOf("add-column")).toBeLessThan(kinds.indexOf("add-constraint"));
150
+ expect(kinds.lastIndexOf("create-table")).toBeLessThan(kinds.indexOf("add-constraint"));
151
+ });
152
+
153
+ it("skips a foreign key the database already has, since ADD CONSTRAINT has no IF NOT EXISTS", () => {
154
+ const existing: ExistingSchema = {
155
+ tables: new Map([
156
+ ["public.posts", new Set(["id", "author"])],
157
+ ["public.authors", new Set(["id", "name"])]
158
+ ]),
159
+ enums: new Set(["public.posts_status"]),
160
+ constraints: new Set(["public.posts.posts_author_fkey"])
161
+ };
162
+ const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], existing);
163
+ expect(plan.actions.some(a => a.kind === "add-constraint")).toBe(false);
164
+ });
165
+
166
+ it("creates the junction table behind a many-to-many, keyed on both endpoints", () => {
167
+ const plan = planCollectionSchemaEnsure([postsWithTags, tags], empty());
168
+ const junction = plan.actions.find(a => a.kind === "create-table" && a.target === "public.posts_tags");
169
+
170
+ expect(junction).toBeDefined();
171
+ // The endpoint key types have to match the primary keys they reference:
172
+ // posts is a uuid, tags an auto-increment integer.
173
+ expect(junction!.sql).toMatch(/"post_id" UUID NOT NULL/);
174
+ expect(junction!.sql).toMatch(/"tag_id" INTEGER NOT NULL/);
175
+ expect(junction!.sql).toMatch(/PRIMARY KEY \("post_id", "tag_id"\)/);
176
+
177
+ const fks = plan.actions.filter(a => a.kind === "add-constraint").map(a => a.target);
178
+ expect(fks).toContain("public.posts_tags.posts_tags_post_id_fkey");
179
+ expect(fks).toContain("public.posts_tags.posts_tags_tag_id_fkey");
180
+ });
181
+
182
+ it("leaves an existing junction table alone", () => {
183
+ const existing: ExistingSchema = {
184
+ tables: new Map([
185
+ ["public.posts", new Set(["id", "title", "views", "status"])],
186
+ ["public.tags", new Set(["id", "name"])],
187
+ ["public.posts_tags", new Set(["post_id", "tag_id"])]
188
+ ]),
189
+ enums: new Set(["public.posts_status"]),
190
+ constraints: new Set([
191
+ "public.posts_tags.posts_tags_post_id_fkey",
192
+ "public.posts_tags.posts_tags_tag_id_fkey"
193
+ ])
194
+ };
195
+ expect(planCollectionSchemaEnsure([postsWithTags, tags], existing).actions).toEqual([]);
100
196
  });
101
197
  });
102
198