@rebasepro/server-postgres 0.10.1-canary.a54c057 → 0.10.1-canary.b1e3dbf

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 (41) hide show
  1. package/dist/PostgresBootstrapper.d.ts +7 -3
  2. package/dist/auth/services.d.ts +43 -4
  3. package/dist/backup/backup-logic.d.ts +23 -0
  4. package/dist/backup/backup-service.d.ts +44 -2
  5. package/dist/backup/pg-tools.d.ts +41 -1
  6. package/dist/cli-helpers.d.ts +33 -1
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.es.js +1314 -159
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/schema/auth-schema.d.ts +170 -0
  11. package/dist/schema/destructive-sql.d.ts +49 -0
  12. package/dist/services/cdc/CdcListener.d.ts +7 -14
  13. package/dist/services/channel-bus/ChannelBus.d.ts +29 -0
  14. package/dist/services/channel-bus/PostgresChannelBus.d.ts +111 -0
  15. package/dist/services/channel-bus/index.d.ts +55 -0
  16. package/dist/services/channel-history.d.ts +11 -0
  17. package/dist/services/channel-presence.d.ts +66 -0
  18. package/dist/services/pg-notify-listener.d.ts +47 -0
  19. package/dist/services/realtimeService.d.ts +114 -6
  20. package/package.json +6 -6
  21. package/src/PostgresBootstrapper.ts +32 -3
  22. package/src/auth/ensure-tables.ts +70 -56
  23. package/src/auth/services.ts +186 -48
  24. package/src/backup/backup-cli.ts +60 -1
  25. package/src/backup/backup-cron.ts +24 -1
  26. package/src/backup/backup-logic.ts +62 -0
  27. package/src/backup/backup-service.ts +132 -13
  28. package/src/backup/pg-tools.ts +70 -2
  29. package/src/cli-helpers.ts +82 -27
  30. package/src/cli.ts +152 -6
  31. package/src/index.ts +4 -0
  32. package/src/schema/auth-schema.ts +41 -3
  33. package/src/schema/destructive-sql.ts +94 -0
  34. package/src/services/cdc/CdcListener.ts +27 -91
  35. package/src/services/channel-bus/ChannelBus.ts +44 -0
  36. package/src/services/channel-bus/PostgresChannelBus.ts +299 -0
  37. package/src/services/channel-bus/index.ts +123 -0
  38. package/src/services/channel-history.ts +35 -0
  39. package/src/services/channel-presence.ts +148 -0
  40. package/src/services/pg-notify-listener.ts +137 -0
  41. package/src/services/realtimeService.ts +383 -14
@@ -4,6 +4,7 @@ import { DataDriver, WebSocketMessage } from "@rebasepro/types";
4
4
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
5
5
  import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
6
6
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
7
+ import { ChannelBus } from "./channel-bus";
7
8
  import type { ChannelRetentionRule } from "@rebasepro/types";
8
9
  /**
9
10
  * Auth context stored per-subscription so real-time refetches respect RLS.
@@ -45,8 +46,33 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
45
46
  * wait on each other.
46
47
  */
47
48
  private channelSendQueues;
49
+ /**
50
+ * Cross-instance transport for channel frames and presence.
51
+ *
52
+ * Defaults to the memory bus, which publishes nowhere — so a single-instance
53
+ * deployment runs the same fan-out it always did, with one resolved promise
54
+ * per broadcast for company. See `channel-bus/ChannelBus.ts`.
55
+ */
56
+ private bus;
57
+ /**
58
+ * The shared presence roster, present only when a real bus is active.
59
+ *
60
+ * Fan-out alone is not enough for presence: `presence_state` has to answer
61
+ * with everyone in the channel, and per-process maps can only answer for
62
+ * this replica's clients. See `channel-presence.ts`.
63
+ */
64
+ private presenceStore?;
65
+ /** Sweeps roster rows left behind by instances that stopped heartbeating. */
66
+ private presenceSweepInterval?;
67
+ /**
68
+ * Channels whose oversized ephemeral broadcasts have already been reported,
69
+ * so a hot channel logs the problem once rather than once per message.
70
+ */
71
+ private oversizedBroadcastWarned;
48
72
  private presenceInterval?;
