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

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 (50) hide show
  1. package/dist/PostgresBootstrapper.d.ts +8 -0
  2. package/dist/auth/services.d.ts +16 -0
  3. package/dist/backup-service-DH9kPg-E.js +8866 -0
  4. package/dist/backup-service-DH9kPg-E.js.map +1 -0
  5. package/dist/collections/buildRegistry.d.ts +1 -1
  6. package/dist/connection-B5Wndbr1.js +196 -0
  7. package/dist/connection-B5Wndbr1.js.map +1 -0
  8. package/dist/ensure-collection-policies-CT-zIUWA.js +57 -0
  9. package/dist/ensure-collection-policies-CT-zIUWA.js.map +1 -0
  10. package/dist/{ensure-collection-tables-DGMYK0fr.js → ensure-collection-tables-Vu-GRELM.js} +83 -6
  11. package/dist/ensure-collection-tables-Vu-GRELM.js.map +1 -0
  12. package/dist/history/HistoryService.d.ts +9 -29
  13. package/dist/index.es.js +773 -9607
  14. package/dist/index.es.js.map +1 -1
  15. package/dist/schema/auth-schema.d.ts +83 -144
  16. package/dist/schema/dynamic-tables.d.ts +1 -1
  17. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  18. package/dist/schema/generate-postgres-ddl-logic.d.ts +43 -1
  19. package/dist/schema/introspect-runtime.d.ts +1 -1
  20. package/dist/services/FetchService.d.ts +36 -1
  21. package/dist/services/row-pipeline.d.ts +3 -1
  22. package/dist/{src-3VmUJ8Xn.js → src-DihrDFuP.js} +354 -165
  23. package/dist/src-DihrDFuP.js.map +1 -0
  24. package/dist/{src-D5xBTl32.js → src-DoU9yPqq.js} +79 -189
  25. package/dist/src-DoU9yPqq.js.map +1 -0
  26. package/dist/utils/drizzle-conditions.d.ts +157 -3
  27. package/dist/utils/pg-error-utils.d.ts +25 -3
  28. package/dist/websocket-BKcGvILX.js +528 -0
  29. package/dist/websocket-BKcGvILX.js.map +1 -0
  30. package/package.json +14 -14
  31. package/src/PostgresAdapter.ts +14 -0
  32. package/src/PostgresBootstrapper.ts +127 -18
  33. package/src/auth/ensure-tables.ts +164 -9
  34. package/src/auth/services.ts +21 -2
  35. package/src/collections/buildRegistry.ts +1 -1
  36. package/src/history/HistoryService.ts +13 -31
  37. package/src/schema/auth-schema.ts +30 -19
  38. package/src/schema/dynamic-tables.ts +1 -1
  39. package/src/schema/ensure-collection-policies.ts +105 -0
  40. package/src/schema/generate-drizzle-schema-logic.ts +17 -5
  41. package/src/schema/generate-postgres-ddl-logic.ts +100 -13
  42. package/src/schema/introspect-runtime.ts +1 -1
  43. package/src/services/FetchService.ts +79 -11
  44. package/src/services/row-pipeline.ts +3 -1
  45. package/src/utils/drizzle-conditions.ts +509 -45
  46. package/src/utils/pg-error-utils.ts +98 -3
  47. package/dist/chunk-DSJWtz9O.js +0 -40
  48. package/dist/ensure-collection-tables-DGMYK0fr.js.map +0 -1
  49. package/dist/src-3VmUJ8Xn.js.map +0 -1
  50. package/dist/src-D5xBTl32.js.map +0 -1
@@ -107,16 +107,38 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
107
107
  ? "GENERATED ALWAYS AS IDENTITY"
108
108
  : "DEFAULT gen_random_uuid()::text";
109
109
 
