@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.
- package/dist/PostgresBootstrapper.d.ts +7 -3
- package/dist/auth/services.d.ts +43 -4
- package/dist/backup/backup-logic.d.ts +23 -0
- package/dist/backup/backup-service.d.ts +44 -2
- package/dist/backup/pg-tools.d.ts +41 -1
- package/dist/cli-helpers.d.ts +33 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +1314 -159
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-schema.d.ts +170 -0
- package/dist/schema/destructive-sql.d.ts +49 -0
- package/dist/services/cdc/CdcListener.d.ts +7 -14
- package/dist/services/channel-bus/ChannelBus.d.ts +29 -0
- package/dist/services/channel-bus/PostgresChannelBus.d.ts +111 -0
- package/dist/services/channel-bus/index.d.ts +55 -0
- package/dist/services/channel-history.d.ts +11 -0
- package/dist/services/channel-presence.d.ts +66 -0
- package/dist/services/pg-notify-listener.d.ts +47 -0
- package/dist/services/realtimeService.d.ts +114 -6
- package/package.json +6 -6
- package/src/PostgresBootstrapper.ts +32 -3
- package/src/auth/ensure-tables.ts +70 -56
- package/src/auth/services.ts +186 -48
- package/src/backup/backup-cli.ts +60 -1
- package/src/backup/backup-cron.ts +24 -1
- package/src/backup/backup-logic.ts +62 -0
- package/src/backup/backup-service.ts +132 -13
- package/src/backup/pg-tools.ts +70 -2
- package/src/cli-helpers.ts +82 -27
- package/src/cli.ts +152 -6
- package/src/index.ts +4 -0
- package/src/schema/auth-schema.ts +41 -3
- package/src/schema/destructive-sql.ts +94 -0
- package/src/services/cdc/CdcListener.ts +27 -91
- package/src/services/channel-bus/ChannelBus.ts +44 -0
- package/src/services/channel-bus/PostgresChannelBus.ts +299 -0
- package/src/services/channel-bus/index.ts +123 -0
- package/src/services/channel-history.ts +35 -0
- package/src/services/channel-presence.ts +148 -0
- package/src/services/pg-notify-listener.ts +137 -0
- 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
|
-
/**
|
|
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
|
-
/**
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
/**
|
|
276
|
-
private
|
|
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.
|
|
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.
|
|
51
|
-
"@rebasepro/common": "0.10.1-canary.
|
|
52
|
-
"@rebasepro/
|
|
53
|
-
"@rebasepro/
|
|
54
|
-
"@rebasepro/utils": "0.10.1-canary.
|
|
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
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
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:
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
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
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
await db.execute(sql`
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
WHERE
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
|
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 ──
|