@rebasepro/server-postgres 0.10.1-canary.18115ba → 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.
@@ -232,23 +232,6 @@ export declare function createAuthSchema(usersSchemaName?: string): {
232
232
  }, {}, {
233
233
  $type: Record<string, unknown>;
234
234
  }>;
235
- tokensValidAfter: import("drizzle-orm/pg-core").PgColumn<{
236
- name: "tokens_valid_after";
237
- tableName: "users";
238
- dataType: "date";
239
- columnType: "PgTimestamp";
240
- data: Date;
241
- driverParam: string;
242
- notNull: false;
243
- hasDefault: false;
244
- isPrimaryKey: false;
245
- isAutoincrement: false;
246
- hasRuntimeDefault: false;
247
- enumValues: undefined;
248
- baseColumn: never;
249
- identity: undefined;
250
- generated: undefined;
251
- }, {}, {}>;
252
235
  createdAt: import("drizzle-orm/pg-core").PgColumn<{
253
236
  name: "created_at";
254
237
  tableName: "users";
@@ -324,23 +307,6 @@ export declare function createAuthSchema(usersSchemaName?: string): {
324
307
  identity: undefined;
325
308
  generated: undefined;
326
309
  }, {}, {}>;
327
- sessionId: import("drizzle-orm/pg-core").PgColumn<{
328
- name: "session_id";
329
- tableName: "refresh_tokens";
330
- dataType: "string";
331
- columnType: "PgUUID";
332
- data: string;
333
- driverParam: string;
334
- notNull: true;
335
- hasDefault: true;
336
- isPrimaryKey: false;
337
- isAutoincrement: false;
338
- hasRuntimeDefault: false;
339
- enumValues: undefined;
340
- baseColumn: never;
341
- identity: undefined;
342
- generated: undefined;
343
- }, {}, {}>;
344
310
  tokenHash: import("drizzle-orm/pg-core").PgColumn<{
345
311
  name: "token_hash";
346
312
  tableName: "refresh_tokens";
@@ -377,57 +343,6 @@ export declare function createAuthSchema(usersSchemaName?: string): {
377
343
  identity: undefined;
378
344
  generated: undefined;
379
345
  }, {}, {}>;
380
- revoked: import("drizzle-orm/pg-core").PgColumn<{
381
- name: "revoked";
382
- tableName: "refresh_tokens";
383
- dataType: "boolean";
384
- columnType: "PgBoolean";
385
- data: boolean;
386
- driverParam: boolean;
387
- notNull: true;
388
- hasDefault: true;
389
- isPrimaryKey: false;
390
- isAutoincrement: false;
391
- hasRuntimeDefault: false;
392
- enumValues: undefined;
393
- baseColumn: never;
394
- identity: undefined;
395
- generated: undefined;
396
- }, {}, {}>;
397
- rotatedAt: import("drizzle-orm/pg-core").PgColumn<{
398
- name: "rotated_at";
399
- tableName: "refresh_tokens";
400
- dataType: "date";
401
- columnType: "PgTimestamp";
402
- data: Date;
403
- driverParam: string;
404
- notNull: false;
405
- hasDefault: false;
406
- isPrimaryKey: false;
407
- isAutoincrement: false;
408
- hasRuntimeDefault: false;
409
- enumValues: undefined;
410
- baseColumn: never;
411
- identity: undefined;
412
- generated: undefined;
413
- }, {}, {}>;
414
- sessionStartedAt: import("drizzle-orm/pg-core").PgColumn<{
415
- name: "session_started_at";
416
- tableName: "refresh_tokens";
417
- dataType: "date";
418
- columnType: "PgTimestamp";
419
- data: Date;
420
- driverParam: string;
421
- notNull: true;
422
- hasDefault: true;
423
- isPrimaryKey: false;
424
- isAutoincrement: false;
425
- hasRuntimeDefault: false;
426
- enumValues: undefined;
427
- baseColumn: never;
428
- identity: undefined;
429
- generated: undefined;
430
- }, {}, {}>;
431
346
  userAgent: import("drizzle-orm/pg-core").PgColumn<{
432
347
  name: "user_agent";
433
348
  tableName: "refresh_tokens";
@@ -1483,23 +1398,6 @@ export declare const users: import("drizzle-orm/pg-core").PgTableWithColumns<{
1483
1398
  }, {}, {
1484
1399
  $type: Record<string, unknown>;
1485
1400
  }>;
1486
- tokensValidAfter: import("drizzle-orm/pg-core").PgColumn<{
1487
- name: "tokens_valid_after";
1488
- tableName: "users";
1489
- dataType: "date";
1490
- columnType: "PgTimestamp";
1491
- data: Date;
1492
- driverParam: string;
1493
- notNull: false;
1494
- hasDefault: false;
1495
- isPrimaryKey: false;
1496
- isAutoincrement: false;
1497
- hasRuntimeDefault: false;
1498
- enumValues: undefined;
1499
- baseColumn: never;
1500
- identity: undefined;
1501
- generated: undefined;
1502
- }, {}, {}>;
1503
1401
  createdAt: import("drizzle-orm/pg-core").PgColumn<{
1504
1402
  name: "created_at";
1505
1403
  tableName: "users";
@@ -1575,23 +1473,6 @@ export declare const refreshTokens: import("drizzle-orm/pg-core").PgTableWithCol
1575
1473
  identity: undefined;
1576
1474
  generated: undefined;
1577
1475
  }, {}, {}>;
1578
- sessionId: import("drizzle-orm/pg-core").PgColumn<{
1579
- name: "session_id";
1580
- tableName: "refresh_tokens";
1581
- dataType: "string";
1582
- columnType: "PgUUID";
1583
- data: string;
1584
- driverParam: string;
1585
- notNull: true;
1586
- hasDefault: true;
1587
- isPrimaryKey: false;
1588
- isAutoincrement: false;
1589
- hasRuntimeDefault: false;
1590
- enumValues: undefined;
1591
- baseColumn: never;
1592
- identity: undefined;
1593
- generated: undefined;
1594
- }, {}, {}>;
1595
1476
  tokenHash: import("drizzle-orm/pg-core").PgColumn<{
1596
1477
  name: "token_hash";
1597
1478
  tableName: "refresh_tokens";
@@ -1628,57 +1509,6 @@ export declare const refreshTokens: import("drizzle-orm/pg-core").PgTableWithCol
1628
1509
  identity: undefined;
1629
1510
  generated: undefined;
1630
1511
  }, {}, {}>;
1631
- revoked: import("drizzle-orm/pg-core").PgColumn<{
1632
- name: "revoked";
1633
- tableName: "refresh_tokens";
1634
- dataType: "boolean";
1635
- columnType: "PgBoolean";
1636
- data: boolean;
1637
- driverParam: boolean;
1638
- notNull: true;
1639
- hasDefault: true;
1640
- isPrimaryKey: false;
1641
- isAutoincrement: false;
1642
- hasRuntimeDefault: false;
1643
- enumValues: undefined;
1644
- baseColumn: never;
1645
- identity: undefined;
1646
- generated: undefined;
1647
- }, {}, {}>;
1648
- rotatedAt: import("drizzle-orm/pg-core").PgColumn<{
1649
- name: "rotated_at";
1650
- tableName: "refresh_tokens";
1651
- dataType: "date";
1652
- columnType: "PgTimestamp";
1653
- data: Date;
1654
- driverParam: string;
1655
- notNull: false;
1656
- hasDefault: false;
1657
- isPrimaryKey: false;
1658
- isAutoincrement: false;
1659
- hasRuntimeDefault: false;
1660
- enumValues: undefined;
1661
- baseColumn: never;
1662
- identity: undefined;
1663
- generated: undefined;
1664
- }, {}, {}>;
1665
- sessionStartedAt: import("drizzle-orm/pg-core").PgColumn<{
1666
- name: "session_started_at";
1667
- tableName: "refresh_tokens";
1668
- dataType: "date";
1669
- columnType: "PgTimestamp";
1670
- data: Date;
1671
- driverParam: string;
1672
- notNull: true;
1673
- hasDefault: true;
1674
- isPrimaryKey: false;
1675
- isAutoincrement: false;
1676
- hasRuntimeDefault: false;
1677
- enumValues: undefined;
1678
- baseColumn: never;
1679
- identity: undefined;
1680
- generated: undefined;
1681
- }, {}, {}>;
1682
1512
  userAgent: import("drizzle-orm/pg-core").PgColumn<{
1683
1513
  name: "user_agent";
1684
1514
  tableName: "refresh_tokens";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-postgres",
3
3
  "type": "module",
4
- "version": "0.10.1-canary.18115ba",
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.1-canary.18115ba",
51
- "@rebasepro/common": "0.10.1-canary.18115ba",
52
- "@rebasepro/utils": "0.10.1-canary.18115ba",
53
- "@rebasepro/server": "0.10.1-canary.18115ba",
54
- "@rebasepro/types": "0.10.1-canary.18115ba"
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",
@@ -107,7 +107,6 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
107
107
  email_verification_sent_at TIMESTAMP WITH TIME ZONE,
108
108
  is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
109
109
  metadata JSONB DEFAULT '{}' NOT NULL,
110
- tokens_valid_after TIMESTAMP WITH TIME ZONE,
111
110
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
112
111
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
113
112
  )
@@ -210,23 +209,17 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
210
209
  `);
211
210
 
212
211
 
213
- // Create refresh tokens table. One row per TOKEN, grouped into a
214
- // sign-in by session_id — deliberately without a uniqueness rule on
215
- // (uid, user_agent, ip_address); see the schema module for why that
216
- // constraint had to go.
212
+ // Create refresh tokens table (includes user_agent, ip_address, and unique constraint)
217
213
  await db.execute(sql`
218
214
  CREATE TABLE IF NOT EXISTS ${sql.raw(refreshTokensTableName)} (
219
215
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
220
216
  uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
221
- session_id TEXT NOT NULL DEFAULT gen_random_uuid()::text,
222
217
  token_hash TEXT NOT NULL UNIQUE,
223
218
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
224
- revoked BOOLEAN DEFAULT FALSE NOT NULL,
225
- rotated_at TIMESTAMP WITH TIME ZONE,
226
- session_started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
227
219
  user_agent TEXT,
228
220
  ip_address TEXT,
229
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
221
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
222
+ CONSTRAINT unique_device_session UNIQUE (uid, user_agent, ip_address)
230
223
  )
231
224
  `);
232
225
 
@@ -355,7 +348,6 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
355
348
  "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
356
349
  "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
357
350
  "metadata JSONB DEFAULT '{}' NOT NULL",
358
- "tokens_valid_after TIMESTAMP WITH TIME ZONE",
359
351
  "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
360
352
  "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
361
353
  ];
@@ -366,26 +358,16 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
366
358
  `);
367
359
  }
368
360
 
369
- // ── Migration: refresh_tokens become session-scoped, rotation-safe ──
370
- // Two shapes are reconciled here, on EVERY table named refresh_tokens
371
- // in whatever schema it lives (a database provisioned by an older era
372
- // can carry the table in a different schema than the one this run
373
- // derives, and auth would then read a table nobody migrated):
374
- //
375
- // 1. The new columns. A token is now a member of a session
376
- // (`session_id`) and is retained after rotation (`revoked`,
377
- // `rotated_at`) so a replayed token can be recognised instead of
378
- // looking like a forgery. `session_started_at` is carried across
379
- // rotations so `users.tokens_valid_after` cannot be outrun.
380
- // 2. The removal of `unique_device_session`. It made (uid,
381
- // user_agent, ip_address) the identity of a session, which evicted
382
- // a second browser profile behind one NAT and churned rows as
383
- // phones changed networks. UA and IP are metadata now.
384
- //
385
- // Every existing row is adopted rather than dropped: it keeps its
386
- // token_hash, gets a session of its own, and stays unrevoked — so the
387
- // sessions live in browsers right now survive the upgrade rather than
388
- // everyone being signed out by the fix for being signed out.
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.
389
371
  try {
390
372
  const rtTables = await db.execute(sql`
391
373
  SELECT table_schema, table_name
@@ -397,53 +379,57 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
397
379
  for (const { table_schema } of found) {
398
380
  const qualified = `"${table_schema}"."refresh_tokens"`;
399
381
  try {
400
- // Added nullable, then back-filled, then constrained: adding
401
- // `session_id NOT NULL DEFAULT gen_random_uuid()` in one step
402
- // would stamp every existing row with the SAME uuid on some
403
- // Postgres versions, silently merging every live session into
404
- // one that a single logout would then wipe.
405
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_id TEXT`);
406
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);
407
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);
408
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_started_at TIMESTAMP WITH TIME ZONE`);
409
-
410
- // One session per pre-existing row: under the old model a row
411
- // WAS a device session, and there is no record of which rows
412
- // descended from the same sign-in.
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).
413
390
  await db.execute(sql`