49
73
  private static readonly PRESENCE_TIMEOUT_MS;
74
+ /** How often stale roster rows from other instances are reaped. */
75
+ private static readonly PRESENCE_SWEEP_INTERVAL_MS;
50
76
  private dataService;
51
77
  private _subscriptions;
52
78
  private subscriptionCallbacks;
@@ -245,6 +271,44 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
245
271
  private persistAndFanOut;
246
272
  /** Deliver a broadcast frame to every member of a channel but the sender. */
247
273
  private fanOutBroadcast;
274
+ /**
275
+ * Install the transport that carries channel frames between instances.
276
+ *
277
+ * Called once at boot. A bus that cannot start is reported and replaced with
278
+ * the memory bus: losing cross-instance fan-out degrades collaboration to
279
+ * what it was before this existed, whereas refusing to boot takes the whole
280
+ * backend down for it.
281
+ */
282
+ configureChannelBus(bus: ChannelBus): Promise<void>;
283
+ /** Which transport is in use — `"memory"` means per-instance only. */
284
+ getChannelBusKind(): ChannelBus["kind"];
285
+ /**
286
+ * Send a broadcast to the other instances.
287
+ *
288
+ * Fire-and-forget by design: the clients on this instance have already been
289
+ * served, and a bus that is briefly unreachable must not turn a broadcast
290
+ * into an error for the sender.
291
+ */
292
+ private publishBroadcast;
293
+ private publishFrame;
294
+ /**
295
+ * Tell the sender that a message was delivered locally but nowhere else.
296
+ *
297
+ * Staying quiet here would be the worst option available: on one instance
298
+ * the app works, on two it works for half the users, and nothing in the
299
+ * logs connects the two. The fix is a one-liner in config — give the
300
+ * channel a retention rule and the message travels as a pointer instead —
301
+ * so the message says exactly that.
302
+ */
303
+ private reportOversizedBroadcast;
304
+ /**
305
+ * Deliver a frame published by another instance to this one's clients.
306
+ *
307
+ * Frames we published ourselves are dropped on arrival — the local fan-out
308
+ * happened before the publish — exactly as the entity-change handler skips
309
+ * its own `sid`.
310
+ */
311
+ private handleBusFrame;
248
312
  /**
249
313
  * Install retention rules and create the tables they need.
250
314
  *
@@ -266,16 +330,60 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
266
330
  */
267
331
  private handleChannelHistoryRequest;
268
332
  private sendChannelHistory;
269
- /** Track presence in a channel */
333
+ /**
334
+ * Track presence in a channel.
335
+ *
336
+ * The client re-sends this every ~20s as a heartbeat against the 30s
337
+ * timeout, so most calls carry the state that is already recorded. Those
338
+ * refresh `last_seen` and stop there: re-announcing an unchanged state to
339
+ * every instance would put a bus message per client per heartbeat on the
340
+ * wire to tell everyone nothing happened.
341
+ */
270
342
  trackPresence(clientId: string, channel: string, state: Record<string, unknown>): void;
271
- /** Remove presence from a channel */
272
- removePresence(clientId: string, channel: string): void;
273
- /** Send full presence state to a specific client */
343
+ /**
344
+ * Remove presence from a channel.
345
+ *
346
+ * `skipStore` is for the socket-close path, which clears every channel at
347
+ * once and then deletes the client's rows in a single statement instead of
348
+ * one per channel.
349
+ */
350
+ removePresence(clientId: string, channel: string, options?: {
351
+ skipStore?: boolean;
352
+ }): void;
353
+ /**
354
+ * Send the full roster for a channel to one client.
355
+ *
356
+ * Answered from the shared table when there is one, because "who is in this
357
+ * document?" has a single answer that must not depend on which replica the
358
+ * asker happens to be connected to. Without a bus there is nothing to share
359
+ * and the local map *is* the roster — that path stays synchronous, which is
360
+ * what it always was.
361
+ */
274
362
  sendPresenceState(clientId: string, channel: string): void;
