@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) 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 +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/connection-BuZ97wsr.js +250 -0
  10. package/dist/connection-BuZ97wsr.js.map +1 -0
  11. package/dist/connection.d.ts +42 -0
  12. package/dist/ensure-collection-policies-ViG8XiPn.js +57 -0
  13. package/dist/ensure-collection-policies-ViG8XiPn.js.map +1 -0
  14. package/dist/ensure-collection-tables-CBQdOETu.js +650 -0
  15. package/dist/ensure-collection-tables-CBQdOETu.js.map +1 -0
  16. package/dist/index.es.js +1231 -10031
  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/auth-schema.d.ts +83 -144
  21. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  22. package/dist/schema/ensure-collection-tables.d.ts +44 -2
  23. package/dist/schema/generate-postgres-ddl-logic.d.ts +135 -1
  24. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  25. package/dist/schema/introspect-db-logic.d.ts +94 -5
  26. package/dist/schema/introspect-db-queries.d.ts +119 -0
  27. package/dist/schema/introspect-db-structure.d.ts +263 -0
  28. package/dist/schema/introspect-db-types.d.ts +11 -0
  29. package/dist/services/FetchService.d.ts +4 -1
  30. package/dist/services/RelationService.d.ts +24 -1
  31. package/dist/services/channel-bus/index.d.ts +1 -7
  32. package/dist/services/collection-helpers.d.ts +36 -2
  33. package/dist/services/dataService.d.ts +3 -1
  34. package/dist/services/row-pipeline.d.ts +1 -1
  35. package/dist/{src-BbFOPJ1S.js → src-DlPBctw_.js} +299 -173
  36. package/dist/src-DlPBctw_.js.map +1 -0
  37. package/dist/{src-Zqwaw3P5.js → src-DoU9yPqq.js} +3 -159
  38. package/dist/src-DoU9yPqq.js.map +1 -0
  39. package/dist/utils/connection-string.d.ts +29 -0
  40. package/dist/utils/drizzle-conditions.d.ts +5 -4
  41. package/dist/utils/pg-error-utils.d.ts +35 -0
  42. package/dist/websocket-B2LsrINK.js +530 -0
  43. package/dist/websocket-B2LsrINK.js.map +1 -0
  44. package/package.json +14 -14
  45. package/src/PostgresAdapter.ts +21 -2
  46. package/src/PostgresBackendDriver.ts +4 -0
  47. package/src/PostgresBootstrapper.ts +192 -33
  48. package/src/auth/ensure-tables.ts +164 -9
  49. package/src/auth/services.ts +24 -2
  50. package/src/backup/backup-cli.ts +41 -2
  51. package/src/backup/backup-service.ts +38 -5
  52. package/src/backup/pg-tools.ts +96 -3
  53. package/src/cli-helpers.ts +70 -0
  54. package/src/cli.ts +44 -26
  55. package/src/collections/validate-relations.ts +15 -0
  56. package/src/connection.ts +73 -0
  57. package/src/data-transformer.ts +9 -3
  58. package/src/databasePoolManager.ts +5 -2
  59. package/src/schema/auth-schema.ts +30 -19
  60. package/src/schema/ensure-collection-policies.ts +105 -0
  61. package/src/schema/ensure-collection-tables.test.ts +105 -9
  62. package/src/schema/ensure-collection-tables.ts +220 -32
  63. package/src/schema/generate-drizzle-schema-logic.ts +42 -6
  64. package/src/schema/generate-postgres-ddl-logic.ts +382 -19
  65. package/src/schema/introspect-db-constraints.ts +385 -0
  66. package/src/schema/introspect-db-inference.ts +18 -8
  67. package/src/schema/introspect-db-logic.ts +385 -71
  68. package/src/schema/introspect-db-queries.ts +326 -0
  69. package/src/schema/introspect-db-structure.ts +670 -0
  70. package/src/schema/introspect-db-types.ts +56 -0
  71. package/src/schema/introspect-db.ts +37 -80
  72. package/src/schema/introspect-runtime.test.ts +56 -8
  73. package/src/schema/introspect-runtime.ts +31 -9
  74. package/src/security/policy-drift.test.ts +11 -3
  75. package/src/services/BranchService.ts +66 -28
  76. package/src/services/FetchService.ts +90 -14
  77. package/src/services/PersistService.ts +20 -6
  78. package/src/services/RelationService.ts +249 -48
  79. package/src/services/channel-bus/index.ts +0 -9
  80. package/src/services/collection-helpers.ts +69 -3
  81. package/src/services/dataService.ts +3 -1
  82. package/src/services/realtimeService.ts +3 -3
  83. package/src/services/row-pipeline.ts +1 -1
  84. package/src/utils/connection-string.ts +58 -0
  85. package/src/utils/drizzle-conditions.ts +31 -6
  86. package/src/utils/pg-error-utils.ts +65 -0
  87. package/src/websocket.ts +18 -9
  88. package/dist/chunk-DSJWtz9O.js +0 -40
  89. package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
  90. package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
  91. package/dist/src-BbFOPJ1S.js.map +0 -1
  92. package/dist/src-Zqwaw3P5.js.map +0 -1
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
@@ -1,8 +1,19 @@
1
- import { pgSchema, pgTable, varchar, uuid, timestamp, boolean, jsonb, text, unique, index } from "drizzle-orm/pg-core";
1
+ import { pgSchema, pgTable, uuid, timestamp, boolean, jsonb, text, unique, index } from "drizzle-orm/pg-core";
2
2
  import { relations } from "drizzle-orm";