110
+ // Identifiers for the constraint and indexes reconciled further down.
111
+ // Derived from the resolved table name so two auth tables in different
112
+ // schemas cannot collide, and truncated to Postgres's 63-byte identifier
113
+ // limit here rather than letting the server truncate silently — the
114
+ // `IF NOT EXISTS` guards below have to compare against the same name
115
+ // Postgres actually stored, or they re-run forever.
116
+ const authIdentifier = (suffix: string) => `${resolvedTable}_${suffix}`.slice(0, 63);
117
+ const emailLengthConstraint = `"${authIdentifier("email_length_check")}"`;
118
+ const emailLowerUniqueIndex = authIdentifier("email_lower_key");
119
+ const verificationTokenIndex = authIdentifier("email_verification_token_idx");
120
+
121
+ // Every string column here is TEXT, deliberately. In Postgres VARCHAR(n)
122
+ // and TEXT are the same type with the same storage and the same
123
+ // performance; the only difference is a length check, and none of these
124
+ // columns wants one. The widths this table used to carry were inherited
125
+ // MySQL habit (255) and they were all wrong in the same direction —
126
+ // `password_hash VARCHAR(255)` against a 193-char scrypt string left 62
127
+ // characters of headroom in front of a KEY_LENGTH constant living in
128
+ // another package, and `photo_url VARCHAR(500)` rejected the `data:` URIs
129
+ // and long signed URLs that OAuth providers hand back. A limit worth
130
+ // having is a CHECK — alterable without a table rewrite, unlike a type
131
+ // modifier — which is why `email` has one and nothing else does.
110
132
  await db.execute(sql`
111
133
  CREATE TABLE IF NOT EXISTS ${sql.raw(usersTableName)} (
112
134
  id ${sql.raw(userIdType)} PRIMARY KEY ${sql.raw(idDefault)},
113
- email VARCHAR(255) UNIQUE NOT NULL,
114
- display_name VARCHAR(255),
115
- photo_url VARCHAR(500),
135
+ email TEXT NOT NULL CONSTRAINT ${sql.raw(emailLengthConstraint)} CHECK (length(email) <= 320),
136
+ display_name TEXT,
137
+ photo_url TEXT,
116
138
  roles TEXT[] DEFAULT '{}' NOT NULL,
117
- password_hash VARCHAR(255),
139
+ password_hash TEXT,
118
140
  email_verified BOOLEAN DEFAULT FALSE NOT NULL,
119
- email_verification_token VARCHAR(255),
141
+ email_verification_token TEXT,
120
142
  email_verification_sent_at TIMESTAMP WITH TIME ZONE,
121
143
  is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
122
144
  metadata JSONB DEFAULT '{}' NOT NULL,
@@ -359,12 +381,12 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
359
381
  // statement that references it. `email` is deliberately absent: it has
360
382
  // existed since the first era and cannot be added NOT NULL safely.
361
383
  const userColumnBackfills = [
362
- "display_name VARCHAR(255)",
363
- "photo_url VARCHAR(500)",
384
+ "display_name TEXT",
385
+ "photo_url TEXT",
364
386
  "roles TEXT[] DEFAULT '{}' NOT NULL",
365
- "password_hash VARCHAR(255)",
387
+ "password_hash TEXT",
366
388
  "email_verified BOOLEAN DEFAULT FALSE NOT NULL",
367
- "email_verification_token VARCHAR(255)",
389
+ "email_verification_token TEXT",
368
390
  "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
369
391
  "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
370
392
  "metadata JSONB DEFAULT '{}' NOT NULL",
@@ -379,6 +401,139 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
379
401
  `);
380
402
  }
381
403
 
