@rebasepro/server-postgres 0.10.0 → 0.10.1-canary.31c773c

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-postgres",
3
3
  "type": "module",
4
- "version": "0.10.0",
4
+ "version": "0.10.1-canary.31c773c",
5
5
  "description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -47,11 +47,11 @@
47
47
  "execa": "^9.6.1",
48
48
  "pg": "^8.21.0",
49
49
  "ws": "^8.21.0",
50
- "@rebasepro/codegen": "0.10.0",
51
- "@rebasepro/common": "0.10.0",
52
- "@rebasepro/types": "0.10.0",
53
- "@rebasepro/utils": "0.10.0",
54
- "@rebasepro/server": "0.10.0"
50
+ "@rebasepro/codegen": "0.10.1-canary.31c773c",
51
+ "@rebasepro/common": "0.10.1-canary.31c773c",
52
+ "@rebasepro/server": "0.10.1-canary.31c773c",
53
+ "@rebasepro/types": "0.10.1-canary.31c773c",
54
+ "@rebasepro/utils": "0.10.1-canary.31c773c"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@hono/node-server": "^2.0.9",
@@ -358,6 +358,80 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
358
358
  `);
359
359
  }
360
360
 
361
+ // ── Migration: reconcile refresh_tokens for the device-session upsert ──
362
+ // The refresh-token rotation does `INSERT ... ON CONFLICT (uid, user_agent,
363
+ // ip_address) DO UPDATE`, which requires the `unique_device_session`
364
+ // constraint. `CREATE TABLE IF NOT EXISTS` never adds it to a table that
365
+ // predates the constraint, so every login on such a database 500s with
366
+ // `42P10 no unique or exclusion constraint matching the ON CONFLICT`.
367
+ // Reconcile EVERY table named refresh_tokens, in whatever schema it lives,
368
+ // rather than only the derived name: a database provisioned by an older
369
+ // era can carry the table in a different schema than the one this run
370
+ // derives, and the login upsert then hits a table without the constraint.
371
+ try {
372
+ const rtTables = await db.execute(sql`
373
+ SELECT table_schema, table_name
374
+ FROM information_schema.tables
375
+ WHERE table_name = 'refresh_tokens'
376
+ `);
377
+ const found = (rtTables.rows as { table_schema: string; table_name: string }[]);
378
+ logger.info(`🔍 refresh_tokens reconcile: found ${found.length} table(s): ${found.map(r => `"${r.table_schema}"."${r.table_name}"`).join(", ") || "(none)"}`);
379
+ for (const { table_schema } of found) {
380
+ const qualified = `"${table_schema}"."refresh_tokens"`;
381
+ try {
382
+ // Old tables may lack these columns entirely.
383
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS user_agent TEXT`);
384
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS ip_address TEXT`);
385
+ // The upsert relies on '' (not NULL) so a device with no UA/IP collides.
386
+ await db.execute(sql`UPDATE ${sql.raw(qualified)} SET user_agent = '' WHERE user_agent IS NULL`);
387
+ await db.execute(sql`UPDATE ${sql.raw(qualified)} SET ip_address = '' WHERE ip_address IS NULL`);
388
+ // Drop pre-existing duplicates that would block the unique constraint,
389
+ // keeping one row per (uid, user_agent, ip_address).
390
+ await db.execute(sql`
391
+ DELETE FROM ${sql.raw(qualified)} a
392
+ USING ${sql.raw(qualified)} b
393
+ WHERE a.ctid < b.ctid
394
+ AND a.uid = b.uid
395
+ AND a.user_agent = b.user_agent
396
+ AND a.ip_address = b.ip_address
397
+ `);
398
+ // Log every unique constraint on the table so drift is visible.
399
+ const uniques = await db.execute(sql`
400
+ SELECT conname, pg_get_constraintdef(oid) AS def
401
+ FROM pg_constraint
402
+ WHERE conrelid = ${sql.raw(`'${qualified}'`)}::regclass AND contype = 'u'
403
+ `);
404
+ for (const u of uniques.rows as { conname: string; def: string }[]) {
405
+ logger.info(` ${qualified} unique: ${u.conname} → ${u.def}`);
406
+ }
407
+ // Does a usable unique constraint cover EXACTLY (uid, user_agent, ip_address)?
408
+ // Checking by name alone is not enough: an older era may carry a
409
+ // `unique_device_session` on different columns, which the ON CONFLICT
410
+ // cannot infer, so login keeps 500ing with 42P10.
411
+ const hasCorrect = (uniques.rows as { def: string }[]).some((u) => {
412
+ const cols = (u.def.match(/\(([^)]*)\)/)?.[1] || "")
413
+ .split(",").map((c) => c.trim().replace(/"/g, ""));
414
+ return cols.length === 3 && cols.includes("uid") && cols.includes("user_agent") && cols.includes("ip_address");
415
+ });
416
+ if (hasCorrect) {
417
+ logger.info(`✓ correct device-session unique already present on ${qualified}`);
418
+ } else {
419
+ // Remove a wrong-columned constraint squatting on the name, then add the right one.
420
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);
421
+ await db.execute(sql`
422
+ ALTER TABLE ${sql.raw(qualified)}
423
+ ADD CONSTRAINT unique_device_session UNIQUE (uid, user_agent, ip_address)
424
+ `);
425
+ logger.info(`✅ Added correct unique_device_session constraint to ${qualified}`);
426
+ }
427
+ } catch (perTableError: unknown) {
428
+ logger.warn(`⚠️ refresh_tokens reconcile failed for ${qualified}: ${perTableError instanceof Error ? perTableError.message : String(perTableError)}`);
429
+ }
430
+ }
431
+ } catch (migrationError: unknown) {
432
+ logger.warn(`⚠️ refresh_tokens device-session migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);
433
+ }
434
+
361
435
  // ── Migration: Copy roles from legacy junction table to inline column ──
362
436
  // If the old rebase.user_roles and rebase.roles tables exist, migrate
363
437
  // the data into the new TEXT[] column then drop the legacy tables.