@rebasepro/server-postgres 0.9.1-canary.73476f2 → 0.9.1-canary.74adfbe

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 (62) hide show
  1. package/README.md +21 -0
  2. package/dist/PostgresBackendDriver.d.ts +43 -2
  3. package/dist/PostgresBootstrapper.d.ts +17 -1
  4. package/dist/auth/services.d.ts +68 -52
  5. package/dist/collections/buildRegistry.d.ts +27 -0
  6. package/dist/connection.d.ts +21 -0
  7. package/dist/data-transformer.d.ts +9 -2
  8. package/dist/index.es.js +2185 -775
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/schema/auth-bootstrap-sql.d.ts +1 -1
  11. package/dist/schema/auth-schema.d.ts +24 -24
  12. package/dist/schema/doctor.d.ts +1 -1
  13. package/dist/schema/introspect-db-logic.d.ts +0 -5
  14. package/dist/schema/introspect-db-naming.d.ts +10 -0
  15. package/dist/security/policy-drift.d.ts +30 -0
  16. package/dist/security/rls-enforcement.d.ts +2 -2
  17. package/dist/services/FetchService.d.ts +4 -24
  18. package/dist/services/PersistService.d.ts +27 -1
  19. package/dist/services/RelationService.d.ts +34 -1
  20. package/dist/services/channel-history.d.ts +118 -0
  21. package/dist/services/collection-helpers.d.ts +79 -14
  22. package/dist/services/dataService.d.ts +3 -1
  23. package/dist/services/index.d.ts +1 -1
  24. package/dist/services/realtimeService.d.ts +76 -2
  25. package/dist/services/row-pipeline.d.ts +63 -0
  26. package/package.json +13 -34
  27. package/src/PostgresBackendDriver.ts +183 -18
  28. package/src/PostgresBootstrapper.ts +80 -26
  29. package/src/auth/ensure-tables.ts +170 -28
  30. package/src/auth/services.ts +181 -150
  31. package/src/cli.ts +60 -0
  32. package/src/collections/buildRegistry.ts +59 -0
  33. package/src/connection.ts +61 -1
  34. package/src/data-transformer.ts +11 -9
  35. package/src/databasePoolManager.ts +2 -0
  36. package/src/schema/auth-bootstrap-sql.ts +7 -1
  37. package/src/schema/auth-schema.ts +13 -13
  38. package/src/schema/doctor.ts +45 -20
  39. package/src/schema/generate-drizzle-schema-logic.ts +24 -29
  40. package/src/schema/generate-postgres-ddl-logic.ts +76 -28
  41. package/src/schema/introspect-db-inference.ts +1 -1
  42. package/src/schema/introspect-db-logic.ts +1 -10
  43. package/src/schema/introspect-db-naming.ts +15 -0
  44. package/src/schema/introspect-db.ts +19 -2
  45. package/src/schema/introspect-runtime.ts +1 -1
  46. package/src/security/policy-drift.test.ts +106 -1
  47. package/src/security/policy-drift.ts +56 -0
  48. package/src/security/rls-enforcement.ts +11 -5
  49. package/src/services/BranchService.ts +42 -10
  50. package/src/services/FetchService.ts +65 -270
  51. package/src/services/PersistService.ts +130 -14
  52. package/src/services/RelationService.ts +153 -94
  53. package/src/services/channel-history.ts +343 -0
  54. package/src/services/collection-helpers.ts +164 -47
  55. package/src/services/dataService.ts +3 -2
  56. package/src/services/index.ts +1 -0
  57. package/src/services/realtimeService.ts +238 -29
  58. package/src/services/row-pipeline.ts +239 -0
  59. package/src/utils/drizzle-conditions.ts +13 -0
  60. package/src/websocket.ts +34 -12
  61. package/dist/schema/auth-default-policies.d.ts +0 -10
  62. package/src/schema/auth-default-policies.ts +0 -132
@@ -4,7 +4,7 @@
4
4
  * Implements the `BackendBootstrapper` interface for PostgreSQL.