404
+ // Which of the columns below the table actually has. An adopted table —
405
+ // one this framework did not create, which the column-name resolution in
406
+ // `services.ts` exists to support — may be missing any of them, and every
407
+ // statement past this point has to tolerate that rather than abort the
408
+ // whole migration block.
409
+ const usersColumns = await db.execute(sql`
410
+ SELECT column_name, data_type
411
+ FROM information_schema.columns
412
+ WHERE table_schema = ${usersSchema} AND table_name = ${resolvedTable}
413
+ `);
414
+ const usersColumnTypes = new Map(
415
+ (usersColumns.rows as { column_name: string; data_type: string }[])
416
+ .map(row => [row.column_name, row.data_type])
417
+ );
418
+
419
+ // ── Migration: VARCHAR(n) → TEXT on the users string columns ────────
420
+ // Tables created before the widths came off still carry them. Postgres
421
+ // treats varchar(n) → text as binary-coercible with no stricter
422
+ // constraint, so this is a catalogue-only change: no table rewrite, no
423
+ // index rebuild, just a brief ACCESS EXCLUSIVE lock. Guarded on the
424
+ // current type so it runs once and is a pure catalogue read thereafter.
425
+ for (const column of ["email", "display_name", "photo_url", "password_hash", "email_verification_token"]) {
426
+ if (usersColumnTypes.get(column) !== "character varying") continue;
427
+ await db.execute(sql`
428
+ ALTER TABLE ${sql.raw(usersTableName)}
429
+ ALTER COLUMN ${sql.raw(`"${column}"`)} TYPE TEXT
430
+ `);
431
+ logger.info(`🔧 Widened ${usersTableName}.${column} from VARCHAR(n) to TEXT`);
432
+ }
433
+
434
+ // ── Migration: case-insensitive email identity ──────────────────────
435
+ // `getUserByEmail` has always searched `email.toLowerCase()` while the
436
+ // write path stored whatever it was handed, leaving normalisation to a
437
+ // convention every caller had to remember. A row that reached the table
438
+ // with mixed case is then invisible to every lookup — the account exists,
439
+ // login reports no such user, and the plain UNIQUE on `email` does not
440
+ // stop a second row differing only in case, because it compares bytes.
441
+ //
442
+ // Fixed on both sides: `mapPayload` now folds on write, and this index
443
+ // makes the database agree. A unique index on lower(email) is strictly
444
+ // stronger than the byte-exact UNIQUE that older tables carry, so the
445
+ // old constraint is left alone — it can no longer fire on anything the
446
+ // new one would allow.
447
+ //
448
+ // Deliberately no AUTH_SCHEMA_VERSION bump: a runtime that predates this
449
+ // migration keeps working against the migrated table (all of its own
450
+ // write paths already lower-cased), which is exactly the additive case
451
+ // the version stamp is documented not to cover.
452
+ if (usersColumnTypes.has("email")) {
453
+ const indexPresent = await db.execute(sql`
454
+ SELECT 1 FROM pg_class c
455
+ JOIN pg_namespace n ON n.oid = c.relnamespace
456
+ WHERE n.nspname = ${usersSchema} AND c.relname = ${emailLowerUniqueIndex} AND c.relkind = 'i'
457
+ `);
458
+ if (indexPresent.rows.length === 0) {
459
+ // Case-collisions already in the table would make the unique
460
+ // index impossible to build. Report them and leave the table
461
+ // alone: the next boot retries, so fixing the rows is all the
462
+ // operator has to do. Failing loudly beats folding the emails
463
+ // and letting CREATE INDEX pick which account survives.
464
+ const collisions = await db.execute(sql`
465
+ SELECT lower(email) AS normalized, count(*)::int AS occurrences
466
+ FROM ${sql.raw(usersTableName)}
467
+ WHERE email IS NOT NULL
468
+ GROUP BY lower(email)
469
+ HAVING count(*) > 1
470
+ LIMIT 10
471
+ `);
472
+ if (collisions.rows.length > 0) {
473
+ const sample = (collisions.rows as { normalized: string; occurrences: number }[])
474
+ .map(row => `${row.normalized} (×${row.occurrences})`)
475
+ .join(", ");
476
+ logger.error(
477
+ `❌ Cannot enforce case-insensitive email uniqueness on ${usersTableName}: ` +
478
+ `these addresses already exist more than once, differing only in case — ${sample}. ` +
479
+ "Merge or delete the duplicates and restart; until then two accounts can share " +
480
+ "one address and only the lower-cased one is reachable by login."
481
+ );
482
+ } else {
483
+ const folded = await db.execute(sql`
484
+ UPDATE ${sql.raw(usersTableName)}
485
+ SET email = lower(email)
486
+ WHERE email IS NOT NULL AND email <> lower(email)
487
+ `);
488
+ if (folded.rowCount) {
489
+ logger.info(`🔧 Lower-cased ${folded.rowCount} email address(es) in ${usersTableName}`);
490
+ }
491
+ await db.execute(sql`
492
+ CREATE UNIQUE INDEX IF NOT EXISTS ${sql.raw(`"${emailLowerUniqueIndex}"`)}
493
+ ON ${sql.raw(usersTableName)} (lower(email))
494
+ `);
495
+ logger.info(`✅ Email uniqueness on ${usersTableName} is now case-insensitive`);
496
+ }
497
+ }
498
+ }
499
+
500
+ // ── Migration: bound the email column's length ──────────────────────
501
+ // The only length limit on this table worth keeping. 320 is the RFC 5321
502
+ // maximum (64-char local part + @ + 255-char domain), and it matters here
503
+ // beyond tidiness: `email` carries a btree index, and a sufficiently long
504
+ // value fails index insertion with an error that says nothing about
505
+ // email. NOT VALID so an adopted table with a long row still migrates —
506
+ // it binds all new writes, which is the part that matters.
507
+ if (usersColumnTypes.has("email")) {
508
+ const checkPresent = await db.execute(sql`
509
+ SELECT 1 FROM pg_constraint c
510
+ JOIN pg_class t ON t.oid = c.conrelid
511
+ JOIN pg_namespace n ON n.oid = t.relnamespace
512
+ WHERE n.nspname = ${usersSchema}
513
+ AND t.relname = ${resolvedTable}
514
+ AND c.conname = ${authIdentifier("email_length_check")}
515
+ `);
516
+ if (checkPresent.rows.length === 0) {
517
+ await db.execute(sql`
518
+ ALTER TABLE ${sql.raw(usersTableName)}
519
+ ADD CONSTRAINT ${sql.raw(emailLengthConstraint)} CHECK (length(email) <= 320) NOT VALID
520
+ `);
521
+ }
522
+ }
523
+
524
+ // ── Index: email verification token lookups ─────────────────────────
525
+ // `getUserByVerificationToken` filters on this column, which had no
526
+ // index — every click of a verification link was a sequential scan of
527
+ // the whole users table. Partial, because the column is NULL for every
528
+ // user who is not mid-verification, which is nearly all of them.
529
+ if (usersColumnTypes.has("email_verification_token")) {
530
+ await db.execute(sql`
531
+ CREATE INDEX IF NOT EXISTS ${sql.raw(`"${verificationTokenIndex}"`)}
532
+ ON ${sql.raw(usersTableName)} (email_verification_token)
533
+ WHERE email_verification_token IS NOT NULL
534
+ `);
535
+ }
536
+
382
537
  // ── Migration: refresh_tokens become session-scoped, rotation-safe ──