414
- UPDATE ${sql.raw(qualified)}
415
- SET session_id = gen_random_uuid()::text
416
- WHERE session_id IS NULL
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
417
397
  `);
418
- await db.execute(sql`
419
- UPDATE ${sql.raw(qualified)}
420
- SET session_started_at = COALESCE(created_at, NOW())
421
- WHERE session_started_at IS NULL
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'
422
403
  `);
423
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET DEFAULT gen_random_uuid()::text`);
424
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET DEFAULT NOW()`);
425
- // SET NOT NULL only once the back-fill above has definitely
426
- // run; on a table that somehow still holds a NULL this throws
427
- // and is caught below rather than failing the boot.
428
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET NOT NULL`);
429
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET NOT NULL`);
430
-
431
- await db.execute(sql`
432
- CREATE INDEX IF NOT EXISTS idx_refresh_tokens_session
433
- ON ${sql.raw(qualified)}(session_id)
434
- `);
435
-
436
- // The device-session constraint is now actively harmful: two
437
- // live tokens of one session (a rotation in flight) share a
438
- // uid, and usually a user agent and IP too.
439
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);
440
- logger.info(`✅ refresh_tokens reconciled for session-scoped rotation: ${qualified}`);
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
+ }
441
427
  } catch (perTableError: unknown) {
442
428
  logger.warn(`⚠️ refresh_tokens reconcile failed for ${qualified}: ${perTableError instanceof Error ? perTableError.message : String(perTableError)}`);
443
429
  }
444
430
  }
445
431
  } catch (migrationError: unknown) {
446
- logger.warn(`⚠️ refresh_tokens session migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);
432
+ logger.warn(`⚠️ refresh_tokens device-session migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);
447
433
  }
448
434
 
449
435
  // ── Migration: Copy roles from legacy junction table to inline column ──