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

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 (69) 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/chunk-DSJWtz9O.js +40 -0
  7. package/dist/cli-helpers.d.ts +33 -1
  8. package/dist/ensure-collection-tables-BVvtkkRm.js +304 -0
  9. package/dist/ensure-collection-tables-BVvtkkRm.js.map +1 -0
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.es.js +1854 -4745
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/schema/auth-schema.d.ts +170 -0
  14. package/dist/schema/destructive-sql.d.ts +49 -0
  15. package/dist/schema/ensure-collection-tables.d.ts +79 -0
  16. package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
  17. package/dist/services/FetchService.d.ts +21 -8
  18. package/dist/services/PersistService.d.ts +12 -0
  19. package/dist/services/RelationService.d.ts +30 -0
  20. package/dist/services/cdc/CdcListener.d.ts +7 -14
  21. package/dist/services/cdc/junction-tables.d.ts +38 -0
  22. package/dist/services/channel-bus/ChannelBus.d.ts +29 -0
  23. package/dist/services/channel-bus/PostgresChannelBus.d.ts +111 -0
  24. package/dist/services/channel-bus/index.d.ts +55 -0
  25. package/dist/services/channel-history.d.ts +11 -0
  26. package/dist/services/channel-presence.d.ts +66 -0
  27. package/dist/services/nested-path.d.ts +59 -0
  28. package/dist/services/pg-notify-listener.d.ts +47 -0
  29. package/dist/services/realtimeService.d.ts +133 -6
  30. package/dist/src-CBgtrPhJ.js +336 -0
  31. package/dist/src-CBgtrPhJ.js.map +1 -0
  32. package/dist/src-lcfUP4xg.js +4106 -0
  33. package/dist/src-lcfUP4xg.js.map +1 -0
  34. package/dist/utils/drizzle-conditions.d.ts +41 -0
  35. package/package.json +8 -9
  36. package/src/PostgresBootstrapper.ts +82 -4
  37. package/src/auth/ensure-tables.ts +70 -56
  38. package/src/auth/services.ts +186 -48
  39. package/src/backup/backup-cli.ts +60 -1
  40. package/src/backup/backup-cron.ts +24 -1
  41. package/src/backup/backup-logic.ts +62 -0
  42. package/src/backup/backup-service.ts +132 -13
  43. package/src/backup/pg-tools.ts +70 -2
  44. package/src/cli-helpers.ts +82 -27
  45. package/src/cli.ts +152 -6
  46. package/src/index.ts +4 -0
  47. package/src/schema/auth-schema.ts +41 -3
  48. package/src/schema/destructive-sql.ts +94 -0
  49. package/src/schema/doctor.ts +6 -6
  50. package/src/schema/ensure-collection-tables.test.ts +156 -0
  51. package/src/schema/ensure-collection-tables.ts +297 -0
  52. package/src/schema/generate-drizzle-schema-logic.ts +13 -9
  53. package/src/schema/generate-postgres-ddl-logic.ts +22 -15
  54. package/src/schema/introspect-db-inference.ts +13 -13
  55. package/src/schema/introspect-db-logic.ts +6 -6
  56. package/src/services/FetchService.ts +104 -114
  57. package/src/services/PersistService.ts +127 -85
  58. package/src/services/RelationService.ts +95 -6
  59. package/src/services/cdc/CdcListener.ts +27 -91
  60. package/src/services/cdc/junction-tables.ts +91 -0
  61. package/src/services/channel-bus/ChannelBus.ts +44 -0
  62. package/src/services/channel-bus/PostgresChannelBus.ts +299 -0
  63. package/src/services/channel-bus/index.ts +123 -0
  64. package/src/services/channel-history.ts +35 -0
  65. package/src/services/channel-presence.ts +148 -0
  66. package/src/services/nested-path.ts +130 -0
  67. package/src/services/pg-notify-listener.ts +137 -0
  68. package/src/services/realtimeService.ts +443 -14
  69. package/src/utils/drizzle-conditions.ts +154 -0
