@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.1-canary.g18cfeb7

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 (112) hide show
  1. package/dist/PostgresBackendDriver.d.ts +49 -2
  2. package/dist/PostgresBootstrapper.d.ts +25 -1
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-BbFOPJ1S.js → auth-users-columns-Do9mw5Y5.js} +602 -200
  5. package/dist/auth-users-columns-Do9mw5Y5.js.map +1 -0
  6. package/dist/backup/backup-service.d.ts +10 -1
  7. package/dist/backup/pg-tools.d.ts +47 -0
  8. package/dist/backup-service-Bww-Lg0s.js +8999 -0
  9. package/dist/backup-service-Bww-Lg0s.js.map +1 -0
  10. package/dist/cli-helpers.d.ts +40 -1
  11. package/dist/connection-BuZ97wsr.js +250 -0
  12. package/dist/connection-BuZ97wsr.js.map +1 -0
  13. package/dist/connection.d.ts +42 -0
  14. package/dist/ensure-collection-policies-DMUdRQdM.js +57 -0
  15. package/dist/ensure-collection-policies-DMUdRQdM.js.map +1 -0
  16. package/dist/ensure-collection-tables-DSIxvLLD.js +694 -0
  17. package/dist/ensure-collection-tables-DSIxvLLD.js.map +1 -0
  18. package/dist/index.es.js +1701 -10167
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/policy-CPkCqVTz.js +105 -0
  21. package/dist/policy-CPkCqVTz.js.map +1 -0
  22. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  23. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  24. package/dist/schema/auth-schema.d.ts +83 -144
  25. package/dist/schema/auth-users-columns.d.ts +97 -0
  26. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  27. package/dist/schema/ensure-collection-tables.d.ts +45 -3
  28. package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
  29. package/dist/schema/generate-postgres-ddl-logic.d.ts +137 -3
  30. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  31. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  32. package/dist/schema/introspect-db-logic.d.ts +94 -5
  33. package/dist/schema/introspect-db-queries.d.ts +119 -0
  34. package/dist/schema/introspect-db-structure.d.ts +263 -0
  35. package/dist/schema/introspect-db-types.d.ts +11 -0
  36. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  37. package/dist/security/rls-enforcement.d.ts +53 -2
  38. package/dist/services/FetchService.d.ts +14 -8
  39. package/dist/services/RelationService.d.ts +24 -1
  40. package/dist/services/channel-bus/index.d.ts +1 -7
  41. package/dist/services/collection-helpers.d.ts +36 -2
  42. package/dist/services/dataService.d.ts +5 -1
  43. package/dist/services/realtimeService.d.ts +25 -21
  44. package/dist/services/row-pipeline.d.ts +1 -1
  45. package/dist/{src-Zqwaw3P5.js → src-C_wvdMnl.js} +78 -145
  46. package/dist/src-C_wvdMnl.js.map +1 -0
  47. package/dist/utils/connection-string.d.ts +29 -0
  48. package/dist/utils/drizzle-conditions.d.ts +5 -4
  49. package/dist/utils/pg-error-utils.d.ts +35 -0
  50. package/dist/websocket-D0TBU3ia.js +530 -0
  51. package/dist/websocket-D0TBU3ia.js.map +1 -0
  52. package/package.json +17 -16
  53. package/src/PostgresAdapter.ts +21 -2
  54. package/src/PostgresBackendDriver.ts +169 -3
  55. package/src/PostgresBootstrapper.ts +233 -35
  56. package/src/auth/ensure-tables.ts +335 -81
  57. package/src/auth/services.ts +24 -2
  58. package/src/backup/backup-cli.ts +41 -2
  59. package/src/backup/backup-service.ts +38 -5
  60. package/src/backup/pg-tools.ts +96 -3
  61. package/src/cli-helpers.ts +92 -9
  62. package/src/cli.ts +219 -56
  63. package/src/collections/validate-relations.ts +136 -14
  64. package/src/connection.ts +73 -0
  65. package/src/data-transformer.ts +22 -6
  66. package/src/databasePoolManager.ts +5 -2
  67. package/src/history/ensure-history-table.ts +7 -0
  68. package/src/schema/auth-schema.ts +30 -19
  69. package/src/schema/auth-users-columns.ts +131 -0
  70. package/src/schema/doctor.ts +7 -5
  71. package/src/schema/ensure-collection-policies.ts +105 -0
  72. package/src/schema/ensure-collection-tables.test.ts +105 -9
  73. package/src/schema/ensure-collection-tables.ts +301 -44
  74. package/src/schema/generate-drizzle-schema-logic.ts +49 -7
  75. package/src/schema/generate-postgres-ddl-logic.ts +474 -27
  76. package/src/schema/generated-schema-staleness.ts +169 -0
  77. package/src/schema/introspect-db-constraints.ts +385 -0
  78. package/src/schema/introspect-db-inference.ts +18 -8
  79. package/src/schema/introspect-db-logic.ts +385 -71
  80. package/src/schema/introspect-db-queries.ts +326 -0
  81. package/src/schema/introspect-db-structure.ts +670 -0
  82. package/src/schema/introspect-db-types.ts +56 -0
  83. package/src/schema/introspect-db.ts +37 -80
  84. package/src/schema/introspect-runtime.test.ts +56 -8
  85. package/src/schema/introspect-runtime.ts +31 -9
  86. package/src/schema/non-sql-collections.test.ts +131 -0
  87. package/src/schema/rls-bootstrap-sql.ts +288 -0
  88. package/src/security/anonymous-grants.test.ts +4 -2
  89. package/src/security/policy-drift.test.ts +11 -3
  90. package/src/security/rls-enforcement.ts +141 -3
  91. package/src/services/BranchService.ts +71 -28
  92. package/src/services/FetchService.ts +100 -107
  93. package/src/services/PersistService.ts +34 -7
  94. package/src/services/RelationService.ts +249 -48
  95. package/src/services/channel-bus/index.ts +0 -9
  96. package/src/services/channel-history.ts +8 -0
  97. package/src/services/channel-presence.ts +6 -0
  98. package/src/services/collection-helpers.ts +69 -3
  99. package/src/services/dataService.ts +5 -1
  100. package/src/services/realtimeService.ts +38 -35
  101. package/src/services/row-pipeline.ts +1 -1
  102. package/src/utils/connection-string.ts +58 -0
  103. package/src/utils/drizzle-conditions.ts +31 -6
  104. package/src/utils/pg-error-utils.ts +65 -0
  105. package/src/websocket.ts +18 -9
  106. package/dist/chunk-DSJWtz9O.js +0 -40
  107. package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
  108. package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
  109. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  110. package/dist/src-BbFOPJ1S.js.map +0 -1
  111. package/dist/src-Zqwaw3P5.js.map +0 -1
  112. package/src/schema/auth-bootstrap-sql.ts +0 -47
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,
@@ -3,6 +3,7 @@ import { AnyPgColumn } from "drizzle-orm/pg-core";
3
3
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
4
  import { CollectionConfig, Properties, Property, ResolvedRelation, RelationProperty, Vector, BinaryProperty, hasForeignKeyOnTarget, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, type ResolvedVia } from "@rebasepro/types";