5
5
  */
6
6
 
7
- import { getTableName, isTable, Relations, sql } from "drizzle-orm";
7
+ import { Relations, sql } from "drizzle-orm";
8
8
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
9
9
  import { PgEnum, PgTable } from "drizzle-orm/pg-core";
10
10
  import type { RebasePgTable } from "./types";
@@ -17,10 +17,12 @@ import {
17
17
  CollectionConfig,
18
18
  type HistoryConfig,
19
19
  InitializedDriver,
20
- RealtimeProvider
20
+ RealtimeProvider,
21
+ type RealtimeChannelsConfig
21
22
  } from "@rebasepro/types";
22
23
  import { PostgresBackendDriver } from "./PostgresBackendDriver";
23
24
  import { RealtimeService } from "./services/realtimeService";
25
+ import { buildCollectionRegistry } from "./collections/buildRegistry";
24
26
  import { DatabasePoolManager } from "./databasePoolManager";
25
27
  import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
26
28
  import { createEmailService, type EmailConfig, type EmailService, logger } from "@rebasepro/server";
@@ -51,6 +53,12 @@ export interface PostgresDriverConfig {
51
53
  * (BaaS mode). Defaults to `public`.
52
54
  */
53
55
  introspectionSchema?: string;
56
+ /**
57
+ * Realtime options. Currently only channel retention, which is opt-in:
58
+ * without rules here no channel keeps any history and broadcast stays
59
+ * fire-and-forget. See {@link ChannelRetentionRule}.
60
+ */
61
+ realtime?: RealtimeChannelsConfig;
54
62
  }
55
63
 
56
64
  /**
@@ -64,6 +72,15 @@ export interface PostgresDriverInternals {
64
72
  realtimeService: RealtimeService;
65
73
  driver: PostgresBackendDriver;
66
74
  poolManager?: DatabasePoolManager;
75
+ /**
76
+ * Attach CDC triggers to tables that did not exist when the driver
77
+ * bootstrapped. Only set when database-level capture is actually active.
78
+ *
79
+ * Auth owns its own tables and creates them later in boot, so at driver
80
+ * bootstrap they are legitimately missing and get skipped; without this
81
+ * they would stay uninstrumented until the next restart.
82
+ */
83
+ provisionCdcForTables?: (tables: CdcTableRef[]) => Promise<void>;
67
84
  }
68
85
 
69
86
  // Re-export from shared CLI error utilities
@@ -161,30 +178,17 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
161
178
  }
162
179
 
163
180
  const activeCollections = introspectedCollections ?? collections;