383
538
  // Two shapes are reconciled here, on EVERY table named refresh_tokens
384
539
  // in whatever schema it lives (a database provisioned by an older era
@@ -54,6 +54,25 @@ function getColumn(table: RebasePgTable | undefined, ...keys: string[]): RebaseP
54
54
  return key ? table[key] : undefined;
55
55
  }
56
56
 
57
+ /**
58
+ * The single definition of what an email address looks like in storage.
59
+ *
60
+ * Reads have always folded case; writes did not, and normalising was left to
61
+ * each caller. That asymmetry is only ever one forgotten `.toLowerCase()` away
62
+ * from a row no lookup can find — the account exists, every sign-in path
63
+ * reports no such user, and the byte-exact UNIQUE on the column does not stop a
64
+ * duplicate differing only in case. Applied on both sides here so the guarantee
65
+ * belongs to the repository rather than to its callers' discipline; the
66
+ * `lower(email)` unique index added in `ensureAuthTablesExist` is the database
67
+ * half of the same rule.
68
+ *
69
+ * Whitespace goes too: a trailing space survives the fold and reproduces the
70
+ * problem exactly.
71
+ */
72
+ export function normalizeEmail<T>(email: T): T | string {
73
+ return typeof email === "string" ? email.trim().toLowerCase() : email;
74
+ }
75
+
57
76
  /**
58
77
  * PostgreSQL implementation of UserRepository.
59
78
  * Handles all user-related database operations using Drizzle ORM.
@@ -184,7 +203,7 @@ export class UserService implements UserRepository {
184
203
  const metadataKey = getColumnKey(this.usersTable, "metadata") || "metadata";
185
204
 
186
205
  if ("id" in data) payload[idKey] = data.id;
187
- if ("email" in data) payload[emailKey] = data.email;
206
+ if ("email" in data) payload[emailKey] = normalizeEmail(data.email);
188
207
  if ("passwordHash" in data) payload[passwordHashKey] = data.passwordHash;
189
208
  if ("displayName" in data) payload[displayNameKey] = data.displayName;
190
209
  if ("photoUrl" in data) payload[photoUrlKey] = data.photoUrl;
@@ -244,7 +263,7 @@ export class UserService implements UserRepository {
244
263
  async getUserByEmail(email: string): Promise<UserData | null> {
245
264
  const emailCol = getColumn(this.usersTable, "email");
246
265
  if (!emailCol) return null;
247
- const [row] = await this.db.select().from(this.usersTable).where(eq(emailCol, email.toLowerCase()));
266
+ const [row] = await this.db.select().from(this.usersTable).where(eq(emailCol, normalizeEmail(email)));
248
267
  return row ? this.mapRowToUser(row as Record<string, unknown>) : null;
249
268
  }
250
269
 
@@ -9,7 +9,7 @@ import { assertRelationsResolve } from "./validate-relations";
9
9
  /**
10
10
  * Everything a registry is built from: the collections, and the drizzle schema
11
11
  * they are backed by. In BaaS mode all of it is introspected from the live
12
- * database; in CMS mode it comes from the config and the generated schema.
12
+ * database; when collections are declared it comes from the config and the generated schema.
13
13
  */