5
5
  import { getTableName, resolveCollectionRelations, findRelation, createRelationRef, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from "@rebasepro/common";
6
+ import { isPrototypePollutingKey } from "@rebasepro/utils";
6
7
  import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
7
8
  import { DrizzleConditionBuilder } from "./utils/drizzle-conditions";
8
9
  import { getPrimaryKeys, buildCompositeId } from "./services/collection-helpers";
@@ -68,9 +69,15 @@ export function sanitizeAndConvertDates(obj: unknown): unknown {
68
69
  if (typeof obj === "object") {
69
70
  const newObj: Record<string, unknown> = {};
70
71
  for (const key in obj) {
71
- if (Object.prototype.hasOwnProperty.call(obj, key)) {
72
- newObj[key] = sanitizeAndConvertDates((obj as Record<string, unknown>)[key]);
73
- }
72
+ if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
73
+ // `JSON.parse` creates `__proto__` as an *own* property, so it gets
74
+ // past the check above — and `newObj[key] = …` then invokes the
75
+ // prototype setter instead of creating a property. The row's
76
+ // prototype becomes whatever the request body supplied, so
77
+ // `row.isAdmin` answers `true` while `Object.keys(row)` shows
78
+ // nothing of the sort. No column can be reached by these names.
79
+ if (isPrototypePollutingKey(key)) continue;
80
+ newObj[key] = sanitizeAndConvertDates((obj as Record<string, unknown>)[key]);
74
81
  }
75
82
  return newObj;
76
83
  }
@@ -126,6 +133,9 @@ joinPathRelationUpdates: [] };
126
133
  });