164
-
165
- // Create a fresh registry for this driver
166
- const registry = new PostgresCollectionRegistry();
167
- if (activeCollections) {
168
- registry.registerMultiple(activeCollections);
169
- logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map(c => c.slug).join(", ")}]`);
170
- }
171
-
172
181
  const schemaTables = introspectedTables ?? pgConfig.schema?.tables;
173
-
174
- // Register tables
175
- if (schemaTables) {
176
- Object.values(schemaTables).forEach((table) => {
177
- if (isTable(table)) {
178
- const tableName = getTableName(table);
179
- registry.registerTable(table as PgTable, tableName);
180
- }
181
- });
182
- }
183
-
184
- if (pgConfig.schema?.enums) registry.registerEnums(pgConfig.schema.enums as Record<string, PgEnum<[string, ...string[]]>>);
185
-
186
182
  const schemaRelations = introspectedRelations ?? (pgConfig.schema?.relations as Record<string, Relations> | undefined);
187
- if (schemaRelations) registry.registerRelations(schemaRelations);
183
+
184
+ // Create a fresh registry for this driver. Registration order is
185
+ // load-bearing, so it lives in one place — see `buildCollectionRegistry`.
186
+ const registry = buildCollectionRegistry({
187
+ collections: activeCollections,
188
+ tables: schemaTables,
189
+ enums: pgConfig.schema?.enums as Record<string, PgEnum<[string, ...string[]]>> | undefined,
190
+ relations: schemaRelations
191
+ });
188
192
 
189
193
  // Patch Drizzle's PgArray columns to handle NULL values safely.
190
194
  // Drizzle's mapFromDriverValue crashes with "value.map is not a function"
@@ -319,6 +323,16 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
319
323
  }
320
324
  }
321
325
 
326
+ // ── Channel history ──────────────────────────────────────────────
327
+ // Opt-in per channel pattern. With no rules this creates no tables
328
+ // and leaves broadcast on its original fire-and-forget path, so
329
+ // presence-only apps pay nothing for it.
330
+ try {
331
+ await realtimeService.configureChannelHistory(pgConfig.realtime?.channels);
332
+ } catch (err) {
333
+ logger.warn("⚠️ Could not initialize channel history tables — retained channels will not replay", { error: err });
334
+ }
335
+
322
336
  // ── Realtime change source ───────────────────────────────────────
323
337
  // Prefer DATABASE_DIRECT_URL to bypass PgBouncer for LISTEN/NOTIFY.
324
338
  const directUrl = process.env.DATABASE_DIRECT_URL || pgConfig.connectionString;
@@ -346,6 +360,7 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
346
360
  const wantsCdc = cdcMode !== "off";
347
361
  const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
348
362
  let cdcEnabled = false;
363
+ let provisionCdcForTables: PostgresDriverInternals["provisionCdcForTables"];
349
364
 
350
365
  if (wantsCdc && !directUrl) {
351
366
  const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
@@ -379,6 +394,11 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
379
394
  await provisionTriggerCdc(cdcRunSql, cdcTables);
380
395
  await realtimeService.enableCdc(directUrl);
381
396
  cdcEnabled = true;
397
+ // Boot steps that create their own tables (auth) run after
398
+ // this one and use it to instrument what they just created.
399
+ provisionCdcForTables = async (tables) => {
400
+ await provisionTriggerCdc(cdcRunSql, tables);
401
+ };
382
402
  logger.info(
383
403
  `📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). ` +
384
404
  `All writes now emit realtime events regardless of origin.`
@@ -429,6 +449,14 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
429
449
  );
430
450
  const missing: Array<{ slug: string; table: string }> = [];
431
451
  for (const col of registeredCollections) {
452
+ // Auth owns its table and creates it later in this same
453
+ // boot (initializeAuth → ensureAuthTablesExist), so it is
454
+ // legitimately absent right now. Reporting it as drift
455
+ // tells the user to `db:push` a table that is about to
456
+ // exist — and on an introspected database, one that the
457
+ // database was never supposed to hold.
458
+ if ((col as { auth?: { enabled?: boolean } }).auth?.enabled) continue;
459
+
432
460
  const schemaName = "schema" in col && col.schema ? col.schema : "public";
433
461
  const tableName = registry.hasTableForCollection(
434
462
  col.table ?? col.slug
@@ -443,8 +471,10 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
443
471
  const checkName = resolvedTable ?? tableName;
444
472
  const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
445
473
  if (!dbTables.has(fullCheckName)) {
474
+ // Report what was actually looked up: an unqualified
475
+ // "users" sends people hunting for public.users.
446
476
  missing.push({ slug: col.slug,
447
- table: checkName });
477
+ table: fullCheckName });
448
478
  }
449
479
  }
450
480
  if (missing.length > 0) {
@@ -478,7 +508,8 @@ table: checkName });
478
508
  registry,
479
509
  realtimeService,
480
510
  driver,
481
- poolManager
511
+ poolManager,
512
+ provisionCdcForTables
482
513
  };
483
514
 
484
515
  return {
@@ -507,6 +538,29 @@ table: checkName });
507
538
  // ensureAuthTablesExist works with the collection abstraction — no Drizzle leakage.
508
539
  await ensureAuthTablesExist(db, authCollection);
509
540
 
541
+ // The driver bootstrapped before these tables existed, so CDC skipped
542
+ // them. Instrument them now, or writes to the user table emit no
543
+ // realtime events until the next restart.
544
+ if (authCollection && internals.provisionCdcForTables) {
545
+ const authSchema = "schema" in authCollection && typeof authCollection.schema === "string"
546
+ ? authCollection.schema
547
+ : "rebase";
548
+ const authTable = "table" in authCollection && typeof authCollection.table === "string"
549
+ ? authCollection.table
550
+ : authCollection.slug;
551
+ if (authTable) {
552
+ try {
553
+ await internals.provisionCdcForTables([{ schema: authSchema, table: authTable }]);
554
+ } catch (err) {
555
+ logger.warn(
556
+ `⚠️ [CDC] Could not attach change-capture to the auth table "${authSchema}.${authTable}" — ` +
557
+ "writes to it won't emit database-level events.",
558
+ { detail: err instanceof Error ? err.message : String(err) }
559
+ );
560
+ }
561
+ }
562
+ }
563
+
510
564
  let emailService: EmailService | undefined;
511
565
  if (authConfig.email) {
512
566
  emailService = createEmailService(authConfig.email as EmailConfig);
@@ -112,13 +112,87 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
112
112
  )
113
113
  `);