3
3
 
4
4
  /**
5
5
  * Factory function to dynamically create the auth tables bound to the specified schema names.
6
+ *
7
+ * This module builds queries; it does not create tables. `ensureAuthTablesExist`
8
+ * owns the DDL, which makes everything here a *claim* about a database it cannot
9
+ * enforce — and the claims drifted. Every column below was declared
10
+ * `varchar(n)` while the DDL created it as `TEXT`: `user_agent` as varchar(500),
11
+ * `ip_address` as varchar(45), `secret_encrypted` as varchar(500), every
12
+ * `token_hash` as varchar(255). None of it was true of any database this
13
+ * framework ever provisioned. Harmless at runtime — drizzle does not enforce a
14
+ * length client-side, so the widths only ever misled the next reader — but a
15
+ * schema module that describes columns that do not exist is worse than no
16
+ * schema module. They are `text` here now because they are TEXT there.
6
17
  */
7
18
  export function createAuthSchema(usersSchemaName = "rebase") {
8
19
  const usersSchema = usersSchemaName === "public" ? null : pgSchema(usersSchemaName);
@@ -15,12 +26,12 @@ export function createAuthSchema(usersSchemaName = "rebase") {
15
26
  */
16
27
  const users = usersTableCreator("users", {
17
28
  id: uuid("id").defaultRandom().primaryKey(),
18
- email: varchar("email", { length: 255 }).notNull().unique(),
19
- passwordHash: varchar("password_hash", { length: 255 }), // NULL for OAuth-only users
20
- displayName: varchar("display_name", { length: 255 }),
21
- photoUrl: varchar("photo_url", { length: 500 }),
29
+ email: text("email").notNull().unique(),
30
+ passwordHash: text("password_hash"), // NULL for OAuth-only users
31
+ displayName: text("display_name"),
32
+ photoUrl: text("photo_url"),
22
33
  emailVerified: boolean("email_verified").default(false).notNull(),
23
- emailVerificationToken: varchar("email_verification_token", { length: 255 }),
34
+ emailVerificationToken: text("email_verification_token"),
24
35
  emailVerificationSentAt: timestamp("email_verification_sent_at"),
25
36
  isAnonymous: boolean("is_anonymous").default(false).notNull(),
26
37
  roles: text("roles").array().default([]).notNull(),
@@ -65,7 +76,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
65
76
  id: uuid("id").defaultRandom().primaryKey(),
66
77
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
67
78
  sessionId: uuid("session_id").defaultRandom().notNull(),
68
- tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
79
+ tokenHash: text("token_hash").notNull().unique(),
69
80
  expiresAt: timestamp("expires_at").notNull(),
70
81
  revoked: boolean("revoked").default(false).notNull(),
71
82
  rotatedAt: timestamp("rotated_at"),
@@ -76,8 +87,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
76
87
  * that rotates immediately after it.
77
88
  */
78
89
  sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
79
- userAgent: varchar("user_agent", { length: 500 }),
80
- ipAddress: varchar("ip_address", { length: 45 }),
90
+ userAgent: text("user_agent"),
91
+ ipAddress: text("ip_address"),
81
92
  createdAt: timestamp("created_at").defaultNow().notNull()
82
93
  }, (table) => ({
83
94
  sessionIdx: index("idx_refresh_tokens_session").on(table.sessionId)
@@ -89,7 +100,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
89
100
  const passwordResetTokens = tableCreator("password_reset_tokens", {
90
101
  id: uuid("id").defaultRandom().primaryKey(),
91
102
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
92
- tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
103
+ tokenHash: text("token_hash").notNull().unique(),
93
104
  expiresAt: timestamp("expires_at").notNull(),
94
105
  usedAt: timestamp("used_at"),
95
106
  createdAt: timestamp("created_at").defaultNow().notNull()
@@ -99,7 +110,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
99
110
  * App config - key/value store for custom settings
100
111
  */
101
112
  const appConfig = tableCreator("app_config", {
102
- key: varchar("key", { length: 100 }).primaryKey(),
113
+ key: text("key").primaryKey(),
103
114
  value: jsonb("value").notNull(),
104
115
  updatedAt: timestamp("updated_at").defaultNow().notNull()
105
116
  });
@@ -110,8 +121,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
110
121
  const userIdentities = tableCreator("user_identities", {
111
122
  id: uuid("id").defaultRandom().primaryKey(),
112
123
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
113
- provider: varchar("provider", { length: 50 }).notNull(), // e.g. 'google', 'linkedin'
114
- providerId: varchar("provider_id", { length: 255 }).notNull(),
124
+ provider: text("provider").notNull(), // e.g. 'google', 'linkedin'
125
+ providerId: text("provider_id").notNull(),
115
126
  profileData: jsonb("profile_data"),
116
127
  createdAt: timestamp("created_at").defaultNow().notNull(),
117
128
  updatedAt: timestamp("updated_at").defaultNow().notNull()
@@ -125,9 +136,9 @@ export function createAuthSchema(usersSchemaName = "rebase") {
125
136
  const mfaFactors = tableCreator("mfa_factors", {
126
137
  id: uuid("id").defaultRandom().primaryKey(),
127
138
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
128
- factorType: varchar("factor_type", { length: 20 }).notNull(), // 'totp'
129
- secretEncrypted: varchar("secret_encrypted", { length: 500 }).notNull(),
130
- friendlyName: varchar("friendly_name", { length: 255 }),
139
+ factorType: text("factor_type").notNull(), // 'totp'
140
+ secretEncrypted: text("secret_encrypted").notNull(),
141
+ friendlyName: text("friendly_name"),
131
142
  verified: boolean("verified").default(false).notNull(),
132
143
  createdAt: timestamp("created_at").defaultNow().notNull(),
133
144
  updatedAt: timestamp("updated_at").defaultNow().notNull()
@@ -141,7 +152,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
141
152
  factorId: uuid("factor_id").notNull().references(() => mfaFactors.id, { onDelete: "cascade" }),
142
153
  createdAt: timestamp("created_at").defaultNow().notNull(),
143
154
  verifiedAt: timestamp("verified_at"),
144
- ipAddress: varchar("ip_address", { length: 45 }),
155
+ ipAddress: text("ip_address"),
145
156
  expiresAt: timestamp("expires_at").notNull()
146
157
  });
147
158
 
@@ -151,7 +162,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
151
162
  const recoveryCodes = tableCreator("recovery_codes", {
152
163
  id: uuid("id").defaultRandom().primaryKey(),
153
164
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
154
- codeHash: varchar("code_hash", { length: 255 }).notNull(),
165
+ codeHash: text("code_hash").notNull(),
155
166
  usedAt: timestamp("used_at"),
156
167
  createdAt: timestamp("created_at").defaultNow().notNull()
157
168
  });
@@ -162,7 +173,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
162
173
  const magicLinkTokens = tableCreator("magic_link_tokens", {
163
174
  id: uuid("id").defaultRandom().primaryKey(),
164
175
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
165
- tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
176
+ tokenHash: text("token_hash").notNull().unique(),
166
177
  expiresAt: timestamp("expires_at").notNull(),
167
178
  usedAt: timestamp("used_at"),
168
179
  createdAt: timestamp("created_at").defaultNow().notNull()
@@ -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