127
134
 
128
135
  for (const [key, value] of Object.entries(row)) {
136
+ // Same reasoning as `sanitizeAndConvertDates`: these keys come from the
137
+ // request body, and no column answers to them.
138
+ if (isPrototypePollutingKey(key)) continue;
129
139
  const property = properties[key as keyof M] as Property;
130
140
 
131
141
  // Coerce empty strings to null for any field that acts as a foreign key
@@ -328,9 +338,15 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
328
338
  const targetCollection = relation.target();
329
339
  const targetTable = registry.getTable(getTableName(targetCollection));
330
340
  const pks = getPrimaryKeys(collection, registry!);
331
- const currentId = buildCompositeId(data, pks);
332
-
333
- if (targetTable && currentId) {
341
+ // What the target's foreign key holds. Ordinarily this
342
+ // row's id; for a link on a natural key, the value of
343
+ // the column it points at — which is on the row already,
344
+ // so this costs nothing extra.
345
+ const currentId = relation.sourceKey
346
+ ? (data as Record<string, unknown>)[relation.sourceKey] as string | number | undefined
347
+ : buildCompositeId(data, pks);
348
+
349
+ if (targetTable && currentId !== undefined && currentId !== null && currentId !== "") {
334
350
  const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget as keyof typeof targetTable] as AnyPgColumn;
335
351
  if (foreignKeyColumn) {
336
352
  // 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,6 +1,7 @@
1
1
  import { sql } from "drizzle-orm";
2
2
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
3
  import { logger } from "@rebasepro/server";
4
+ import { revokeInternalTableSql } from "@rebasepro/common";
4
5
 
5
6
  /**
6
7
  * Auto-create the row history table if it doesn't exist.
@@ -38,6 +39,12 @@ export async function ensureHistoryTableExists(db: NodePgDatabase): Promise<void
38
39
  ON rebase.entity_history(table_name, entity_id, updated_at DESC)
39
40
  `);
40
41
 
42
+ // Every previous value of every audited row, in one table with no RLS
43
+ // and no tenant scoping — so a readable copy defeats the row policies on
44
+ // the tables it shadows. The driver's schema-wide grant reaches it
45
+ // (created here, after that grant ran), so take it back.
46
+ await db.execute(sql.raw(revokeInternalTableSql("rebase", "entity_history")));
47
+
41
48
  logger.info("✅ Entity history table ready");
42
49
  } catch (error) {
43
50
  logger.error("❌ Failed to create row history table", { error: error });
@@ -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,131 @@
1
+ /**
2
+ * The one description of what an auth user table's columns must be.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Three different code paths create `rebase.users`, and before this module they
7
+ * disagreed about it:
8
+ *
9
+ * | column | `db push` (generator) | `ensureAuthTablesExist` | `ensureCollectionTables` |
10
+ * |------------------|-----------------------|-------------------------|--------------------------|
11
+ * | `email` | `TEXT UNIQUE NOT NULL`| `TEXT NOT NULL` + CHECK | `TEXT` (nullable) |
12
+ * | `roles` | `TEXT[]` | `NOT NULL DEFAULT '{}'` | nullable, no default |
13
+ * | `email_verified` | `BOOLEAN` | `NOT NULL DEFAULT FALSE`| nullable |
14
+ * | `created_at` | `DEFAULT now()` | `NOT NULL DEFAULT NOW()`| no default |
15
+ *
16
+ * They are all `CREATE TABLE IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`, so
17
+ * whichever ran first decided the table and the other two silently no-op'd.
18
+ * Boot order therefore chose the constraints: a managed deploy (collection
19
+ * tables first) got a users table whose `email` was nullable and whose
20
+ * `email_verified` had no default, while the same project pushed from a
21
+ * checkout got the strict one. In the same table, `is_anonymous` came out
22
+ * `NOT NULL DEFAULT false` — because *that* column existed in only one of the
23
+ * three lists, so its owner's definition won by default.
24
+ *
25
+ * A second consequence was drift in the other direction. The scaffold's
26
+ * `users.ts` describes 12 columns; auth needs 14. `db push` is declarative and
27
+ * builds its desired state from the collection alone, so the two columns only
28
+ * auth knows about (`is_anonymous`, `tokens_valid_after`) read as unmanaged
29
+ * drift, and a push run after the server had booted once planned to DROP them.
30
+ *
31
+ * Both problems are the same problem: no single place said what this table is.
32
+ * This is that place. Every creator asks here first and only then falls back to
33
+ * the collection's own property definitions, so a column auth owns has one
34
+ * definition regardless of who gets there first, and a column the developer
35
+ * added to their users collection still behaves like any other field.
36
+ *
37
+ * ## What belongs here
38
+ *
39
+ * Only columns the auth services read or write. A developer's own additions to
40
+ * their users collection (`bio`, `stripe_customer_id`) are ordinary columns and
41
+ * must NOT be listed — they are generated from the collection like every other
42
+ * field, and listing them here would freeze a user's schema.
43
+ *
44
+ * `id` is deliberately absent: its type comes from the collection's id property
45
+ * (uuid / increment / text) and each creator already derives it.
46
+ *
47
+ * Keep in step with {@link AUTH_SCHEMA_VERSION} when a change here makes an
48
+ * older runtime unable to work against a migrated table.
49
+ */
50
+
51
+ /**
52
+ * A column auth owns.
53
+ *
54
+ * Structured rather than one SQL string because the same facts are needed in
55
+ * three grammars — a `CREATE TABLE` column list, an `ADD COLUMN IF NOT EXISTS`,
56
+ * and a pair of `ALTER COLUMN … SET DEFAULT` / `SET NOT NULL` reconciles for a
57
+ * table that already exists with the wrong shape. Parsing a string back apart
58
+ * for the third of those is how the copies drifted in the first place.
59
+ */
60
+ export interface AuthUsersColumn {
61
+ /** Physical column name. */
62
+ column: string;
63
+ /** Postgres type. */
64
+ type: string;
65
+ /** Default expression, verbatim, or absent for no default. */
66
+ default?: string;
67
+ /** Whether the column is NOT NULL. */
68
+ notNull?: boolean;
69
+ }
70
+
71
+ /**
72
+ * `email` is NOT NULL on purpose, and the anonymous sign-in route depends on it
73
+ * — it synthesizes `anon_<32 hex>@anonymous.local` rather than inserting NULL.
74
+ * The 320-char bound (RFC 5321) is a CHECK rather than a `VARCHAR(n)`, added
75
+ * separately by `ensureAuthTablesExist` so it can be `NOT VALID` on an adopted
76
+ * table that already holds a longer row.
77
+ */
78
+ export const AUTH_USERS_COLUMNS: readonly AuthUsersColumn[] = [
79
+ { column: "email", type: "TEXT", notNull: true },
80
+ { column: "display_name", type: "TEXT" },
81
+ { column: "photo_url", type: "TEXT" },
82
+ { column: "roles", type: "TEXT[]", default: "'{}'", notNull: true },
83
+ { column: "password_hash", type: "TEXT" },
84
+ { column: "email_verified", type: "BOOLEAN", default: "FALSE", notNull: true },
85
+ { column: "email_verification_token", type: "TEXT" },
86
+ { column: "email_verification_sent_at", type: "TIMESTAMP WITH TIME ZONE" },
87
+ { column: "is_anonymous", type: "BOOLEAN", default: "FALSE", notNull: true },
88
+ { column: "metadata", type: "JSONB", default: "'{}'", notNull: true },
89
+ { column: "tokens_valid_after", type: "TIMESTAMP WITH TIME ZONE" },
90
+ { column: "created_at", type: "TIMESTAMP WITH TIME ZONE", default: "NOW()", notNull: true },
91
+ { column: "updated_at", type: "TIMESTAMP WITH TIME ZONE", default: "NOW()", notNull: true }
92
+ ];
93
+
94
+ const BY_COLUMN = new Map(AUTH_USERS_COLUMNS.map(c => [c.column, c]));
95
+
96
+ /** Type + inline constraints, as they appear after the column name. */
97
+ export function authUsersColumnSql(spec: AuthUsersColumn): string {
98
+ return [
99
+ spec.type,
100
+ spec.default !== undefined ? `DEFAULT ${spec.default}` : "",
101
+ spec.notNull ? "NOT NULL" : ""
102
+ ].filter(Boolean).join(" ");
103
+ }
104
+
105
+ /**
106
+ * The auth-owned definition for a physical column name, or `undefined` when
107
+ * auth does not own it.
108
+ *
109
+ * Callers pass the RESOLVED column name (after `columnName` mapping), because
110
+ * that is the only name the three creators agree on: the scaffold's users
111
+ * collection spells the property `displayName` and the column `display_name`.
112
+ */
113
+ export function authUsersColumnDefinition(column: string): string | undefined {
114
+ const spec = BY_COLUMN.get(column);
115
+ return spec ? authUsersColumnSql(spec) : undefined;
116
+ }
117
+
118
+ /**
119
+ * Whether a collection is an auth collection, i.e. whether the definitions in
120
+ * this module apply to its table at all.
121
+ *
122
+ * Duplicated in shape from `@rebasepro/common`'s policy defaults on purpose:
123
+ * that one takes a `CollectionConfig`, this one is called from DDL code paths
124
+ * that hold looser objects, and both spellings must accept `auth: true` as well
125
+ * as `auth: { enabled: true }`.
126
+ */
127
+ export function isAuthCollection(collection: unknown): boolean {
128
+ const auth = (collection as { auth?: unknown } | undefined)?.auth;
129
+ if (auth === true) return true;
130
+ return typeof auth === "object" && auth !== null && (auth as { enabled?: unknown }).enabled === true;
131
+ }
@@ -15,7 +15,7 @@ import chalk from "chalk";
15
15
  import { CollectionConfig, isPostgresCollectionConfig, Property, NumberProperty, StringProperty, DateProperty, ArrayProperty, MapProperty, RelationProperty, type ResolvedManyToMany, type ResolvedBelongsTo, isManyToMany } from "@rebasepro/types";
16
16
  import { generateSchema } from "./generate-drizzle-schema-logic";
17
17
  import { generateTypedefs } from "@rebasepro/codegen";
18
- import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
18
+ import { getTableName, resolveCollectionRelations, findRelation, relationalCollections } from "@rebasepro/common";
19
19
  import { toSnakeCase } from "@rebasepro/utils";
20
20
  import { logger, loadCollectionsFromDirectory } from "@rebasepro/server";
21
21
 
@@ -164,8 +164,10 @@ export async function checkCollectionsVsSchema(
164
164
  issues };
165
165
  }
166
166
 
167
- // Re-generate schema in-memory and compare with file on disk
168
- const postgresCollections = collections.filter(isPostgresCollectionConfig);
167
+ // Re-generate schema in-memory and compare with file on disk. Only the
168
+ // collections the generator itself will emit — a Firestore collection has
169
+ // no table in the generated file and must not be reported as missing one.
170
+ const postgresCollections = relationalCollections(collections);
169
171
  if (postgresCollections.length === 0) {
170
172
  return { passed: true,
171
173
  issues };
@@ -292,7 +294,7 @@ export async function checkCollectionsVsDatabase(
292
294
  const schemas = Array.from(new Set([
293
295
  "public",
294
296
  "rebase",
295
- ...collections
297
+ ...relationalCollections(collections)
296
298
  .filter(isPostgresCollectionConfig)
297
299
  .map(c => c.schema)
298
300
  .filter((s): s is string => !!s)
@@ -383,7 +385,7 @@ export async function checkCollectionsVsDatabase(
383
385
 
384
386
  // ── Compare each collection against the database ─────────────────
385
387
 
386
- const postgresCollections = collections.filter(isPostgresCollectionConfig);
388
+ const postgresCollections = relationalCollections(collections);
387
389
 
388
390
  for (const collection of postgresCollections) {
389
391
  const tableName = getTableName(collection);
@@ -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
+ }