114
114
 
115
+ // ── Migration: auth FK column user_id → uid, phase 1 (expand) ───────
116
+ // Must run BEFORE the dependent tables below: CREATE TABLE IF NOT
117
+ // EXISTS never revisits an existing table, so a database provisioned
118
+ // before this migration still lacks `uid` — and the
119
+ // CREATE INDEX ... (uid) statements that follow would fail on it.
120
+ //
121
+ // Deliberately NOT a plain RENAME. Both Cloud Run and Kubernetes roll
122
+ // deploys, so old and new pods serve the same database at the same time,
123
+ // and a rollback puts old code back in front of a migrated database. A
124
+ // rename breaks every auth query on whichever side is out of step.
125
+ // Instead: add `uid`, backfill it, drop the NOT NULL on `user_id`, and
126
+ // keep the two in sync with a trigger, so a backend of either era can
127
+ // read and write. `scripts/drop-legacy-auth-user-id.sql` removes the
128
+ // column once no old backend remains (phase 2, contract).
129
+ //
130
+ // Idempotent throughout: every step is guarded on catalogue state.
131
+ await db.execute(sql`
132
+ CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
133
+ BEGIN
134
+ IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
135
+ NEW.uid := NEW.user_id;
136
+ ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
137
+ NEW.user_id := NEW.uid;
138
+ END IF;
139
+ RETURN NEW;
140
+ END $$ LANGUAGE plpgsql
141
+ `);
142
+
143
+ for (const authTable of [
144
+ "user_identities",
145
+ "refresh_tokens",
146
+ "password_reset_tokens",
147
+ "magic_link_tokens",
148
+ "mfa_factors",
149
+ "recovery_codes"
150
+ ]) {
151
+ const qualified = `"${authSchema}"."${authTable}"`;
152
+ await db.execute(sql`
153
+ DO $$
154
+ DECLARE
155
+ has_legacy boolean;
156
+ has_uid boolean;
157
+ BEGIN
158
+ SELECT
159
+ bool_or(column_name = 'user_id'),
160
+ bool_or(column_name = 'uid')
161
+ INTO has_legacy, has_uid
162
+ FROM information_schema.columns
163
+ WHERE table_schema = ${sql.raw(`'${authSchema}'`)}
164
+ AND table_name = ${sql.raw(`'${authTable}'`)};
165
+
166
+ -- Table absent, or already uid-only (a fresh install, or
167
+ -- phase 2 already run): nothing to do.
168
+ IF has_legacy IS NOT TRUE THEN
169
+ RETURN;
170
+ END IF;
171
+
172
+ IF has_uid IS NOT TRUE THEN
173
+ EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ADD COLUMN uid ${userIdType} REFERENCES ${usersTableName}(id) ON DELETE CASCADE'`)};
174
+ EXECUTE ${sql.raw(`'UPDATE ${qualified} SET uid = user_id WHERE uid IS NULL'`)};
175
+ EXECUTE ${sql.raw(`'CREATE INDEX IF NOT EXISTS idx_${authTable}_uid ON ${qualified}(uid)'`)};
176
+ END IF;
177
+
178
+ -- New code inserts uid and never user_id, so the legacy
179
+ -- column can no longer be NOT NULL. The trigger below
180
+ -- backfills it, but the constraint is checked first.
181
+ EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ALTER COLUMN user_id DROP NOT NULL'`)};
182
+
183
+ EXECUTE ${sql.raw(`'DROP TRIGGER IF EXISTS sync_uid_user_id ON ${qualified}'`)};
184
+ EXECUTE ${sql.raw(`'CREATE TRIGGER sync_uid_user_id BEFORE INSERT OR UPDATE ON ${qualified} FOR EACH ROW EXECUTE FUNCTION "${authSchema}".sync_uid_user_id()'`)};
185
+ END $$
186
+ `);
187
+ }
188
+
115
189
  // ── Create dependent auth tables (idempotent) ───────────────────
116
190
 
117
191
  // Create user_identities table
118
192
  await db.execute(sql`