275
- /** Broadcast presence diff (joins/leaves) to channel */
276
- private broadcastPresenceDiff;
363
+ /** Presence of the clients connected to this instance. */
364
+ private localPresences;
365
+ private sendPresenceStateMessage;
366
+ /** Deliver a presence diff to this instance's members of the channel. */
367
+ private deliverPresenceDiff;
368
+ /** Tell the other instances about a presence change. */
369
+ private publishPresenceDiff;
370
+ /** Run a roster write when there is a roster, and never let it throw. */
371
+ private presenceStoreOp;
277
372
  /** Periodic cleanup for stale presences */
278
373
  private ensurePresenceCleanup;
374
+ /**
375
+ * Reap roster rows whose owning instance stopped heartbeating.
376
+ *
377
+ * This is the cross-instance half of the sweep above, and it doubles as
378
+ * crash recovery: a pod that dies takes its clients with it but leaves
379
+ * their rows behind, and after one TTL window they look exactly like any
380
+ * other client that went quiet. The delete returns what it removed, so
381
+ * whichever instance wins the race is the one that announces the
382
+ * departures — once for the cluster, not once per replica.
383
+ */
384
+ private ensurePresenceSweep;
385
+ /** One pass of the stale-roster sweep. See {@link ensurePresenceSweep}. */
386
+ private sweepStalePresence;
279
387
  /**
280
388
  * Gracefully tear down all realtime resources.
281
389
  *
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.a54c057",
4
+ "version": "0.10.1-canary.b1e3dbf",
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.a54c057",
51
- "@rebasepro/common": "0.10.1-canary.a54c057",
52
- "@rebasepro/server": "0.10.1-canary.a54c057",
53
- "@rebasepro/types": "0.10.1-canary.a54c057",
54
- "@rebasepro/utils": "0.10.1-canary.a54c057"
50
+ "@rebasepro/codegen": "0.10.1-canary.b1e3dbf",
51
+ "@rebasepro/common": "0.10.1-canary.b1e3dbf",
52
+ "@rebasepro/types": "0.10.1-canary.b1e3dbf",
53
+ "@rebasepro/server": "0.10.1-canary.b1e3dbf",
54
+ "@rebasepro/utils": "0.10.1-canary.b1e3dbf"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@hono/node-server": "^2.0.9",
@@ -37,6 +37,8 @@ import { buildCollectionsFromSchema, introspectSchema, readRlsStatus } from "./s
37
37
  import { buildDrizzleTablesFromSchema, buildDrizzleRelationsFromSchema } from "./schema/dynamic-tables";
38
38
  import { detectConnectionPosture, ensureAppRole, validatePolicyPgRoles, warnOnAnonymousGrants, REBASE_USER_ROLE, type RawSqlRunner } from "./security/rls-enforcement";
39
39
  import { provisionTriggerCdc, type CdcTableRef } from "./services/cdc/trigger-cdc";
40
+ import { createChannelBus, resolveChannelBusSetting } from "./services/channel-bus";
41
+ import { isChannelBusInstance } from "@rebasepro/types";
40
42
 
41
43
  export interface PostgresDriverConfig {
42
44
  connectionString?: string;
@@ -54,9 +56,13 @@ export interface PostgresDriverConfig {
54
56
  */
55
57
  introspectionSchema?: string;
56
58
  /**
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}.
59
+ * Realtime options, both opt-in:
60
+ *
61
+ * - `channels` — retention. Without rules no channel keeps any history and
62
+ * broadcast stays fire-and-forget. See {@link ChannelRetentionRule}.
63
+ * - `bus` — the cross-instance transport for channel broadcast and
64
+ * presence. Defaults to in-process only, which is correct for a single
65
+ * instance and wrong for two. See {@link ChannelBusConfig}.
60
66
  */
61
67
  realtime?: RealtimeChannelsConfig;
62
68
  }