@@ -18,6 +18,47 @@ export interface DrizzleDynamicQuery {
18
18
  * const builder: ConditionBuilderStatic<SQL> = DrizzleConditionBuilder;
19
19
  */
20
20
  export declare class DrizzleConditionBuilder {
21
+ /**
22
+ * Express "reachable from this parent through this relation" as a plain
23
+ * `WHERE` condition on the target table.
24
+ *
25
+ * This is the primitive that lets a relation be a *filter* rather than an
26
+ * addressing scheme. A nested listing used to be served by its own query
27
+ * builder — `fetchEntitiesUsingJoins`, which grew joins the root pipeline
28
+ * did not have and lost the options the root pipeline did have (offset,
29
+ * filter, orderBy, include). Reduced to a condition, the same listing runs
30
+ * through the ordinary collection query, so it inherits all of them and
31
+ * there is one read path instead of two.
32
+ *
33
+ * The shapes:
34
+ * - inverse FK → `target.<fk> = :parentId`, a column comparison.
35
+ * - `through` → `EXISTS (SELECT 1 FROM junction …)`, correlated on the
36
+ * target's key, so the junction never multiplies rows the
37
+ * way an `INNER JOIN` would.
38
+ * - `joinPath` → the same `EXISTS`, with the path's steps joined inside
39
+ * it and the final step correlating to the outer row.
40
+ */
41
+ static buildRelationScopeCondition(relation: Relation,
42
+ /**
43
+ * Lazy: only `joinPath` and `localKey` relations need the parent's own
44
+ * table. An inverse foreign key and a junction are both expressible
45
+ * from the parent's *id* alone, and requiring the table for them would
46
+ * make a child listing fail on a parent whose table isn't registered.
47
+ */
48
+ parent: () => {
49
+ table: PgTable<any>;
50
+ idColumn: AnyPgColumn;
51
+ }, parentId: string | number, targetTable: PgTable<any>, targetIdColumn: AnyPgColumn, registry: PostgresCollectionRegistry): SQL;
52
+ /**
53
+ * `EXISTS` for an explicit `joinPath`.
54
+ *
55
+ * The path is declared source → target. The subquery replays every step but
56
+ * the last from inside, and turns the last one into the correlation with the
57
+ * outer target row — so the target table is never named twice and needs no
58
+ * alias. Each intermediate table is aliased positionally, which keeps a path
59
+ * that revisits a table (a self-referencing many-to-many) unambiguous.
60
+ */
61
+ private static buildJoinPathScopeCondition;
21
62
  /**
22
63
  * Build filter conditions from FilterValues
23
64
  */
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.31c773c",
4
+ "version": "0.10.1-canary.3d09338",
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,21 +47,20 @@
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.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"
50
+ "@rebasepro/codegen": "0.10.1-canary.3d09338",
51
+ "@rebasepro/server": "0.10.1-canary.3d09338",
52
+ "@rebasepro/types": "0.10.1-canary.3d09338",
53
+ "@rebasepro/common": "0.10.1-canary.3d09338",
54
+ "@rebasepro/utils": "0.10.1-canary.3d09338"
55
55
  },
56
56
  "devDependencies": {
57
- "@hono/node-server": "^2.0.9",
57
+ "@hono/node-server": "^2.0.11",
58
58
  "@jest/globals": "^30.4.1",
59
59
  "@types/jest": "^30.0.0",
60
60
  "@types/node": "^25.9.3",
61
61
  "@types/pg": "^8.20.0",
62
62
  "@types/ws": "^8.18.1",
63
- "@vitejs/plugin-react": "^6.0.2",
64
- "hono": "^4.12.25",
63
+ "hono": "^4.12.27",
65
64
  "jest": "^30.4.2",
66
65
  "ts-jest": "^29.4.11",
67
66
  "typescript": "^6.0.3",
@@ -37,6 +37,9 @@ 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 { collectJunctionLinks } from "./services/cdc/junction-tables";
41
+ import { createChannelBus, resolveChannelBusSetting } from "./services/channel-bus";
42
+ import { isChannelBusInstance } from "@rebasepro/types";
40
43
 
41
44
  export interface PostgresDriverConfig {
42
45
  connectionString?: string;
@@ -54,9 +57,13 @@ export interface PostgresDriverConfig {
54
57
  */
55
58
  introspectionSchema?: string;
56
59
  /**
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
+ * Realtime options, both opt-in:
61
+ *
62
+ * - `channels` — retention. Without rules no channel keeps any history and
63
+ * broadcast stays fire-and-forget. See {@link ChannelRetentionRule}.
64
+ * - `bus` — the cross-instance transport for channel broadcast and
65
+ * presence. Defaults to in-process only, which is correct for a single
66
+ * instance and wrong for two. See {@link ChannelBusConfig}.
60
67
  */
61
68
  realtime?: RealtimeChannelsConfig;
62
69
  }
@@ -227,8 +234,8 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
227
234
  ` The database server is not running or is not accepting\n` +
228
235
  ` connections. Common fixes:\n` +
229
236
  `\n` +
237
+ ` • docker compose up -d db (the service a Rebase scaffold ships)\n` +
230
238
  ` • brew services start postgresql@18\n` +
231
- ` • docker compose up -d postgres\n` +
232
239
  ` • Verify DATABASE_URL in your .env file\n` +
233
240
  `\n` +
234
241
  `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
@@ -337,6 +344,29 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
337
344
  // Prefer DATABASE_DIRECT_URL to bypass PgBouncer for LISTEN/NOTIFY.
338
345
  const directUrl = process.env.DATABASE_DIRECT_URL || pgConfig.connectionString;
339
346
 
347
+ // ── Cross-instance channel bus ───────────────────────────────────
348
+ // Entity changes already span instances (CDC / LISTEN below).
349
+ // Channel broadcast and presence did not — they lived in per-process
350
+ // maps, so behind two replicas the clients of one were invisible to
351
+ // the other. Opt-in, and a no-op when left at "memory".
352
+ try {
353
+ const busSetting = resolveChannelBusSetting(pgConfig.realtime?.bus);
354
+ // A supplied instance is always installed; a named built-in only
355
+ // when it is not the in-process default, so the common case
356
+ // touches none of this machinery.
357
+ const wantsBus = isChannelBusInstance(busSetting) || busSetting.type !== "memory";
358
+ if (wantsBus) {
359
+ await realtimeService.configureChannelBus(
360
+ createChannelBus(busSetting, {
361
+ db: schemaAwareDb as unknown as NodePgDatabase<Record<string, unknown>>,
362
+ directUrl
363
+ })
364
+ );
365
+ }
366
+ } catch (err) {
367
+ logger.warn("⚠️ [ChannelBus] Could not configure the channel bus — channel broadcast and presence stay per-instance", { error: err });
368
+ }
369
+
340
370
  // Database-level Change Data Capture. When active, realtime events are
341
371
  // emitted for EVERY committed write — including ones that bypass the
342
372
  // Rebase API (psql, another service's cron, raw SQL, the Studio SQL
@@ -388,6 +418,14 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
388
418
  table: getCollectionTableName(c)
389
419
  }))
390
420
  .filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table));
421
+ // Junction tables back no collection, so the list above misses
422
+ // them — and a link or unlink is a write nobody would hear
423
+ // about. Their rows are the contents of a child list, which is
424
+ // as much a change as a write to the rows themselves.
425
+ for (const link of collectJunctionLinks(registry)) {
426
+ cdcTables.push({ schema: link.schema,
427
+ table: link.table });
428
+ }
391
429
  // Provisioning throws only when the connection can't create the
392
430
  // trigger function (insufficient privilege); enableCdc throws when
393
431
  // the LISTEN connection can't be established. Either → fall back.
@@ -615,6 +653,46 @@ authRepository };
615
653
  return internals.realtimeService;
616
654
  },
617
655
 
656
+ /**
657
+ * Create any collection tables, columns and enum types the database is
658
+ * missing — additively, never destructively.
659
+ *
660
+ * This is what lets the managed runtime boot a project against a fresh
661
+ * database and actually serve it. Before this, only auth tables were
662
+ * ensured, so a managed tenant came up with working sign-in and a 500 on
663
+ * every data route.
664
+ *
665
+ * Runs through the drizzle handle's underlying session so it uses the
666
+ * same connection (and therefore the same privileges) the driver already
667
+ * proved it can bootstrap with.
668
+ */
669
+ async ensureCollectionSchema(
670
+ collections: unknown[],
671
+ driverResult: InitializedDriver,
672
+ log?: (message: string) => void
673
+ ): Promise<{ applied: number }> {
674
+ const internals = driverResult.internals as PostgresDriverInternals;
675
+ const { ensureCollectionTables } = await import("./schema/ensure-collection-tables");
676
+ // Runs through the drizzle handle the driver already bootstrapped
677
+ // with, so it uses exactly the connection and privileges that were
678
+ // proven to work. Every statement is DDL or a catalogue read with no
679
+ // bindable values (schema names are identifiers), and the module
680
+ // validates them before they reach a string.
681
+ const queryable = {
682
+ async query<T>(text: string): Promise<{ rows: T[] }> {
683
+ const result = await internals.db.execute(sql.raw(text));
684
+ const rows = (result as unknown as { rows?: T[] }).rows;
685
+ return { rows: rows ?? (Array.isArray(result) ? (result as T[]) : []) };
686
+ }
687
+ };
688
+ const plan = await ensureCollectionTables(
689
+ queryable,
690
+ collections as Parameters<typeof ensureCollectionTables>[1],
691
+ log
692
+ );
693
+ return { applied: plan.actions.length };
694
+ },
695
+
618
696
  getAdmin(driverResult: InitializedDriver): DatabaseAdmin | undefined {
619
697
  const internals = driverResult.internals as PostgresDriverInternals;
620
698
  return internals.driver.admin;
@@ -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 ──