119
193
  CREATE TABLE IF NOT EXISTS ${sql.raw(userIdentitiesTable)} (
120
194
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
121
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
195
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
122
196
  provider TEXT NOT NULL,
123
197
  provider_id TEXT NOT NULL,
124
198
  profile_data JSONB,
@@ -131,7 +205,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
131
205
  // Create indexes on user_identities
132
206
  await db.execute(sql`
133
207
  CREATE INDEX IF NOT EXISTS idx_user_identities_user
134
- ON ${sql.raw(userIdentitiesTable)}(user_id)
208
+ ON ${sql.raw(userIdentitiesTable)}(uid)
135
209
  `);
136
210
 
137
211
 
@@ -139,13 +213,13 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
139
213
  await db.execute(sql`
140
214
  CREATE TABLE IF NOT EXISTS ${sql.raw(refreshTokensTableName)} (
141
215
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
142
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
216
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
143
217
  token_hash TEXT NOT NULL UNIQUE,
144
218
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
145
219
  user_agent TEXT,
146
220
  ip_address TEXT,
147
221
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
148
- CONSTRAINT unique_device_session UNIQUE (user_id, user_agent, ip_address)
222
+ CONSTRAINT unique_device_session UNIQUE (uid, user_agent, ip_address)
149
223
  )
150
224
  `);
151
225
 
@@ -155,17 +229,17 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
155
229
  ON ${sql.raw(refreshTokensTableName)}(token_hash)
156
230
  `);
157
231
 
158
- // Create index on user_id for cleanup operations
232
+ // Create index on uid for cleanup operations
159
233
  await db.execute(sql`
160
234
  CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user
161
- ON ${sql.raw(refreshTokensTableName)}(user_id)
235
+ ON ${sql.raw(refreshTokensTableName)}(uid)
162
236
  `);
163
237
 
164
238
  // Create password reset tokens table
165
239
  await db.execute(sql`
166
240
  CREATE TABLE IF NOT EXISTS ${sql.raw(passwordResetTokensTableName)} (
167
241
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
168
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
242
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
169
243
  token_hash TEXT NOT NULL UNIQUE,
170
244
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
171
245
  used_at TIMESTAMP WITH TIME ZONE,
@@ -179,10 +253,10 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
179
253
  ON ${sql.raw(passwordResetTokensTableName)}(token_hash)
180
254
  `);
181
255
 
182
- // Create index on user_id for password reset cleanup
256
+ // Create index on uid for password reset cleanup
183
257
  await db.execute(sql`
184
258
  CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user
185
- ON ${sql.raw(passwordResetTokensTableName)}(user_id)
259
+ ON ${sql.raw(passwordResetTokensTableName)}(uid)
186
260
  `);
187
261
 
188
262
  // Create magic link tokens table
@@ -190,7 +264,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
190
264
  await db.execute(sql`
191
265
  CREATE TABLE IF NOT EXISTS ${sql.raw(magicLinkTokensTableName)} (
192
266
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
193
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
267
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
194
268
  token_hash TEXT NOT NULL UNIQUE,
195
269
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
196
270
  used_at TIMESTAMP WITH TIME ZONE,
@@ -204,10 +278,10 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
204
278
  ON ${sql.raw(magicLinkTokensTableName)}(token_hash)
205
279
  `);
206
280
 
207
- // Create index on user_id for magic link cleanup
281
+ // Create index on uid for magic link cleanup
208
282
  await db.execute(sql`
209
283
  CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user
210
- ON ${sql.raw(magicLinkTokensTableName)}(user_id)
284
+ ON ${sql.raw(magicLinkTokensTableName)}(uid)
211
285
  `);
212
286
 
213
287
  // Create app config table
@@ -226,9 +300,15 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
226
300
  await db.transaction(async (tx) => {
227
301
  await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
228
302
 
303
+ // Falls back to the pre-rename `app.user_id` so a database that has
304
+ // taken the new schema but is still served by an older backend keeps
305
+ // resolving the principal. Keep in sync with AUTH_BOOTSTRAP_SQL.
229
306
  await tx.execute(sql`
230
307
  CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
231
- SELECT NULLIF(current_setting('app.user_id', true), '');
308
+ SELECT COALESCE(
309
+ NULLIF(current_setting('app.uid', true), ''),
310
+ NULLIF(current_setting('app.user_id', true), '')
311
+ );
232
312
  $$ LANGUAGE sql STABLE
233
313
  `);
234
314
 
@@ -251,17 +331,32 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
251
331
  // Seed default roles if none exist
252
332
  // (no-op: roles are now stored inline on the users table)
253
333
 
254
- // ── Migration: Add is_anonymous column (safe for existing tables) ────
255
- await db.execute(sql`
256
- ALTER TABLE ${sql.raw(usersTableName)}
257
- ADD COLUMN IF NOT EXISTS is_anonymous BOOLEAN DEFAULT FALSE
258
- `);
259
-
260
- // ── Migration: Add inline roles column (safe for existing tables) ────
261
- await db.execute(sql`
262
- ALTER TABLE ${sql.raw(usersTableName)}
263
- ADD COLUMN IF NOT EXISTS roles TEXT[] DEFAULT '{}' NOT NULL
264
- `);
334
+ // ── Migration: reconcile the full users column set (safe for existing tables) ──
335
+ // CREATE TABLE IF NOT EXISTS never revisits an existing table, so a
336
+ // database provisioned by an older framework era is missing every
337
+ // column added since. Each column the auth services read or write must
338
+ // be back-filled here, or upgraded deployments break on the first
339
+ // statement that references it. `email` is deliberately absent: it has
340
+ // existed since the first era and cannot be added NOT NULL safely.
341
+ const userColumnBackfills = [
342
+ "display_name VARCHAR(255)",
343
+ "photo_url VARCHAR(500)",
344
+ "roles TEXT[] DEFAULT '{}' NOT NULL",
345
+ "password_hash VARCHAR(255)",
346
+ "email_verified BOOLEAN DEFAULT FALSE NOT NULL",
347
+ "email_verification_token VARCHAR(255)",
348
+ "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
349
+ "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
350
+ "metadata JSONB DEFAULT '{}' NOT NULL",
351
+ "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
352
+ "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
353
+ ];
354
+ for (const columnDef of userColumnBackfills) {
355
+ await db.execute(sql`
356
+ ALTER TABLE ${sql.raw(usersTableName)}
357
+ ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
358
+ `);
359
+ }
265
360
 
266
361
  // ── Migration: Copy roles from legacy junction table to inline column ──
267
362
  // If the old rebase.user_roles and rebase.roles tables exist, migrate
@@ -307,7 +402,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
307
402
  await db.execute(sql`
308
403
  CREATE TABLE IF NOT EXISTS ${sql.raw(mfaFactorsTableName)} (
309
404
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
310
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
405
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
311
406
  factor_type TEXT NOT NULL DEFAULT 'totp',
312
407
  secret_encrypted TEXT NOT NULL,
313
408
  friendly_name TEXT,
@@ -320,7 +415,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
320
415
  // Create indexes on mfa_factors
321
416
  await db.execute(sql`
322
417
  CREATE INDEX IF NOT EXISTS idx_mfa_factors_user
323
- ON ${sql.raw(mfaFactorsTableName)}(user_id)
418
+ ON ${sql.raw(mfaFactorsTableName)}(uid)
324
419
  `);
325
420
 
326
421
  // Create mfa_challenges table
@@ -345,7 +440,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
345
440
  await db.execute(sql`
346
441
  CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
347
442
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
348
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
443
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
349
444
  code_hash TEXT NOT NULL,
350
445
  used_at TIMESTAMP WITH TIME ZONE,
351
446
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
@@ -355,9 +450,56 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
355
450
  // Create indexes on recovery_codes
356
451
  await db.execute(sql`
357
452
  CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
358
- ON ${sql.raw(recoveryCodesTableName)}(user_id)
453
+ ON ${sql.raw(recoveryCodesTableName)}(uid)
359
454
  `);
360
455
 
456
+ // ── Migration: clear stale FORCE ROW LEVEL SECURITY (older RLS model) ──
457
+ // The current model never emits FORCE: privileged auth writes run as
458
+ // the table owner and rely on the owner bypassing plain ENABLE RLS
459
+ // (see generate-postgres-ddl-logic). A table still carrying FORCE from
460
+ // an older framework era binds the owner too, so the first user
461
+ // registration after an upgrade fails with SQLSTATE 42501. Reconcile
462
+ // on boot; only tables actually flagged get the ALTER (and its lock).
463
+ try {
464
+ const authTablePairs: [string, string][] = [
465
+ [usersSchema, resolvedTable],
466
+ [authSchema, "user_identities"],
467
+ [authSchema, "refresh_tokens"],
468
+ [authSchema, "password_reset_tokens"],
469
+ [authSchema, "app_config"],
470
+ [authSchema, "mfa_factors"],
471
+ [authSchema, "mfa_challenges"],
472
+ [authSchema, "recovery_codes"]
473
+ ];
474
+ for (const [schemaName, tableName] of authTablePairs) {
475
+ const forced = await db.execute(sql`
476
+ SELECT 1
477
+ FROM pg_class c
478
+ JOIN pg_namespace n ON n.oid = c.relnamespace
479
+ WHERE n.nspname = ${schemaName}
480
+ AND c.relname = ${tableName}
481
+ AND c.relforcerowsecurity
482
+ `);
483
+ if (forced.rows.length > 0) {
484
+ await db.execute(sql`
485
+ ALTER TABLE ${sql.raw(`"${schemaName}"."${tableName}"`)}
486
+ NO FORCE ROW LEVEL SECURITY
487
+ `);
488
+ logger.warn(
489
+ `🔧 Cleared stale FORCE ROW LEVEL SECURITY on "${schemaName}"."${tableName}" ` +
490
+ "(legacy RLS model — it binds the owner connection and breaks privileged auth writes)"
491
+ );
492
+ }
493
+ }
494
+ } catch (rlsReconcileError: unknown) {
495
+ // Non-fatal: the connection may lack ownership on a pre-provisioned
496
+ // table; registration will still fail loudly (42501) if FORCE remains.
497
+ logger.warn(
498
+ `⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ` +
499
+ `${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`
500
+ );
501
+ }
502
+
361
503
  logger.info("✅ Auth tables ready");
362
504
  } catch (error) {
363
505
  logger.error("❌ Failed to create auth tables", { error });