@@ -337,6 +343,29 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
337
343
  // Prefer DATABASE_DIRECT_URL to bypass PgBouncer for LISTEN/NOTIFY.
338
344
  const directUrl = process.env.DATABASE_DIRECT_URL || pgConfig.connectionString;
339
345
 
346
+ // ── Cross-instance channel bus ───────────────────────────────────
347
+ // Entity changes already span instances (CDC / LISTEN below).
348
+ // Channel broadcast and presence did not — they lived in per-process
349
+ // maps, so behind two replicas the clients of one were invisible to
350
+ // the other. Opt-in, and a no-op when left at "memory".
351
+ try {
352
+ const busSetting = resolveChannelBusSetting(pgConfig.realtime?.bus);
353
+ // A supplied instance is always installed; a named built-in only
354
+ // when it is not the in-process default, so the common case
355
+ // touches none of this machinery.
356
+ const wantsBus = isChannelBusInstance(busSetting) || busSetting.type !== "memory";
357
+ if (wantsBus) {
358
+ await realtimeService.configureChannelBus(
359
+ createChannelBus(busSetting, {
360
+ db: schemaAwareDb as unknown as NodePgDatabase<Record<string, unknown>>,
361
+ directUrl
362
+ })
363
+ );
364
+ }
365
+ } catch (err) {
366
+ logger.warn("⚠️ [ChannelBus] Could not configure the channel bus — channel broadcast and presence stay per-instance", { error: err });
367
+ }
368
+
340
369
  // Database-level Change Data Capture. When active, realtime events are
341
370
  // emitted for EVERY committed write — including ones that bypass the
342
371
  // Rebase API (psql, another service's cron, raw SQL, the Studio SQL
@@ -107,6 +107,7 @@ 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,
110
111
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
111
112
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
112
113
  )
@@ -209,17 +210,23 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
209
210
  `);
210
211
 
211
212
 
212
- // Create refresh tokens table (includes user_agent, ip_address, and unique constraint)
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.
213
217
  await db.execute(sql`
214
218
  CREATE TABLE IF NOT EXISTS ${sql.raw(refreshTokensTableName)} (
215
219
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
216
220
  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,
217
222
  token_hash TEXT NOT NULL UNIQUE,
218
223
  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,
219
227
  user_agent TEXT,
220
228
  ip_address TEXT,
221
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
222
- CONSTRAINT unique_device_session UNIQUE (uid, user_agent, ip_address)
229
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
223
230
  )
224
231
  `);
225
232
 
@@ -348,6 +355,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
348
355
  "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
349
356
  "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
350
357
  "metadata JSONB DEFAULT '{}' NOT NULL",
358
+ "tokens_valid_after TIMESTAMP WITH TIME ZONE",
351
359
  "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
352
360
  "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
353
361
  ];
@@ -358,16 +366,26 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
358
366
  `);
359
367
  }
360
368
 
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.
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.
371
389
  try {
372
390
  const rtTables = await db.execute(sql`
373
391
  SELECT table_schema, table_name
@@ -379,57 +397,53 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
379
397
  for (const { table_schema } of found) {
380
398
  const qualified = `"${table_schema}"."refresh_tokens"`;
381
399
  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).
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.
390
413
  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
414
+ UPDATE ${sql.raw(qualified)}
415
+ SET session_id = gen_random_uuid()::text
416
+ WHERE session_id IS NULL
397
417
  `);
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'
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
403
422
  `);
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
- }
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}`);
427
441
  } catch (perTableError: unknown) {
428
442
  logger.warn(`⚠️ refresh_tokens reconcile failed for ${qualified}: ${perTableError instanceof Error ? perTableError.message : String(perTableError)}`);
429
443
  }
430
444
  }
431
445
  } catch (migrationError: unknown) {
432
- logger.warn(`⚠️ refresh_tokens device-session migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);
446
+ logger.warn(`⚠️ refresh_tokens session migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);
433
447
  }
434
448
 
435
449
  // ── Migration: Copy roles from legacy junction table to inline column ──