14
14
  export interface RegistrySchema {
15
15
  collections?: CollectionConfig[];
@@ -1,39 +1,21 @@
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 type { EntityHistoryEntry } from "@rebasepro/types";
4
5
 
5
- export interface HistoryEntry {
6
- id: string;
7
- table_name: string;
8
- entity_id: string;
9
- action: "create" | "update" | "delete";
10
- changed_fields: string[] | null;
11
- values: Record<string, unknown> | null;
12
- previous_values: Record<string, unknown> | null;
13
- updated_by: string | null;
14
- updated_at: string;
15
- }
16
-
17
- export interface RecordHistoryParams {
18
- tableName: string;
19
- id: string;
20
- action: "create" | "update" | "delete";
21
- values?: Record<string, unknown> | null;
22
- previousValues?: Record<string, unknown> | null;
23
- updatedBy?: string | null;
24
- }
25
-
26
- export interface FetchHistoryOptions {
27
- limit?: number;
28
- offset?: number;
29
- }
6
+ export type {
7
+ RecordHistoryParams,
8
+ FetchHistoryOptions,
9
+ HistoryRetentionConfig
10
+ } from "@rebasepro/types";
11
+ import type { RecordHistoryParams, FetchHistoryOptions, HistoryRetentionConfig } from "@rebasepro/types";
30
12
 
31
- export interface HistoryRetentionConfig {
32
- /** Max entries per row. Oldest pruned first. Default 200. */
33
- maxEntries: number;
34
- /** Entries older than this many days are pruned. Default 90. */
35
- ttlDays: number;
36
- }
13
+ /**
14
+ * A Postgres history row is already the wire shape — `updated_at` comes back
15
+ * from the driver as a string. Kept as an alias because the name is used
16
+ * throughout this package and in `PostgresBackendDriver`.
17
+ */
18
+ export type HistoryEntry = EntityHistoryEntry;
37
19
 
38
20
  const DEFAULT_RETENTION: HistoryRetentionConfig = {
39
21
  maxEntries: 200,
@@ -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()
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Build drizzle tables at runtime from an introspected schema.
3
3
  *
4
- * CMS mode gets its drizzle tables from a generated `schema.generated.ts` that
4
+ * A project with declared collections gets its drizzle tables from a generated `schema.generated.ts` that
5
5
  * the developer commits. BaaS mode has no such file — it points at a database
6
6
  * and serves it — so the equivalent table objects are constructed here from
7
7
  * `information_schema` metadata.
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import { CollectionConfig, NumberProperty, Property, ResolvedRelation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty, isManyToMany, type ResolvedManyToMany, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, hasForeignKeyOnTarget } from "@rebasepro/types";
2
2
  import { getPrimaryKeys } from "../services/collection-helpers";
3
- import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
3
+ import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength } from "@rebasepro/common";
4
4
  import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
5
5
  import { logger } from "@rebasepro/server";
6
6
  // --- Helper Functions ---
@@ -98,9 +98,13 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
98
98
  } else if (stringProp.columnType === "uuid") {
99
99
  columnDefinition = `uuid("${colName}")`;
100
100
  } else if (stringProp.columnType === "char") {
101
- columnDefinition = `char("${colName}")`;
101
+ columnDefinition = `char("${colName}", { length: ${resolveStringColumnLength(stringProp)} })`;
102
102
  } else if (stringProp.columnType === "varchar") {
103
- columnDefinition = `varchar("${colName}")`;
103
+ // The length is not optional decoration: `varchar("col")` with
104
+ // no length is an UNBOUNDED varchar in Postgres, which is what
105
+ // this emitted while the DDL generator emitted VARCHAR(255) for
106
+ // the very same property.
107
+ columnDefinition = `varchar("${colName}", { length: ${resolveStringColumnLength(stringProp)} })`;
104
108
  } else {
105
109
  // `text` is the default, and the only length-unbounded choice.
106
110
  // Ask for `varchar` explicitly if you want the length constraint.
@@ -708,9 +712,17 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
708
712
  emittedRelationNames.add(deduplicationKey);
709
713
 
710
714
  switch (rel.kind) {
711
- case "belongsTo":
712
- tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${rel.localKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
715
+ case "belongsTo": {
716
+ // `localKey` is a COLUMN name; the generated Drizzle
717
+ // object is keyed by PROPERTY. They differ whenever
718
+ // the property is camelCase — `user_id` is exposed
719
+ // as `userId` — and emitting the column produces a
720
+ // schema that does not compile. The three other
721
+ // emission sites normalise; this one did not.
722
+ const localFieldKey = resolvePropertyKeyForColumn(collection, rel.localKey);
723
+ tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${localFieldKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
713
724
  break;
725
+ }
714
726
 
715
727
  case "hasOne":
716
728
  // The foreign key lives on the TARGET table. Drizzle pairs