@rebasepro/server-postgres 0.9.1-canary.fd3754b → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) 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 +2711 -2772
  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 +70 -5
  16. package/dist/security/rls-enforcement.d.ts +29 -4
  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 +15 -40
  27. package/src/PostgresBackendDriver.ts +183 -18
  28. package/src/PostgresBootstrapper.ts +86 -27
  29. package/src/auth/ensure-tables.ts +170 -28
  30. package/src/auth/services.ts +181 -150
  31. package/src/cli-helpers.ts +2 -20
  32. package/src/cli.ts +60 -0
  33. package/src/collections/buildRegistry.ts +59 -0
  34. package/src/connection.ts +61 -1
  35. package/src/data-transformer.ts +11 -9
  36. package/src/databasePoolManager.ts +2 -0
  37. package/src/schema/auth-bootstrap-sql.ts +7 -1
  38. package/src/schema/auth-schema.ts +13 -13
  39. package/src/schema/doctor-cli.ts +5 -1
  40. package/src/schema/doctor.ts +45 -20
  41. package/src/schema/generate-drizzle-schema-logic.ts +24 -29
  42. package/src/schema/generate-postgres-ddl-logic.ts +76 -28
  43. package/src/schema/introspect-db-inference.ts +1 -1
  44. package/src/schema/introspect-db-logic.ts +1 -10
  45. package/src/schema/introspect-db-naming.ts +15 -0
  46. package/src/schema/introspect-db.ts +19 -2
  47. package/src/schema/introspect-runtime.ts +1 -1
  48. package/src/security/policy-drift.test.ts +199 -14
  49. package/src/security/policy-drift.ts +197 -13
  50. package/src/security/rls-enforcement.ts +74 -7
  51. package/src/services/BranchService.ts +42 -10
  52. package/src/services/FetchService.ts +65 -270
  53. package/src/services/PersistService.ts +130 -14
  54. package/src/services/RelationService.ts +153 -94
  55. package/src/services/channel-history.ts +343 -0
  56. package/src/services/collection-helpers.ts +164 -47
  57. package/src/services/dataService.ts +3 -2
  58. package/src/services/index.ts +1 -0
  59. package/src/services/realtimeService.ts +238 -29
  60. package/src/services/row-pipeline.ts +239 -0
  61. package/src/utils/drizzle-conditions.ts +13 -0
  62. package/src/websocket.ts +34 -12
  63. package/dist/chunk-DSJWtz9O.js +0 -40
  64. package/dist/schema/auth-default-policies.d.ts +0 -10
  65. package/dist/src-Eh-CZosp.js +0 -595
  66. package/dist/src-Eh-CZosp.js.map +0 -1
  67. package/src/schema/auth-default-policies.ts +0 -125
@@ -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 });