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

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,6 +232,23 @@ 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
+ }, {}, {}>;
235
252
  createdAt: import("drizzle-orm/pg-core").PgColumn<{
236
253
  name: "created_at";
237
254
  tableName: "users";
@@ -307,6 +324,23 @@ export declare function createAuthSchema(usersSchemaName?: string): {
307
324
  identity: undefined;
308
325
  generated: undefined;
309
326
  }, {}, {}>;
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
+ }, {}, {}>;
310
344
  tokenHash: import("drizzle-orm/pg-core").PgColumn<{
311
345
  name: "token_hash";
312
346
  tableName: "refresh_tokens";
@@ -343,6 +377,57 @@ export declare function createAuthSchema(usersSchemaName?: string): {
343
377
  identity: undefined;
344
378
  generated: undefined;
345
379
  }, {}, {}>;
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
+ }, {}, {}>;
346
431
  userAgent: import("drizzle-orm/pg-core").PgColumn<{
347
432
  name: "user_agent";
348
433
  tableName: "refresh_tokens";
@@ -1398,6 +1483,23 @@ export declare const users: import("drizzle-orm/pg-core").PgTableWithColumns<{
1398
1483
  }, {}, {
1399
1484
  $type: Record<string, unknown>;
1400
1485
  }>;
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
+ }, {}, {}>;
1401
1503
  createdAt: import("drizzle-orm/pg-core").PgColumn<{
1402
1504
  name: "created_at";
1403
1505
  tableName: "users";
@@ -1473,6 +1575,23 @@ export declare const refreshTokens: import("drizzle-orm/pg-core").PgTableWithCol
1473
1575
  identity: undefined;
1474
1576
  generated: undefined;
1475
1577
  }, {}, {}>;
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
+ }, {}, {}>;
1476
1595
  tokenHash: import("drizzle-orm/pg-core").PgColumn<{
1477
1596
  name: "token_hash";
1478
1597
  tableName: "refresh_tokens";
@@ -1509,6 +1628,57 @@ export declare const refreshTokens: import("drizzle-orm/pg-core").PgTableWithCol
1509
1628
  identity: undefined;
1510
1629
  generated: undefined;
1511
1630
  }, {}, {}>;
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
+ }, {}, {}>;
1512
1682
  userAgent: import("drizzle-orm/pg-core").PgColumn<{
1513
1683
  name: "user_agent";
1514
1684
  tableName: "refresh_tokens";
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Pure helpers that classify an Atlas declarative-apply plan as destructive
3
+ * or not, and decide what `rebase db push` should do about it.
4
+ *
5
+ * `db push` runs `atlas schema apply` to make the live database match the
6
+ * generated `schema.sql`. Removing a collection field compiles to
7
+ * `DROP COLUMN`; renaming compiles to drop-then-add — either destroys data.
8
+ * We first run the apply with `--dry-run` to obtain the planned SQL, scan it
9
+ * here, and refuse to auto-approve anything destructive without an explicit
10
+ * opt-in.
11
+ *
12
+ * Everything in this file is side-effect free so it can be unit-tested
13
+ * without Atlas or a database.
14
+ */
15
+ /**
16
+ * Split a SQL script into individual statements, dropping blank lines and
17
+ * `--` comment lines. Deliberately simple: Atlas emits one plain statement
18
+ * per `;`, without string literals that contain semicolons in a schema DDL
19
+ * plan, so a naive split is safe and keeps this dependency-free.
20
+ */
21
+ export declare function splitSqlStatements(sql: string): string[];
22
+ export interface DestructiveStatement {
23
+ /** The offending statement (trimmed, without the trailing `;`). */
24
+ statement: string;
25
+ /** Which destructive operation it was flagged for, e.g. "DROP COLUMN". */
26
+ kind: string;
27
+ }
28
+ /**
29
+ * Scan an Atlas plan (the SQL printed by `schema apply --dry-run`) and return
30
+ * the statements that would destroy data. An empty array means the plan is
31
+ * safe to auto-approve.
32
+ */
33
+ export declare function detectDestructiveStatements(planSql: string): DestructiveStatement[];
34
+ export type PushDecision = "apply" | "confirm" | "refuse";
35
+ /**
36
+ * Decide how `db push` should proceed given the plan's destructiveness and
37
+ * the invocation context.
38
+ *
39
+ * - No destructive statements → `apply` (safe to auto-approve).
40
+ * - Destructive + `--allow-destructive` → `apply` (operator opted in).
41
+ * - Destructive + interactive TTY → `confirm` (prompt before applying).
42
+ * - Destructive + non-interactive → `refuse` (never silently drop data in
43
+ * CI / scripts / agents).
44
+ */
45
+ export declare function decidePushSafety(opts: {
46
+ destructiveCount: number;
47
+ allowDestructive: boolean;
48
+ interactive: boolean;
49
+ }): PushDecision;
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.6f89f77",
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.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.6f89f77",
51
+ "@rebasepro/server": "0.10.1-canary.6f89f77",
52
+ "@rebasepro/common": "0.10.1-canary.6f89f77",
53
+ "@rebasepro/utils": "0.10.1-canary.6f89f77",
54
+ "@rebasepro/types": "0.10.1-canary.6f89f77"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@hono/node-server": "^2.0.9",
@@ -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 ──