@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
package/dist/index.es.js
CHANGED
|
@@ -5,7 +5,7 @@ import { Client, Pool } from "pg";
|
|
|
5
5
|
import { drizzle } from "drizzle-orm/node-postgres";
|
|
6
6
|
import { ApiError, createEmailService, extractUserFromToken, loadCollectionsFromDirectory, logger, safeCompare } from "@rebasepro/server";
|
|
7
7
|
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isTable, lt, or, relations, sql } from "drizzle-orm";
|
|
8
|
-
import { PgArray, PgChar, PgTable, PgText, PgVarchar, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
|
|
8
|
+
import { PgArray, PgChar, PgTable, PgText, PgVarchar, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, index, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
|
|
9
9
|
import fs, { promises } from "fs";
|
|
10
10
|
import path from "path";
|
|
11
11
|
import chokidar from "chokidar";
|
|
@@ -439,6 +439,19 @@ function isSQLAdmin(admin) {
|
|
|
439
439
|
function isSchemaAdmin(admin) {
|
|
440
440
|
return !!admin && (typeof admin.fetchUnmappedTables === "function" || typeof admin.fetchTableMetadata === "function");
|
|
441
441
|
}
|
|
442
|
+
//#endregion
|
|
443
|
+
//#region ../types/src/types/channel_bus.ts
|
|
444
|
+
/**
|
|
445
|
+
* Whether `setting` is an already-constructed transport rather than a request
|
|
446
|
+
* for a built-in one.
|
|
447
|
+
*
|
|
448
|
+
* Structural rather than nominal so that an instance from a *different copy* of
|
|
449
|
+
* `@rebasepro/types` — an entirely normal outcome of a separately versioned
|
|
450
|
+
* transport package — is still recognised.
|
|
451
|
+
*/
|
|
452
|
+
function isChannelBusInstance(setting) {
|
|
453
|
+
return typeof setting?.publish === "function";
|
|
454
|
+
}
|
|
442
455
|
/** @group Models */
|
|
443
456
|
var POSTGRES_CAPABILITIES = {
|
|
444
457
|
key: "postgres",
|
|
@@ -518,6 +531,25 @@ function getDataSourceCapabilities(engine) {
|
|
|
518
531
|
if (!engine) return POSTGRES_CAPABILITIES;
|
|
519
532
|
return CAPABILITIES_REGISTRY[engine] ?? DEFAULT_CAPABILITIES;
|
|
520
533
|
}
|
|
534
|
+
/**
|
|
535
|
+
* Resolve a client-supplied list `limit` into a safe, always-defined value.
|
|
536
|
+
*
|
|
537
|
+
* - A provided limit is coerced to an integer and clamped to `[1, maxLimit]`,
|
|
538
|
+
* so `0`, negatives, and absurd values can never bypass the cap.
|
|
539
|
+
* - An absent / blank / non-numeric limit falls back to the mode default:
|
|
540
|
+
* `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
|
|
541
|
+
*
|
|
542
|
+
* The return is never `undefined` — no ingress that routes its client limit
|
|
543
|
+
* through this can produce an unbounded read.
|
|
544
|
+
*/
|
|
545
|
+
function resolveClientListLimit(rawLimit, opts = {}) {
|
|
546
|
+
const maxLimit = opts.maxLimit ?? 1e3;
|
|
547
|
+
if (rawLimit != null && String(rawLimit).trim() !== "") {
|
|
548
|
+
const parsed = typeof rawLimit === "number" ? rawLimit : parseInt(String(rawLimit), 10);
|
|
549
|
+
if (Number.isFinite(parsed)) return Math.min(Math.max(1, Math.floor(parsed)), maxLimit);
|
|
550
|
+
}
|
|
551
|
+
return opts.vectorSearch ? opts.vectorDefaultLimit ?? 10 : opts.defaultLimit ?? 50;
|
|
552
|
+
}
|
|
521
553
|
var snakeCaseRegex = /[A-Z]{2,}(?=[A-Z][a-z]|\b)|[A-Z]?[a-z]+|[0-9]+(?:[a-z](?![a-z]))?|[A-Z]/g;
|
|
522
554
|
var toSnakeCase = (str) => {
|
|
523
555
|
if (!str || typeof str !== "string") return "";
|
|
@@ -9349,21 +9381,59 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
9349
9381
|
isAnonymous: boolean("is_anonymous").default(false).notNull(),
|
|
9350
9382
|
roles: text("roles").array().default([]).notNull(),
|
|
9351
9383
|
metadata: jsonb("metadata").$type().default({}).notNull(),
|
|
9384
|
+
/**
|
|
9385
|
+
* Sessions that began before this instant are dead, whatever tokens
|
|
9386
|
+
* they still hold. Password resets and admin revocations stamp it.
|
|
9387
|
+
*
|
|
9388
|
+
* Deleting the user's refresh-token rows (which we also do) is not
|
|
9389
|
+
* sufficient on its own: a request already in flight can insert a
|
|
9390
|
+
* freshly rotated row microseconds after the delete and survive it.
|
|
9391
|
+
* This timestamp cannot be outrun that way — it is checked against
|
|
9392
|
+
* `refresh_tokens.session_started_at`, which rotation carries forward.
|
|
9393
|
+
*/
|
|
9394
|
+
tokensValidAfter: timestamp("tokens_valid_after"),
|
|
9352
9395
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
9353
9396
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
9354
9397
|
});
|
|
9355
9398
|
/**
|
|
9356
|
-
* Refresh tokens for long-lived sessions
|
|
9399
|
+
* Refresh tokens for long-lived sessions.
|
|
9400
|
+
*
|
|
9401
|
+
* A row is one token, not one device. Every token minted from the same
|
|
9402
|
+
* sign-in shares a `sessionId`, and rotation ADDS a row rather than
|
|
9403
|
+
* replacing one: the superseded token stays on file, flagged `revoked`
|
|
9404
|
+
* with a `rotatedAt` stamp. That record is what lets the refresh endpoint
|
|
9405
|
+
* tell a client replaying a token it never got an answer for (a response
|
|
9406
|
+
* lost to a redeploy, a second tab racing on boot) apart from a stranger
|
|
9407
|
+
* presenting a token that was never issued. Deleting the old row on sight
|
|
9408
|
+
* — the previous behaviour — made those two cases indistinguishable, and
|
|
9409
|
+
* the legitimate one is overwhelmingly the common one.
|
|
9410
|
+
*
|
|
9411
|
+
* There is deliberately NO unique constraint on (uid, user_agent,
|
|
9412
|
+
* ip_address). Keying a session on the IP meant one row per "device",
|
|
9413
|
+
* so a second browser profile behind the same NAT silently evicted the
|
|
9414
|
+
* first, and a phone changing networks orphaned a row on every hop.
|
|
9415
|
+
* User agent and IP are descriptive metadata for the sessions list;
|
|
9416
|
+
* `sessionId` is the identity.
|
|
9357
9417
|
*/
|
|
9358
9418
|
const refreshTokens = tableCreator("refresh_tokens", {
|
|
9359
9419
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
9360
9420
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
9421
|
+
sessionId: uuid("session_id").defaultRandom().notNull(),
|
|
9361
9422
|
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
|
|
9362
9423
|
expiresAt: timestamp("expires_at").notNull(),
|
|
9424
|
+
revoked: boolean("revoked").default(false).notNull(),
|
|
9425
|
+
rotatedAt: timestamp("rotated_at"),
|
|
9426
|
+
/**
|
|
9427
|
+
* When the sign-in this token descends from happened — carried across
|
|
9428
|
+
* every rotation, unlike `createdAt`. `users.tokensValidAfter` is
|
|
9429
|
+
* compared against this, so a revocation cannot be outrun by a token
|
|
9430
|
+
* that rotates immediately after it.
|
|
9431
|
+
*/
|
|
9432
|
+
sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
|
|
9363
9433
|
userAgent: varchar("user_agent", { length: 500 }),
|
|
9364
9434
|
ipAddress: varchar("ip_address", { length: 45 }),
|
|
9365
9435
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
9366
|
-
}, (table) => ({
|
|
9436
|
+
}, (table) => ({ sessionIdx: index("idx_refresh_tokens_session").on(table.sessionId) }));
|
|
9367
9437
|
/**
|
|
9368
9438
|
* Password reset tokens for forgot password flow
|
|
9369
9439
|
*/
|
|
@@ -10190,67 +10260,46 @@ async function provisionTriggerCdc(run, tables) {
|
|
|
10190
10260
|
};
|
|
10191
10261
|
}
|
|
10192
10262
|
//#endregion
|
|
10193
|
-
//#region src/services/
|
|
10194
|
-
/**
|
|
10195
|
-
* Parse a `rebase_cdc` NOTIFY payload. Returns `null` for anything malformed so
|
|
10196
|
-
* a single bad message can never crash the listener.
|
|
10197
|
-
*/
|
|
10198
|
-
function parseCdcPayload(payload) {
|
|
10199
|
-
let parsed;
|
|
10200
|
-
try {
|
|
10201
|
-
parsed = JSON.parse(payload);
|
|
10202
|
-
} catch {
|
|
10203
|
-
return null;
|
|
10204
|
-
}
|
|
10205
|
-
if (!parsed || typeof parsed !== "object") return null;
|
|
10206
|
-
const obj = parsed;
|
|
10207
|
-
const schema = typeof obj.schema === "string" ? obj.schema : void 0;
|
|
10208
|
-
const table = typeof obj.table === "string" ? obj.table : void 0;
|
|
10209
|
-
const op = obj.op;
|
|
10210
|
-
if (!schema || !table) return null;
|
|
10211
|
-
if (op !== "INSERT" && op !== "UPDATE" && op !== "DELETE") return null;
|
|
10212
|
-
return {
|
|
10213
|
-
schema,
|
|
10214
|
-
table,
|
|
10215
|
-
op,
|
|
10216
|
-
row: obj.row && typeof obj.row === "object" ? obj.row : {},
|
|
10217
|
-
truncated: obj.truncated === true
|
|
10218
|
-
};
|
|
10219
|
-
}
|
|
10263
|
+
//#region src/services/pg-notify-listener.ts
|
|
10220
10264
|
/**
|
|
10221
|
-
*
|
|
10265
|
+
* A dedicated, self-healing Postgres `LISTEN` connection.
|
|
10266
|
+
*
|
|
10267
|
+
* Every cross-instance feature in the backend needs the same thing: one
|
|
10268
|
+
* connection *outside* the Drizzle pool that stays open, holds a `LISTEN`, and
|
|
10269
|
+
* comes back on its own after the database or the network drops it. CDC needed
|
|
10270
|
+
* it first; the channel bus needs it too. This is that connection, with the one
|
|
10271
|
+
* behaviour that matters to callers preserved: the **first** connect is
|
|
10272
|
+
* validated and rethrown, so a caller can fall back to a different strategy,
|
|
10273
|
+
* while every later drop is repaired quietly in the background.
|
|
10222
10274
|
*
|
|
10223
|
-
*
|
|
10224
|
-
*
|
|
10225
|
-
* transparently reconnects on error/disconnect. Each backend instance runs one,
|
|
10226
|
-
* so every instance observes every committed change regardless of which
|
|
10227
|
-
* instance (or external process) made the write.
|
|
10275
|
+
* `LISTEN` is session state, so this connection must not go through a
|
|
10276
|
+
* transaction-mode pooler (PgBouncer): give it the direct database URL.
|
|
10228
10277
|
*/
|
|
10229
|
-
var
|
|
10230
|
-
|
|
10231
|
-
|
|
10278
|
+
var DEFAULT_RECONNECT_DELAY_MS = 3e3;
|
|
10279
|
+
/** Guards the identifier interpolated into `LISTEN`. */
|
|
10280
|
+
var SAFE_CHANNEL = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
10281
|
+
var PgNotifyListener = class {
|
|
10282
|
+
options;
|
|
10232
10283
|
client;
|
|
10233
10284
|
running = false;
|
|
10234
10285
|
reconnectTimer;
|
|
10235
|
-
|
|
10236
|
-
|
|
10237
|
-
|
|
10238
|
-
|
|
10286
|
+
constructor(options) {
|
|
10287
|
+
this.options = options;
|
|
10288
|
+
if (!SAFE_CHANNEL.test(options.channel)) throw new Error(`Unsafe NOTIFY channel name "${options.channel}" — expected a plain SQL identifier.`);
|
|
10289
|
+
}
|
|
10290
|
+
/** Whether the listener is meant to be connected right now. */
|
|
10291
|
+
get active() {
|
|
10292
|
+
return this.running;
|
|
10239
10293
|
}
|
|
10240
10294
|
/**
|
|
10241
10295
|
* Connect and begin listening. Idempotent.
|
|
10242
10296
|
*
|
|
10243
|
-
*
|
|
10244
|
-
*
|
|
10245
|
-
*
|
|
10246
|
-
* app-level realtime. Once the initial connection succeeds, later drops
|
|
10247
|
-
* self-heal via {@link scheduleReconnect}.
|
|
10297
|
+
* Rejects if the *initial* connection or `LISTEN` fails, leaving the
|
|
10298
|
+
* listener stopped — callers use that to degrade deliberately instead of
|
|
10299
|
+
* running blind against a channel nothing is delivering.
|
|
10248
10300
|
*/
|
|
10249
10301
|
async start() {
|
|
10250
|
-
if (this.running)
|
|
10251
|
-
logger.warn("⚠️ [CDC] CdcListener.start() called but already running. Ignoring.");
|
|
10252
|
-
return;
|
|
10253
|
-
}
|
|
10302
|
+
if (this.running) return;
|
|
10254
10303
|
this.running = true;
|
|
10255
10304
|
try {
|
|
10256
10305
|
await this.connect({ initial: true });
|
|
@@ -10259,7 +10308,7 @@ var CdcListener = class CdcListener {
|
|
|
10259
10308
|
throw err;
|
|
10260
10309
|
}
|
|
10261
10310
|
}
|
|
10262
|
-
/** Stop listening and release the connection. */
|
|
10311
|
+
/** Stop listening and release the connection. Idempotent. */
|
|
10263
10312
|
async stop() {
|
|
10264
10313
|
this.running = false;
|
|
10265
10314
|
if (this.reconnectTimer) {
|
|
@@ -10274,34 +10323,30 @@ var CdcListener = class CdcListener {
|
|
|
10274
10323
|
}
|
|
10275
10324
|
}
|
|
10276
10325
|
async connect({ initial = false } = {}) {
|
|
10326
|
+
const { connectionString, channel, onPayload, logLabel } = this.options;
|
|
10277
10327
|
try {
|
|
10278
|
-
const client = new Client({ connectionString
|
|
10328
|
+
const client = new Client({ connectionString });
|
|
10279
10329
|
client.on("error", (err) => {
|
|
10280
|
-
logger.error(
|
|
10330
|
+
logger.error(`❌ ${logLabel} LISTEN client error`, { detail: err.message });
|
|
10281
10331
|
this.scheduleReconnect();
|
|
10282
10332
|
});
|
|
10283
10333
|
client.on("end", () => {
|
|
10284
10334
|
if (this.running) {
|
|
10285
|
-
logger.warn(
|
|
10335
|
+
logger.warn(`⚠️ ${logLabel} LISTEN client disconnected unexpectedly.`);
|
|
10286
10336
|
this.scheduleReconnect();
|
|
10287
10337
|
}
|
|
10288
10338
|
});
|
|
10289
10339
|
client.on("notification", (msg) => {
|
|
10290
10340
|
if (!msg.payload) return;
|
|
10291
|
-
|
|
10292
|
-
if (!event) {
|
|
10293
|
-
logger.warn("⚠️ [CDC] Dropping unparseable change notification.");
|
|
10294
|
-
return;
|
|
10295
|
-
}
|
|
10296
|
-
Promise.resolve(this.onEvent(event)).catch((err) => logger.error("❌ [CDC] Error handling change event", { error: err }));
|
|
10341
|
+
Promise.resolve(onPayload(msg.payload)).catch((err) => logger.error(`❌ ${logLabel} Error handling notification`, { error: err }));
|
|
10297
10342
|
});
|
|
10298
10343
|
await client.connect();
|
|
10299
|
-
await client.query(`LISTEN ${
|
|
10344
|
+
await client.query(`LISTEN ${channel}`);
|
|
10300
10345
|
this.client = client;
|
|
10301
|
-
logger.info(`📡
|
|
10346
|
+
logger.info(`📡 ${logLabel} Listening on channel "${channel}".`);
|
|
10302
10347
|
} catch (err) {
|
|
10303
10348
|
if (initial) throw err;
|
|
10304
|
-
logger.error(
|
|
10349
|
+
logger.error(`❌ ${logLabel} Failed to connect LISTEN client`, { error: err });
|
|
10305
10350
|
this.scheduleReconnect();
|
|
10306
10351
|
}
|
|
10307
10352
|
}
|
|
@@ -10317,7 +10362,82 @@ var CdcListener = class CdcListener {
|
|
|
10317
10362
|
this.client = void 0;
|
|
10318
10363
|
}
|
|
10319
10364
|
await this.connect();
|
|
10320
|
-
},
|
|
10365
|
+
}, this.options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS);
|
|
10366
|
+
}
|
|
10367
|
+
};
|
|
10368
|
+
//#endregion
|
|
10369
|
+
//#region src/services/cdc/CdcListener.ts
|
|
10370
|
+
/**
|
|
10371
|
+
* Parse a `rebase_cdc` NOTIFY payload. Returns `null` for anything malformed so
|
|
10372
|
+
* a single bad message can never crash the listener.
|
|
10373
|
+
*/
|
|
10374
|
+
function parseCdcPayload(payload) {
|
|
10375
|
+
let parsed;
|
|
10376
|
+
try {
|
|
10377
|
+
parsed = JSON.parse(payload);
|
|
10378
|
+
} catch {
|
|
10379
|
+
return null;
|
|
10380
|
+
}
|
|
10381
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
10382
|
+
const obj = parsed;
|
|
10383
|
+
const schema = typeof obj.schema === "string" ? obj.schema : void 0;
|
|
10384
|
+
const table = typeof obj.table === "string" ? obj.table : void 0;
|
|
10385
|
+
const op = obj.op;
|
|
10386
|
+
if (!schema || !table) return null;
|
|
10387
|
+
if (op !== "INSERT" && op !== "UPDATE" && op !== "DELETE") return null;
|
|
10388
|
+
return {
|
|
10389
|
+
schema,
|
|
10390
|
+
table,
|
|
10391
|
+
op,
|
|
10392
|
+
row: obj.row && typeof obj.row === "object" ? obj.row : {},
|
|
10393
|
+
truncated: obj.truncated === true
|
|
10394
|
+
};
|
|
10395
|
+
}
|
|
10396
|
+
/**
|
|
10397
|
+
* Dedicated Postgres LISTEN client for database-level CDC.
|
|
10398
|
+
*
|
|
10399
|
+
* A {@link PgNotifyListener} — a connection outside the Drizzle pool that stays
|
|
10400
|
+
* open and repairs itself — plus the parsing that turns a `rebase_cdc` payload
|
|
10401
|
+
* into a change event. Each backend instance runs one, so every instance
|
|
10402
|
+
* observes every committed change regardless of which instance (or external
|
|
10403
|
+
* process) made the write.
|
|
10404
|
+
*/
|
|
10405
|
+
var CdcListener = class {
|
|
10406
|
+
listener;
|
|
10407
|
+
constructor(connectionString, onEvent) {
|
|
10408
|
+
this.listener = new PgNotifyListener({
|
|
10409
|
+
connectionString,
|
|
10410
|
+
channel: CDC_CHANNEL,
|
|
10411
|
+
logLabel: "[CDC]",
|
|
10412
|
+
onPayload: (payload) => {
|
|
10413
|
+
const event = parseCdcPayload(payload);
|
|
10414
|
+
if (!event) {
|
|
10415
|
+
logger.warn("⚠️ [CDC] Dropping unparseable change notification.");
|
|
10416
|
+
return;
|
|
10417
|
+
}
|
|
10418
|
+
return onEvent(event);
|
|
10419
|
+
}
|
|
10420
|
+
});
|
|
10421
|
+
}
|
|
10422
|
+
/**
|
|
10423
|
+
* Connect and begin listening. Idempotent.
|
|
10424
|
+
*
|
|
10425
|
+
* The **initial** connection is validated synchronously: if it cannot be
|
|
10426
|
+
* established (or `LISTEN` is refused), this rejects so callers — notably
|
|
10427
|
+
* `REALTIME_CDC=auto` — can detect an unusable connection and fall back to
|
|
10428
|
+
* app-level realtime. Once the initial connection succeeds, later drops
|
|
10429
|
+
* self-heal in the background.
|
|
10430
|
+
*/
|
|
10431
|
+
async start() {
|
|
10432
|
+
if (this.listener.active) {
|
|
10433
|
+
logger.warn("⚠️ [CDC] CdcListener.start() called but already running. Ignoring.");
|
|
10434
|
+
return;
|
|
10435
|
+
}
|
|
10436
|
+
await this.listener.start();
|
|
10437
|
+
}
|
|
10438
|
+
/** Stop listening and release the connection. */
|
|
10439
|
+
async stop() {
|
|
10440
|
+
await this.listener.stop();
|
|
10321
10441
|
}
|
|
10322
10442
|
};
|
|
10323
10443
|
//#endregion
|
|
@@ -10541,6 +10661,31 @@ var ChannelHistoryStore = class {
|
|
|
10541
10661
|
};
|
|
10542
10662
|
}
|
|
10543
10663
|
/**
|
|
10664
|
+
* One retained message by its address.
|
|
10665
|
+
*
|
|
10666
|
+
* This is what makes the cross-instance pointer path work: a broadcast too
|
|
10667
|
+
* large to travel inside a `pg_notify` payload is already stored here, so
|
|
10668
|
+
* the notification carries `(channel, seq)` and each receiving instance
|
|
10669
|
+
* reads the body back. Returns null when the message has since been pruned
|
|
10670
|
+
* — a receiver that is that far behind has nothing useful to deliver, and
|
|
10671
|
+
* the client's own `channel_history` replay is the repair path.
|
|
10672
|
+
*/
|
|
10673
|
+
async getBySeq(channel, seq) {
|
|
10674
|
+
const row = (await this.db.execute(sql`
|
|
10675
|
+
SELECT seq, event, payload, sender_id, created_at
|
|
10676
|
+
FROM rebase.channel_messages
|
|
10677
|
+
WHERE channel = ${channel} AND seq = ${seq}
|
|
10678
|
+
`)).rows[0];
|
|
10679
|
+
if (!row) return null;
|
|
10680
|
+
return {
|
|
10681
|
+
seq: Number(row.seq),
|
|
10682
|
+
event: row.event,
|
|
10683
|
+
payload: row.payload,
|
|
10684
|
+
senderId: row.sender_id ?? void 0,
|
|
10685
|
+
at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)
|
|
10686
|
+
};
|
|
10687
|
+
}
|
|
10688
|
+
/**
|
|
10544
10689
|
* Enforce a channel's retention bounds.
|
|
10545
10690
|
*
|
|
10546
10691
|
* Throttled per channel, so a burst of operations prunes once rather than
|
|
@@ -10582,6 +10727,447 @@ var ChannelHistoryStore = class {
|
|
|
10582
10727
|
}
|
|
10583
10728
|
};
|
|
10584
10729
|
//#endregion
|
|
10730
|
+
//#region src/services/channel-presence.ts
|
|
10731
|
+
/**
|
|
10732
|
+
* The shared presence roster.
|
|
10733
|
+
*
|
|
10734
|
+
* Broadcast only ever needed *fan-out* to work across instances — a frame goes
|
|
10735
|
+
* out, whoever is connected receives it. Presence needs more than that, because
|
|
10736
|
+
* `presence_state` is a question ("who is in this document?") and a per-process
|
|
10737
|
+
* `Map` can only answer for the clients that happen to share a replica with the
|
|
10738
|
+
* asker. Two people editing the same scene through different pods would each
|
|
10739
|
+
* see an empty room while broadcasting cursors at each other perfectly.
|
|
10740
|
+
*
|
|
10741
|
+
* So presence gets one row per tracked client, in Postgres, readable by every
|
|
10742
|
+
* instance. Three consequences worth stating:
|
|
10743
|
+
*
|
|
10744
|
+
* - **The table is the roster; the in-process map is a cache of our own
|
|
10745
|
+
* clients.** Reads answer from the table when this store is active, so the
|
|
10746
|
+
* answer is the same whichever instance is asked.
|
|
10747
|
+
*
|
|
10748
|
+
* - **`last_seen` is the liveness signal, and it is already there.** The client
|
|
10749
|
+
* heartbeats presence every ~20 s against a 30 s window; the sweep that has
|
|
10750
|
+
* always reaped local stale entries now also reaps rows belonging to
|
|
10751
|
+
* instances that stopped writing — which is exactly what a crashed pod looks
|
|
10752
|
+
* like. Crash recovery is a property of the TTL, not a separate mechanism.
|
|
10753
|
+
*
|
|
10754
|
+
* - **The sweep deletes with `RETURNING`.** Whichever instance wins the delete
|
|
10755
|
+
* is the one that announces the departures, so a stale client produces one
|
|
10756
|
+
* `presence_diff` for the cluster rather than one per replica.
|
|
10757
|
+
*/
|
|
10758
|
+
var ChannelPresenceStore = class {
|
|
10759
|
+
db;
|
|
10760
|
+
instanceId;
|
|
10761
|
+
tablesReady = false;
|
|
10762
|
+
constructor(db, instanceId) {
|
|
10763
|
+
this.db = db;
|
|
10764
|
+
this.instanceId = instanceId;
|
|
10765
|
+
}
|
|
10766
|
+
/** Create the roster table. Idempotent. */
|
|
10767
|
+
async ensureTables() {
|
|
10768
|
+
if (this.tablesReady) return;
|
|
10769
|
+
await this.db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
|
|
10770
|
+
await this.db.execute(sql`
|
|
10771
|
+
CREATE TABLE IF NOT EXISTS rebase.channel_presence (
|
|
10772
|
+
channel TEXT NOT NULL,
|
|
10773
|
+
client_id TEXT NOT NULL,
|
|
10774
|
+
instance_id TEXT NOT NULL,
|
|
10775
|
+
state JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
10776
|
+
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
10777
|
+
PRIMARY KEY (channel, client_id)
|
|
10778
|
+
)
|
|
10779
|
+
`);
|
|
10780
|
+
await this.db.execute(sql`
|
|
10781
|
+
CREATE INDEX IF NOT EXISTS idx_channel_presence_last_seen
|
|
10782
|
+
ON rebase.channel_presence (last_seen)
|
|
10783
|
+
`);
|
|
10784
|
+
this.tablesReady = true;
|
|
10785
|
+
}
|
|
10786
|
+
/** Record (or refresh) a client's presence. */
|
|
10787
|
+
async track(channel, clientId, state) {
|
|
10788
|
+
await this.db.execute(sql`
|
|
10789
|
+
INSERT INTO rebase.channel_presence (channel, client_id, instance_id, state, last_seen)
|
|
10790
|
+
VALUES (${channel}, ${clientId}, ${this.instanceId}, ${JSON.stringify(state ?? {})}::jsonb, NOW())
|
|
10791
|
+
ON CONFLICT (channel, client_id) DO UPDATE
|
|
10792
|
+
SET state = EXCLUDED.state,
|
|
10793
|
+
instance_id = EXCLUDED.instance_id,
|
|
10794
|
+
last_seen = NOW()
|
|
10795
|
+
`);
|
|
10796
|
+
}
|
|
10797
|
+
/** Drop one client's presence in one channel. */
|
|
10798
|
+
async remove(channel, clientId) {
|
|
10799
|
+
await this.db.execute(sql`
|
|
10800
|
+
DELETE FROM rebase.channel_presence
|
|
10801
|
+
WHERE channel = ${channel} AND client_id = ${clientId}
|
|
10802
|
+
`);
|
|
10803
|
+
}
|
|
10804
|
+
/** Drop a client from every channel — used when its socket closes. */
|
|
10805
|
+
async removeClient(clientId) {
|
|
10806
|
+
await this.db.execute(sql`
|
|
10807
|
+
DELETE FROM rebase.channel_presence WHERE client_id = ${clientId}
|
|
10808
|
+
`);
|
|
10809
|
+
}
|
|
10810
|
+
/** The global roster for a channel. */
|
|
10811
|
+
async roster(channel) {
|
|
10812
|
+
const result = await this.db.execute(sql`
|
|
10813
|
+
SELECT client_id, state FROM rebase.channel_presence WHERE channel = ${channel}
|
|
10814
|
+
`);
|
|
10815
|
+
const presences = {};
|
|
10816
|
+
for (const row of result.rows) presences[row.client_id] = row.state ?? {};
|
|
10817
|
+
return presences;
|
|
10818
|
+
}
|
|
10819
|
+
/**
|
|
10820
|
+
* Reap rows this instance is not responsible for and that have gone quiet.
|
|
10821
|
+
*
|
|
10822
|
+
* Own rows are excluded because the in-process sweep already handles them —
|
|
10823
|
+
* and handles them better, since it can tell "the socket is gone" from "the
|
|
10824
|
+
* heartbeat is late". What is left is precisely the interesting case: rows
|
|
10825
|
+
* written by an instance that is no longer writing.
|
|
10826
|
+
*
|
|
10827
|
+
* Returns what was removed, so the caller can announce it.
|
|
10828
|
+
*/
|
|
10829
|
+
async sweepStale(ttlMs) {
|
|
10830
|
+
return (await this.db.execute(sql`
|
|
10831
|
+
DELETE FROM rebase.channel_presence
|
|
10832
|
+
WHERE instance_id <> ${this.instanceId}
|
|
10833
|
+
AND last_seen < NOW() - MAKE_INTERVAL(secs => ${ttlMs / 1e3})
|
|
10834
|
+
RETURNING channel, client_id, state
|
|
10835
|
+
`)).rows.map((row) => ({
|
|
10836
|
+
channel: row.channel,
|
|
10837
|
+
clientId: row.client_id,
|
|
10838
|
+
state: row.state ?? {}
|
|
10839
|
+
}));
|
|
10840
|
+
}
|
|
10841
|
+
/**
|
|
10842
|
+
* Remove every row this instance owns. Called on graceful shutdown so a
|
|
10843
|
+
* rolling deploy does not leave a TTL window of ghosts in every roster.
|
|
10844
|
+
*/
|
|
10845
|
+
async removeInstance() {
|
|
10846
|
+
await this.db.execute(sql`
|
|
10847
|
+
DELETE FROM rebase.channel_presence WHERE instance_id = ${this.instanceId}
|
|
10848
|
+
`);
|
|
10849
|
+
}
|
|
10850
|
+
};
|
|
10851
|
+
//#endregion
|
|
10852
|
+
//#region src/services/channel-bus/ChannelBus.ts
|
|
10853
|
+
/**
|
|
10854
|
+
* The default: no cross-instance delivery at all.
|
|
10855
|
+
*
|
|
10856
|
+
* This is what every deployment ran before the bus existed, and what a
|
|
10857
|
+
* single-instance deployment should keep running — `publish` resolves without
|
|
10858
|
+
* touching the network, so the broadcast path is the same handful of `ws.send`
|
|
10859
|
+
* calls it always was.
|
|
10860
|
+
*/
|
|
10861
|
+
var MemoryChannelBus = class {
|
|
10862
|
+
kind = "memory";
|
|
10863
|
+
maxFrameBytes = Infinity;
|
|
10864
|
+
async start() {}
|
|
10865
|
+
async publish() {}
|
|
10866
|
+
async stop() {}
|
|
10867
|
+
};
|
|
10868
|
+
/** Encoded size of a frame, for a transport's size check. */
|
|
10869
|
+
function frameByteLength(frame) {
|
|
10870
|
+
return Buffer.byteLength(JSON.stringify(frame), "utf8");
|
|
10871
|
+
}
|
|
10872
|
+
//#endregion
|
|
10873
|
+
//#region src/services/channel-bus/PostgresChannelBus.ts
|
|
10874
|
+
/**
|
|
10875
|
+
* Channel bus over Postgres LISTEN/NOTIFY.
|
|
10876
|
+
*
|
|
10877
|
+
* Chosen because it needs nothing that a Rebase deployment does not already
|
|
10878
|
+
* have — the same database, the same direct URL the CDC listener uses. Three
|
|
10879
|
+
* properties of `NOTIFY` shape everything below:
|
|
10880
|
+
*
|
|
10881
|
+
* - **8000 bytes per payload.** Presence and cursors fit with room to spare; a
|
|
10882
|
+
* scene snapshot does not. Rather than truncate or drop, an oversized frame
|
|
10883
|
+
* on a *retained* channel is published as a pointer — the body is already in
|
|
10884
|
+
* `rebase.channel_messages` with a sequence number, so the receiver reads it
|
|
10885
|
+
* back. That is the same trick the entity path uses (notify an address,
|
|
10886
|
+
* refetch the row), applied to a different table. On an ephemeral channel
|
|
10887
|
+
* there is nothing to point at, so the publish is refused loudly instead of
|
|
10888
|
+
* reaching some instances and not others.
|
|
10889
|
+
*
|
|
10890
|
+
* - **A notify is a query on the primary database.** Not a slow one, but it
|
|
10891
|
+
* competes with the application's real queries, and that — not throughput —
|
|
10892
|
+
* is what actually limits this transport. Measured, it carried ~10k
|
|
10893
|
+
* cross-instance messages/second and stayed flat out to eight instances; what
|
|
10894
|
+
* it should not do is spend 10k queries/second of the database's budget on
|
|
10895
|
+
* cursor movement. Hence the batching below.
|
|
10896
|
+
*
|
|
10897
|
+
* - **Delivery is best-effort.** Retained channels repair themselves through
|
|
10898
|
+
* the client's history replay, so a lost frame costs a live update rather
|
|
10899
|
+
* than correctness. That is what makes coalescing safe.
|
|
10900
|
+
*/
|
|
10901
|
+
/** NOTIFY channel carrying channel-bus frames. */
|
|
10902
|
+
var CHANNEL_BUS_NOTIFY_CHANNEL = "rebase_channel_bus";
|
|
10903
|
+
/**
|
|
10904
|
+
* Postgres refuses a NOTIFY payload of 8000 bytes or more. The margin below it
|
|
10905
|
+
* is for nothing in particular — it is there so that a payload which passes this
|
|
10906
|
+
* check cannot fail at the server for being a few bytes over.
|
|
10907
|
+
*/
|
|
10908
|
+
var PG_NOTIFY_MAX_PAYLOAD_BYTES = 7500;
|
|
10909
|
+
/**
|
|
10910
|
+
* How long a batching window stays open.
|
|
10911
|
+
*
|
|
10912
|
+
* Ten milliseconds is below the threshold where a human notices a cursor lag,
|
|
10913
|
+
* and it is the difference between one query per message and one query per
|
|
10914
|
+
* window under load. Set to 0 to disable coalescing entirely.
|
|
10915
|
+
*/
|
|
10916
|
+
var DEFAULT_BATCH_WINDOW_MS = 10;
|
|
10917
|
+
/** JSON overhead per frame inside a batch: the wrapping array's comma. */
|
|
10918
|
+
var BATCH_SEPARATOR_BYTES = 1;
|
|
10919
|
+
/** JSON overhead of the batch envelope itself: `{"batch":[]}`. */
|
|
10920
|
+
var BATCH_ENVELOPE_BYTES = 12;
|
|
10921
|
+
var PostgresChannelBus = class {
|
|
10922
|
+
db;
|
|
10923
|
+
connectionString;
|
|
10924
|
+
kind = "postgres";
|
|
10925
|
+
maxFrameBytes = PG_NOTIFY_MAX_PAYLOAD_BYTES;
|
|
10926
|
+
listener;
|
|
10927
|
+
batchWindowMs;
|
|
10928
|
+
/**
|
|
10929
|
+
* Frames waiting for the current window to close.
|
|
10930
|
+
*
|
|
10931
|
+
* The window is opened by a publish that found none open, and that publish
|
|
10932
|
+
* is sent *immediately* rather than joining a batch — see {@link publish}.
|
|
10933
|
+
*/
|
|
10934
|
+
pending = [];
|
|
10935
|
+
pendingBytes = BATCH_ENVELOPE_BYTES;
|
|
10936
|
+
windowTimer;
|
|
10937
|
+
stopped = false;
|
|
10938
|
+
constructor(db, connectionString, options = {}) {
|
|
10939
|
+
this.db = db;
|
|
10940
|
+
this.connectionString = connectionString;
|
|
10941
|
+
const configured = options.batchWindowMs;
|
|
10942
|
+
this.batchWindowMs = typeof configured === "number" && configured >= 0 ? configured : 10;
|
|
10943
|
+
}
|
|
10944
|
+
async start(handler) {
|
|
10945
|
+
this.stopped = false;
|
|
10946
|
+
this.listener = new PgNotifyListener({
|
|
10947
|
+
connectionString: this.connectionString,
|
|
10948
|
+
channel: CHANNEL_BUS_NOTIFY_CHANNEL,
|
|
10949
|
+
logLabel: "[ChannelBus]",
|
|
10950
|
+
onPayload: async (payload) => {
|
|
10951
|
+
const frames = parseChannelBusPayload(payload);
|
|
10952
|
+
if (!frames.length) {
|
|
10953
|
+
logger.warn("⚠️ [ChannelBus] Dropping unparseable payload.");
|
|
10954
|
+
return;
|
|
10955
|
+
}
|
|
10956
|
+
for (const frame of frames) await handler(frame);
|
|
10957
|
+
}
|
|
10958
|
+
});
|
|
10959
|
+
await this.listener.start();
|
|
10960
|
+
}
|
|
10961
|
+
/**
|
|
10962
|
+
* Publish, coalescing under load.
|
|
10963
|
+
*
|
|
10964
|
+
* The window is *leading edge*: a publish arriving when no window is open is
|
|
10965
|
+
* sent straight away and opens one, so an idle channel pays no added latency
|
|
10966
|
+
* at all. Frames arriving while it is open are collected and leave together
|
|
10967
|
+
* when it closes. The effect is that cost tracks elapsed time rather than
|
|
10968
|
+
* message count — one query per window instead of one per message — which is
|
|
10969
|
+
* the same shape as the retention pruning throttle, for the same reason.
|
|
10970
|
+
*
|
|
10971
|
+
* The returned promise settles when the frame has actually left, not when it
|
|
10972
|
+
* was queued, so the contract ("reaches the other instances, or rejects")
|
|
10973
|
+
* still holds.
|
|
10974
|
+
*/
|
|
10975
|
+
async publish(frame) {
|
|
10976
|
+
if (this.batchWindowMs === 0 || this.stopped) {
|
|
10977
|
+
await this.send([frame]);
|
|
10978
|
+
return;
|
|
10979
|
+
}
|
|
10980
|
+
if (!this.windowTimer) {
|
|
10981
|
+
this.openWindow();
|
|
10982
|
+
await this.send([frame]);
|
|
10983
|
+
return;
|
|
10984
|
+
}
|
|
10985
|
+
const bytes = frameByteLength(frame) + BATCH_SEPARATOR_BYTES;
|
|
10986
|
+
if (this.pending.length && this.pendingBytes + bytes > this.maxFrameBytes) this.flush();
|
|
10987
|
+
return new Promise((resolve, reject) => {
|
|
10988
|
+
this.pending.push({
|
|
10989
|
+
frame,
|
|
10990
|
+
bytes,
|
|
10991
|
+
resolve,
|
|
10992
|
+
reject
|
|
10993
|
+
});
|
|
10994
|
+
this.pendingBytes += bytes;
|
|
10995
|
+
});
|
|
10996
|
+
}
|
|
10997
|
+
async stop() {
|
|
10998
|
+
this.stopped = true;
|
|
10999
|
+
if (this.windowTimer) {
|
|
11000
|
+
clearTimeout(this.windowTimer);
|
|
11001
|
+
this.windowTimer = void 0;
|
|
11002
|
+
}
|
|
11003
|
+
this.flush();
|
|
11004
|
+
await this.listener?.stop();
|
|
11005
|
+
this.listener = void 0;
|
|
11006
|
+
}
|
|
11007
|
+
openWindow() {
|
|
11008
|
+
this.windowTimer = setTimeout(() => {
|
|
11009
|
+
this.windowTimer = void 0;
|
|
11010
|
+
if (this.pending.length) {
|
|
11011
|
+
this.flush();
|
|
11012
|
+
this.openWindow();
|
|
11013
|
+
}
|
|
11014
|
+
}, this.batchWindowMs);
|
|
11015
|
+
this.windowTimer.unref?.();
|
|
11016
|
+
}
|
|
11017
|
+
/** Send everything queued and settle the promises waiting on it. */
|
|
11018
|
+
flush() {
|
|
11019
|
+
if (!this.pending.length) return;
|
|
11020
|
+
const batch = this.pending;
|
|
11021
|
+
this.pending = [];
|
|
11022
|
+
this.pendingBytes = BATCH_ENVELOPE_BYTES;
|
|
11023
|
+
this.send(batch.map((p) => p.frame)).then(() => {
|
|
11024
|
+
for (const p of batch) p.resolve();
|
|
11025
|
+
}).catch((error) => {
|
|
11026
|
+
for (const p of batch) p.reject(error);
|
|
11027
|
+
});
|
|
11028
|
+
}
|
|
11029
|
+
/**
|
|
11030
|
+
* One NOTIFY.
|
|
11031
|
+
*
|
|
11032
|
+
* A single frame goes out in the plain, unwrapped shape. That is not just
|
|
11033
|
+
* economy: during a rolling deploy an instance running the previous build
|
|
11034
|
+
* understands only that shape, and low-rate traffic — presence, the tail of
|
|
11035
|
+
* a session — is exactly what is flowing while pods restart. Batching only
|
|
11036
|
+
* appears under load, which shrinks the mixed-version window to almost
|
|
11037
|
+
* nothing.
|
|
11038
|
+
*/
|
|
11039
|
+
async send(frames) {
|
|
11040
|
+
if (!frames.length) return;
|
|
11041
|
+
const payload = frames.length === 1 ? JSON.stringify(frames[0]) : JSON.stringify({ batch: frames });
|
|
11042
|
+
await this.db.execute(sql`SELECT pg_notify(${CHANNEL_BUS_NOTIFY_CHANNEL}, ${payload})`);
|
|
11043
|
+
}
|
|
11044
|
+
};
|
|
11045
|
+
/**
|
|
11046
|
+
* Parse a bus payload into the frames it carries.
|
|
11047
|
+
*
|
|
11048
|
+
* Accepts both wire shapes — a bare frame and a `{ batch: [...] }` envelope —
|
|
11049
|
+
* so an instance on the new build understands one on the old. Returns an empty
|
|
11050
|
+
* array for anything unrecognisable: a malformed or future-versioned message
|
|
11051
|
+
* must never take the listener down.
|
|
11052
|
+
*/
|
|
11053
|
+
function parseChannelBusPayload(payload) {
|
|
11054
|
+
let parsed;
|
|
11055
|
+
try {
|
|
11056
|
+
parsed = JSON.parse(payload);
|
|
11057
|
+
} catch {
|
|
11058
|
+
return [];
|
|
11059
|
+
}
|
|
11060
|
+
if (!parsed || typeof parsed !== "object") return [];
|
|
11061
|
+
const batch = parsed.batch;
|
|
11062
|
+
if (Array.isArray(batch)) return batch.map((entry) => coerceFrame(entry)).filter((frame) => frame !== null);
|
|
11063
|
+
const single = coerceFrame(parsed);
|
|
11064
|
+
return single ? [single] : [];
|
|
11065
|
+
}
|
|
11066
|
+
/**
|
|
11067
|
+
* Parse a single bus frame, returning null for anything that is not a frame we
|
|
11068
|
+
* understand.
|
|
11069
|
+
*/
|
|
11070
|
+
function parseChannelBusFrame(payload) {
|
|
11071
|
+
try {
|
|
11072
|
+
return coerceFrame(JSON.parse(payload));
|
|
11073
|
+
} catch {
|
|
11074
|
+
return null;
|
|
11075
|
+
}
|
|
11076
|
+
}
|
|
11077
|
+
function coerceFrame(value) {
|
|
11078
|
+
if (!value || typeof value !== "object") return null;
|
|
11079
|
+
const obj = value;
|
|
11080
|
+
const sid = typeof obj.sid === "string" ? obj.sid : void 0;
|
|
11081
|
+
const channel = typeof obj.channel === "string" ? obj.channel : void 0;
|
|
11082
|
+
if (!sid || !channel) return null;
|
|
11083
|
+
switch (obj.kind) {
|
|
11084
|
+
case "broadcast":
|
|
11085
|
+
if (typeof obj.event !== "string") return null;
|
|
11086
|
+
return {
|
|
11087
|
+
kind: "broadcast",
|
|
11088
|
+
sid,
|
|
11089
|
+
channel,
|
|
11090
|
+
event: obj.event,
|
|
11091
|
+
from: typeof obj.from === "string" ? obj.from : void 0,
|
|
11092
|
+
seq: typeof obj.seq === "number" ? obj.seq : void 0,
|
|
11093
|
+
payload: obj.payload
|
|
11094
|
+
};
|
|
11095
|
+
case "broadcast_ref":
|
|
11096
|
+
if (typeof obj.seq !== "number") return null;
|
|
11097
|
+
return {
|
|
11098
|
+
kind: "broadcast_ref",
|
|
11099
|
+
sid,
|
|
11100
|
+
channel,
|
|
11101
|
+
from: typeof obj.from === "string" ? obj.from : void 0,
|
|
11102
|
+
seq: obj.seq
|
|
11103
|
+
};
|
|
11104
|
+
case "presence_diff": return {
|
|
11105
|
+
kind: "presence_diff",
|
|
11106
|
+
sid,
|
|
11107
|
+
channel,
|
|
11108
|
+
joins: obj.joins ?? {},
|
|
11109
|
+
leaves: obj.leaves ?? {}
|
|
11110
|
+
};
|
|
11111
|
+
default: return null;
|
|
11112
|
+
}
|
|
11113
|
+
}
|
|
11114
|
+
//#endregion
|
|
11115
|
+
//#region src/services/channel-bus/index.ts
|
|
11116
|
+
/**
|
|
11117
|
+
* Merge `REALTIME_CHANNEL_BUS` into the configured bus.
|
|
11118
|
+
*
|
|
11119
|
+
* The environment wins over a *named* built-in, so the transport can be changed
|
|
11120
|
+
* per deployment without a rebuild — the same reason `REALTIME_CDC` is an env
|
|
11121
|
+
* var. It does **not** win over a supplied instance: the env var can only name
|
|
11122
|
+
* transports this package knows how to construct, so honouring it there would
|
|
11123
|
+
* mean silently discarding the object the application handed us.
|
|
11124
|
+
*/
|
|
11125
|
+
function resolveChannelBusSetting(configured) {
|
|
11126
|
+
const raw = (process.env.REALTIME_CHANNEL_BUS || "").trim().toLowerCase();
|
|
11127
|
+
if (isChannelBusInstance(configured)) {
|
|
11128
|
+
if (raw && raw !== configured.kind) logger.warn(`⚠️ [ChannelBus] REALTIME_CHANNEL_BUS="${raw}" is ignored because realtime.bus was given a "${configured.kind}" transport instance directly. Remove one of the two to make the intent clear.`);
|
|
11129
|
+
return configured;
|
|
11130
|
+
}
|
|
11131
|
+
if (!raw) return configured ?? { type: "memory" };
|
|
11132
|
+
if (raw !== "memory" && raw !== "postgres") {
|
|
11133
|
+
logger.warn(`⚠️ [ChannelBus] Unknown REALTIME_CHANNEL_BUS value "${raw}" — expected memory|postgres, or pass a ChannelBus instance as realtime.bus for a transport that ships separately. Falling back to the configured bus.`);
|
|
11134
|
+
return configured ?? { type: "memory" };
|
|
11135
|
+
}
|
|
11136
|
+
if (configured?.type === raw) return configured;
|
|
11137
|
+
return raw === "memory" ? { type: "memory" } : { type: "postgres" };
|
|
11138
|
+
}
|
|
11139
|
+
/**
|
|
11140
|
+
* @deprecated Use {@link resolveChannelBusSetting}, which also accepts a
|
|
11141
|
+
* supplied {@link ChannelBus} instance. Kept as a narrow alias so existing
|
|
11142
|
+
* config-only callers keep their exact types.
|
|
11143
|
+
*/
|
|
11144
|
+
function resolveChannelBusConfig(configured) {
|
|
11145
|
+
return resolveChannelBusSetting(configured);
|
|
11146
|
+
}
|
|
11147
|
+
/**
|
|
11148
|
+
* Produce the bus a setting asks for.
|
|
11149
|
+
*
|
|
11150
|
+
* An instance is handed straight back — constructing it was the application's
|
|
11151
|
+
* job, and this function has nothing to add. A named built-in that turns out to
|
|
11152
|
+
* be unusable degrades to the memory bus, with the reason logged, rather than
|
|
11153
|
+
* throwing: a misconfigured bus should cost a deployment its cross-instance
|
|
11154
|
+
* fan-out, not its ability to boot.
|
|
11155
|
+
*/
|
|
11156
|
+
function createChannelBus(setting, deps) {
|
|
11157
|
+
if (isChannelBusInstance(setting)) return setting;
|
|
11158
|
+
switch (setting.type) {
|
|
11159
|
+
case "postgres": {
|
|
11160
|
+
const connectionString = setting.connectionString || deps.directUrl;
|
|
11161
|
+
if (!connectionString) {
|
|
11162
|
+
logger.warn("⚠️ [ChannelBus] realtime.bus is \"postgres\" but no direct database URL is available (set DATABASE_DIRECT_URL or realtime.bus.connectionString) — channel broadcast and presence stay per-instance.");
|
|
11163
|
+
return new MemoryChannelBus();
|
|
11164
|
+
}
|
|
11165
|
+
return new PostgresChannelBus(deps.db, connectionString, { batchWindowMs: setting.batchWindowMs });
|
|
11166
|
+
}
|
|
11167
|
+
default: return new MemoryChannelBus();
|
|
11168
|
+
}
|
|
11169
|
+
}
|
|
11170
|
+
//#endregion
|
|
10585
11171
|
//#region src/services/realtimeService.ts
|
|
10586
11172
|
/** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
|
|
10587
11173
|
var PG_NOTIFY_CHANNEL = "rebase_entity_changes";
|
|
@@ -10617,8 +11203,33 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10617
11203
|
* wait on each other.
|
|
10618
11204
|
*/
|
|
10619
11205
|
channelSendQueues = /* @__PURE__ */ new Map();
|
|
11206
|
+
/**
|
|
11207
|
+
* Cross-instance transport for channel frames and presence.
|
|
11208
|
+
*
|
|
11209
|
+
* Defaults to the memory bus, which publishes nowhere — so a single-instance
|
|
11210
|
+
* deployment runs the same fan-out it always did, with one resolved promise
|
|
11211
|
+
* per broadcast for company. See `channel-bus/ChannelBus.ts`.
|
|
11212
|
+
*/
|
|
11213
|
+
bus = new MemoryChannelBus();
|
|
11214
|
+
/**
|
|
11215
|
+
* The shared presence roster, present only when a real bus is active.
|
|
11216
|
+
*
|
|
11217
|
+
* Fan-out alone is not enough for presence: `presence_state` has to answer
|
|
11218
|
+
* with everyone in the channel, and per-process maps can only answer for
|
|
11219
|
+
* this replica's clients. See `channel-presence.ts`.
|
|
11220
|
+
*/
|
|
11221
|
+
presenceStore;
|
|
11222
|
+
/** Sweeps roster rows left behind by instances that stopped heartbeating. */
|
|
11223
|
+
presenceSweepInterval;
|
|
11224
|
+
/**
|
|
11225
|
+
* Channels whose oversized ephemeral broadcasts have already been reported,
|
|
11226
|
+
* so a hot channel logs the problem once rather than once per message.
|
|
11227
|
+
*/
|
|
11228
|
+
oversizedBroadcastWarned = /* @__PURE__ */ new Set();
|
|
10620
11229
|
presenceInterval;
|
|
10621
11230
|
static PRESENCE_TIMEOUT_MS = 3e4;
|
|
11231
|
+
/** How often stale roster rows from other instances are reaped. */
|
|
11232
|
+
static PRESENCE_SWEEP_INTERVAL_MS = 1e4;
|
|
10622
11233
|
dataService;
|
|
10623
11234
|
_subscriptions = /* @__PURE__ */ new Map();
|
|
10624
11235
|
subscriptionCallbacks = /* @__PURE__ */ new Map();
|
|
@@ -10767,10 +11378,11 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10767
11378
|
}
|
|
10768
11379
|
for (const [channel, members] of this.channels.entries()) if (members.has(clientId)) {
|
|
10769
11380
|
members.delete(clientId);
|
|
10770
|
-
this.removePresence(clientId, channel);
|
|
11381
|
+
this.removePresence(clientId, channel, { skipStore: true });
|
|
10771
11382
|
if (members.size === 0) this.channels.delete(channel);
|
|
10772
11383
|
}
|
|
10773
|
-
for (const [channel] of this.presence) this.removePresence(clientId, channel);
|
|
11384
|
+
for (const [channel] of this.presence) this.removePresence(clientId, channel, { skipStore: true });
|
|
11385
|
+
this.presenceStoreOp(() => this.presenceStore.removeClient(clientId), "client removal");
|
|
10774
11386
|
}
|
|
10775
11387
|
async handleMessage(clientId, message, authContext) {
|
|
10776
11388
|
const payload = message.payload;
|
|
@@ -10819,6 +11431,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10819
11431
|
this.sendError(clientId, msg, subscriptionId);
|
|
10820
11432
|
return;
|
|
10821
11433
|
}
|
|
11434
|
+
const boundedLimit = resolveClientListLimit(request.limit, { vectorSearch: !!request.vectorSearch });
|
|
10822
11435
|
this._subscriptions.set(subscriptionId, {
|
|
10823
11436
|
clientId,
|
|
10824
11437
|
type: "collection",
|
|
@@ -10827,7 +11440,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10827
11440
|
filter: request.filter,
|
|
10828
11441
|
orderBy: request.orderBy,
|
|
10829
11442
|
order: request.order,
|
|
10830
|
-
limit:
|
|
11443
|
+
limit: boundedLimit,
|
|
10831
11444
|
startAfter: request.startAfter,
|
|
10832
11445
|
databaseId: request.collection?.databaseId,
|
|
10833
11446
|
searchString: request.searchString
|
|
@@ -10838,7 +11451,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10838
11451
|
filter: request.filter,
|
|
10839
11452
|
orderBy: request.orderBy,
|
|
10840
11453
|
order: request.order,
|
|
10841
|
-
limit:
|
|
11454
|
+
limit: boundedLimit,
|
|
10842
11455
|
startAfter: request.startAfter,
|
|
10843
11456
|
searchString: request.searchString
|
|
10844
11457
|
}, authContext);
|
|
@@ -11299,6 +11912,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
11299
11912
|
const retention = this.channelHistory?.retentionFor(channel);
|
|
11300
11913
|
if (!retention) {
|
|
11301
11914
|
this.fanOutBroadcast(clientId, channel, event, payload);
|
|
11915
|
+
this.publishBroadcast(clientId, channel, event, payload);
|
|
11302
11916
|
return;
|
|
11303
11917
|
}
|
|
11304
11918
|
const next = (this.channelSendQueues.get(channel) ?? Promise.resolve()).catch(() => {}).then(() => this.persistAndFanOut(clientId, channel, event, payload, retention));
|
|
@@ -11327,6 +11941,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
11327
11941
|
return;
|
|
11328
11942
|
}
|
|
11329
11943
|
this.fanOutBroadcast(clientId, channel, event, payload, seq);
|
|
11944
|
+
this.publishBroadcast(clientId, channel, event, payload, seq);
|
|
11330
11945
|
try {
|
|
11331
11946
|
await this.channelHistory.prune(channel, retention);
|
|
11332
11947
|
} catch (error) {
|
|
@@ -11351,6 +11966,132 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
11351
11966
|
}
|
|
11352
11967
|
}
|
|
11353
11968
|
/**
|
|
11969
|
+
* Install the transport that carries channel frames between instances.
|
|
11970
|
+
*
|
|
11971
|
+
* Called once at boot. A bus that cannot start is reported and replaced with
|
|
11972
|
+
* the memory bus: losing cross-instance fan-out degrades collaboration to
|
|
11973
|
+
* what it was before this existed, whereas refusing to boot takes the whole
|
|
11974
|
+
* backend down for it.
|
|
11975
|
+
*/
|
|
11976
|
+
async configureChannelBus(bus) {
|
|
11977
|
+
if (bus.kind === "memory") {
|
|
11978
|
+
this.bus = bus;
|
|
11979
|
+
return;
|
|
11980
|
+
}
|
|
11981
|
+
try {
|
|
11982
|
+
await bus.start((frame) => this.handleBusFrame(frame));
|
|
11983
|
+
} catch (error) {
|
|
11984
|
+
logger.warn(`⚠️ [ChannelBus] Could not start the "${bus.kind}" channel bus — channel broadcast and presence stay per-instance. Clients served by different replicas will not see each other.`, { error });
|
|
11985
|
+
await bus.stop().catch(() => {});
|
|
11986
|
+
this.bus = new MemoryChannelBus();
|
|
11987
|
+
return;
|
|
11988
|
+
}
|
|
11989
|
+
this.bus = bus;
|
|
11990
|
+
try {
|
|
11991
|
+
const store = new ChannelPresenceStore(this.db, this.instanceId);
|
|
11992
|
+
await store.ensureTables();
|
|
11993
|
+
this.presenceStore = store;
|
|
11994
|
+
this.ensurePresenceSweep();
|
|
11995
|
+
} catch (error) {
|
|
11996
|
+
logger.warn("⚠️ [ChannelBus] Could not create the shared presence table — presence rosters will only list clients connected to this instance (broadcast is unaffected).", { error });
|
|
11997
|
+
this.presenceStore = void 0;
|
|
11998
|
+
}
|
|
11999
|
+
logger.info(`📡 [ChannelBus] Cross-instance channels active via ${bus.kind} (instanceId: ${this.instanceId}).`);
|
|
12000
|
+
}
|
|
12001
|
+
/** Which transport is in use — `"memory"` means per-instance only. */
|
|
12002
|
+
getChannelBusKind() {
|
|
12003
|
+
return this.bus.kind;
|
|
12004
|
+
}
|
|
12005
|
+
/**
|
|
12006
|
+
* Send a broadcast to the other instances.
|
|
12007
|
+
*
|
|
12008
|
+
* Fire-and-forget by design: the clients on this instance have already been
|
|
12009
|
+
* served, and a bus that is briefly unreachable must not turn a broadcast
|
|
12010
|
+
* into an error for the sender.
|
|
12011
|
+
*/
|
|
12012
|
+
publishBroadcast(clientId, channel, event, payload, seq) {
|
|
12013
|
+
if (this.bus.kind === "memory") return;
|
|
12014
|
+
const frame = {
|
|
12015
|
+
kind: "broadcast",
|
|
12016
|
+
sid: this.instanceId,
|
|
12017
|
+
channel,
|
|
12018
|
+
event,
|
|
12019
|
+
from: clientId,
|
|
12020
|
+
...seq !== void 0 ? { seq } : {},
|
|
12021
|
+
payload
|
|
12022
|
+
};
|
|
12023
|
+
if (frameByteLength(frame) > this.bus.maxFrameBytes) {
|
|
12024
|
+
if (seq === void 0) {
|
|
12025
|
+
this.reportOversizedBroadcast(clientId, channel);
|
|
12026
|
+
return;
|
|
12027
|
+
}
|
|
12028
|
+
this.publishFrame({
|
|
12029
|
+
kind: "broadcast_ref",
|
|
12030
|
+
sid: this.instanceId,
|
|
12031
|
+
channel,
|
|
12032
|
+
from: clientId,
|
|
12033
|
+
seq
|
|
12034
|
+
});
|
|
12035
|
+
return;
|
|
12036
|
+
}
|
|
12037
|
+
this.publishFrame(frame);
|
|
12038
|
+
}
|
|
12039
|
+
async publishFrame(frame) {
|
|
12040
|
+
try {
|
|
12041
|
+
await this.bus.publish(frame);
|
|
12042
|
+
} catch (error) {
|
|
12043
|
+
logger.error("❌ [ChannelBus] Failed to publish frame — other instances did not receive it", {
|
|
12044
|
+
detail: `${frame.kind} on "${frame.channel}"`,
|
|
12045
|
+
error
|
|
12046
|
+
});
|
|
12047
|
+
}
|
|
12048
|
+
}
|
|
12049
|
+
/**
|
|
12050
|
+
* Tell the sender that a message was delivered locally but nowhere else.
|
|
12051
|
+
*
|
|
12052
|
+
* Staying quiet here would be the worst option available: on one instance
|
|
12053
|
+
* the app works, on two it works for half the users, and nothing in the
|
|
12054
|
+
* logs connects the two. The fix is a one-liner in config — give the
|
|
12055
|
+
* channel a retention rule and the message travels as a pointer instead —
|
|
12056
|
+
* so the message says exactly that.
|
|
12057
|
+
*/
|
|
12058
|
+
reportOversizedBroadcast(clientId, channel) {
|
|
12059
|
+
const remedy = `Add a retention rule for "${channel}" (realtime.channels) — retained messages travel by reference and have no size limit.`;
|
|
12060
|
+
if (!this.oversizedBroadcastWarned.has(channel)) {
|
|
12061
|
+
this.oversizedBroadcastWarned.add(channel);
|
|
12062
|
+
logger.warn(`⚠️ [ChannelBus] A broadcast on ephemeral channel "${channel}" exceeds the ${this.bus.maxFrameBytes}-byte limit of the ${this.bus.kind} bus and reached only this instance. ` + remedy);
|
|
12063
|
+
}
|
|
12064
|
+
this.sendError(clientId, `Broadcast on "${channel}" was too large to reach other instances. ${remedy}`, void 0, "CHANNEL_BUS_PAYLOAD_TOO_LARGE");
|
|
12065
|
+
}
|
|
12066
|
+
/**
|
|
12067
|
+
* Deliver a frame published by another instance to this one's clients.
|
|
12068
|
+
*
|
|
12069
|
+
* Frames we published ourselves are dropped on arrival — the local fan-out
|
|
12070
|
+
* happened before the publish — exactly as the entity-change handler skips
|
|
12071
|
+
* its own `sid`.
|
|
12072
|
+
*/
|
|
12073
|
+
async handleBusFrame(frame) {
|
|
12074
|
+
if (frame.sid === this.instanceId) return;
|
|
12075
|
+
switch (frame.kind) {
|
|
12076
|
+
case "broadcast":
|
|
12077
|
+
this.fanOutBroadcast(frame.from ?? "", frame.channel, frame.event, frame.payload, frame.seq);
|
|
12078
|
+
return;
|
|
12079
|
+
case "broadcast_ref": {
|
|
12080
|
+
if (!this.channels.get(frame.channel)?.size) return;
|
|
12081
|
+
const entry = await this.channelHistory?.getBySeq(frame.channel, frame.seq);
|
|
12082
|
+
if (!entry) {
|
|
12083
|
+
logger.warn(`⚠️ [ChannelBus] Message ${frame.seq} on "${frame.channel}" is no longer retained — clients on this instance will need to replay (channel_history) to catch up.`);
|
|
12084
|
+
return;
|
|
12085
|
+
}
|
|
12086
|
+
this.fanOutBroadcast(frame.from ?? "", frame.channel, entry.event, entry.payload, entry.seq);
|
|
12087
|
+
return;
|
|
12088
|
+
}
|
|
12089
|
+
case "presence_diff":
|
|
12090
|
+
this.deliverPresenceDiff(frame.channel, frame.joins, frame.leaves);
|
|
12091
|
+
return;
|
|
12092
|
+
}
|
|
12093
|
+
}
|
|
12094
|
+
/**
|
|
11354
12095
|
* Install retention rules and create the tables they need.
|
|
11355
12096
|
*
|
|
11356
12097
|
* Safe to call with no rules (and safe not to call at all): the store stays
|
|
@@ -11399,32 +12140,77 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
11399
12140
|
...latestSeq !== void 0 ? { latestSeq } : {}
|
|
11400
12141
|
}));
|
|
11401
12142
|
}
|
|
11402
|
-
/**
|
|
12143
|
+
/**
|
|
12144
|
+
* Track presence in a channel.
|
|
12145
|
+
*
|
|
12146
|
+
* The client re-sends this every ~20s as a heartbeat against the 30s
|
|
12147
|
+
* timeout, so most calls carry the state that is already recorded. Those
|
|
12148
|
+
* refresh `last_seen` and stop there: re-announcing an unchanged state to
|
|
12149
|
+
* every instance would put a bus message per client per heartbeat on the
|
|
12150
|
+
* wire to tell everyone nothing happened.
|
|
12151
|
+
*/
|
|
11403
12152
|
trackPresence(clientId, channel, state) {
|
|
11404
12153
|
if (!this.presence.has(channel)) this.presence.set(channel, /* @__PURE__ */ new Map());
|
|
11405
|
-
this.presence.get(channel)
|
|
12154
|
+
const channelPresence = this.presence.get(channel);
|
|
12155
|
+
const previous = channelPresence.get(clientId);
|
|
12156
|
+
const changed = !previous || JSON.stringify(previous.state) !== JSON.stringify(state);
|
|
12157
|
+
channelPresence.set(clientId, {
|
|
11406
12158
|
state,
|
|
11407
12159
|
lastSeen: Date.now()
|
|
11408
12160
|
});
|
|
11409
|
-
this.
|
|
12161
|
+
this.presenceStoreOp(() => this.presenceStore.track(channel, clientId, state), "track");
|
|
12162
|
+
this.deliverPresenceDiff(channel, { [clientId]: state }, {});
|
|
12163
|
+
if (changed) this.publishPresenceDiff(channel, { [clientId]: state }, {});
|
|
11410
12164
|
this.ensurePresenceCleanup();
|
|
11411
12165
|
}
|
|
11412
|
-
/**
|
|
11413
|
-
|
|
12166
|
+
/**
|
|
12167
|
+
* Remove presence from a channel.
|
|
12168
|
+
*
|
|
12169
|
+
* `skipStore` is for the socket-close path, which clears every channel at
|
|
12170
|
+
* once and then deletes the client's rows in a single statement instead of
|
|
12171
|
+
* one per channel.
|
|
12172
|
+
*/
|
|
12173
|
+
removePresence(clientId, channel, options) {
|
|
11414
12174
|
const channelPresence = this.presence.get(channel);
|
|
11415
12175
|
if (!channelPresence) return;
|
|
11416
12176
|
const entry = channelPresence.get(clientId);
|
|
11417
12177
|
if (entry) {
|
|
11418
12178
|
channelPresence.delete(clientId);
|
|
11419
|
-
this.
|
|
12179
|
+
this.deliverPresenceDiff(channel, {}, { [clientId]: entry.state });
|
|
12180
|
+
this.publishPresenceDiff(channel, {}, { [clientId]: entry.state });
|
|
12181
|
+
if (!options?.skipStore) this.presenceStoreOp(() => this.presenceStore.remove(channel, clientId), "remove");
|
|
11420
12182
|
}
|
|
11421
12183
|
if (channelPresence.size === 0) this.presence.delete(channel);
|
|
11422
12184
|
}
|
|
11423
|
-
/**
|
|
12185
|
+
/**
|
|
12186
|
+
* Send the full roster for a channel to one client.
|
|
12187
|
+
*
|
|
12188
|
+
* Answered from the shared table when there is one, because "who is in this
|
|
12189
|
+
* document?" has a single answer that must not depend on which replica the
|
|
12190
|
+
* asker happens to be connected to. Without a bus there is nothing to share
|
|
12191
|
+
* and the local map *is* the roster — that path stays synchronous, which is
|
|
12192
|
+
* what it always was.
|
|
12193
|
+
*/
|
|
11424
12194
|
sendPresenceState(clientId, channel) {
|
|
12195
|
+
if (!this.presenceStore) {
|
|
12196
|
+
this.sendPresenceStateMessage(clientId, channel, this.localPresences(channel));
|
|
12197
|
+
return;
|
|
12198
|
+
}
|
|
12199
|
+
this.presenceStore.roster(channel).then((presences) => {
|
|
12200
|
+
this.sendPresenceStateMessage(clientId, channel, presences);
|
|
12201
|
+
}).catch((error) => {
|
|
12202
|
+
logger.warn(`⚠️ [Presence] Could not read the shared roster for "${channel}" — answering with this instance's clients only.`, { error });
|
|
12203
|
+
this.sendPresenceStateMessage(clientId, channel, this.localPresences(channel));
|
|
12204
|
+
});
|
|
12205
|
+
}
|
|
12206
|
+
/** Presence of the clients connected to this instance. */
|
|
12207
|
+
localPresences(channel) {
|
|
11425
12208
|
const channelPresence = this.presence.get(channel);
|
|
11426
12209
|
const presences = {};
|
|
11427
12210
|
if (channelPresence) for (const [id, { state }] of channelPresence) presences[id] = state;
|
|
12211
|
+
return presences;
|
|
12212
|
+
}
|
|
12213
|
+
sendPresenceStateMessage(clientId, channel, presences) {
|
|
11428
12214
|
const ws = this.clients.get(clientId);
|
|
11429
12215
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({
|
|
11430
12216
|
type: "presence_state",
|
|
@@ -11432,8 +12218,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
11432
12218
|
presences
|
|
11433
12219
|
}));
|
|
11434
12220
|
}
|
|
11435
|
-
/**
|
|
11436
|
-
|
|
12221
|
+
/** Deliver a presence diff to this instance's members of the channel. */
|
|
12222
|
+
deliverPresenceDiff(channel, joins, leaves) {
|
|
11437
12223
|
const members = this.channels.get(channel);
|
|
11438
12224
|
if (!members) return;
|
|
11439
12225
|
const message = JSON.stringify({
|
|
@@ -11447,6 +12233,26 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
11447
12233
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(message);
|
|
11448
12234
|
}
|
|
11449
12235
|
}
|
|
12236
|
+
/** Tell the other instances about a presence change. */
|
|
12237
|
+
publishPresenceDiff(channel, joins, leaves) {
|
|
12238
|
+
if (this.bus.kind === "memory") return;
|
|
12239
|
+
this.publishFrame({
|
|
12240
|
+
kind: "presence_diff",
|
|
12241
|
+
sid: this.instanceId,
|
|
12242
|
+
channel,
|
|
12243
|
+
joins,
|
|
12244
|
+
leaves
|
|
12245
|
+
});
|
|
12246
|
+
}
|
|
12247
|
+
/** Run a roster write when there is a roster, and never let it throw. */
|
|
12248
|
+
async presenceStoreOp(op, label) {
|
|
12249
|
+
if (!this.presenceStore) return;
|
|
12250
|
+
try {
|
|
12251
|
+
await op();
|
|
12252
|
+
} catch (error) {
|
|
12253
|
+
logger.warn(`⚠️ [Presence] Shared roster ${label} failed`, { error });
|
|
12254
|
+
}
|
|
12255
|
+
}
|
|
11450
12256
|
/** Periodic cleanup for stale presences */
|
|
11451
12257
|
ensurePresenceCleanup() {
|
|
11452
12258
|
if (this.presenceInterval) return;
|
|
@@ -11460,6 +12266,35 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
11460
12266
|
}, 1e4);
|
|
11461
12267
|
}
|
|
11462
12268
|
/**
|
|
12269
|
+
* Reap roster rows whose owning instance stopped heartbeating.
|
|
12270
|
+
*
|
|
12271
|
+
* This is the cross-instance half of the sweep above, and it doubles as
|
|
12272
|
+
* crash recovery: a pod that dies takes its clients with it but leaves
|
|
12273
|
+
* their rows behind, and after one TTL window they look exactly like any
|
|
12274
|
+
* other client that went quiet. The delete returns what it removed, so
|
|
12275
|
+
* whichever instance wins the race is the one that announces the
|
|
12276
|
+
* departures — once for the cluster, not once per replica.
|
|
12277
|
+
*/
|
|
12278
|
+
ensurePresenceSweep() {
|
|
12279
|
+
if (this.presenceSweepInterval || !this.presenceStore) return;
|
|
12280
|
+
this.presenceSweepInterval = setInterval(() => void this.sweepStalePresence(), RealtimeService.PRESENCE_SWEEP_INTERVAL_MS);
|
|
12281
|
+
this.presenceSweepInterval.unref?.();
|
|
12282
|
+
}
|
|
12283
|
+
/** One pass of the stale-roster sweep. See {@link ensurePresenceSweep}. */
|
|
12284
|
+
async sweepStalePresence() {
|
|
12285
|
+
if (!this.presenceStore) return;
|
|
12286
|
+
try {
|
|
12287
|
+
const removed = await this.presenceStore.sweepStale(RealtimeService.PRESENCE_TIMEOUT_MS);
|
|
12288
|
+
for (const row of removed) {
|
|
12289
|
+
this.debugLog(`👻 [Presence] Reaped stale presence ${row.clientId} on "${row.channel}"`);
|
|
12290
|
+
this.deliverPresenceDiff(row.channel, {}, { [row.clientId]: row.state });
|
|
12291
|
+
this.publishPresenceDiff(row.channel, {}, { [row.clientId]: row.state });
|
|
12292
|
+
}
|
|
12293
|
+
} catch (error) {
|
|
12294
|
+
logger.warn("⚠️ [Presence] Stale-roster sweep failed", { error });
|
|
12295
|
+
}
|
|
12296
|
+
}
|
|
12297
|
+
/**
|
|
11463
12298
|
* Gracefully tear down all realtime resources.
|
|
11464
12299
|
*
|
|
11465
12300
|
* This MUST be called during process shutdown, **before** `pool.end()`.
|
|
@@ -11486,8 +12321,23 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
11486
12321
|
clearInterval(this.presenceInterval);
|
|
11487
12322
|
this.presenceInterval = void 0;
|
|
11488
12323
|
}
|
|
12324
|
+
if (this.presenceSweepInterval) {
|
|
12325
|
+
clearInterval(this.presenceSweepInterval);
|
|
12326
|
+
this.presenceSweepInterval = void 0;
|
|
12327
|
+
}
|
|
12328
|
+
this.oversizedBroadcastWarned.clear();
|
|
12329
|
+
if (this.presenceStore) {
|
|
12330
|
+
try {
|
|
12331
|
+
await this.presenceStore.removeInstance();
|
|
12332
|
+
} catch (error) {
|
|
12333
|
+
logger.warn("⚠️ [Presence] Could not clear this instance's roster rows on shutdown", { error });
|
|
12334
|
+
}
|
|
12335
|
+
this.presenceStore = void 0;
|
|
12336
|
+
}
|
|
11489
12337
|
await this.stopListening();
|
|
11490
12338
|
await this.stopCdc();
|
|
12339
|
+
await this.bus.stop().catch((error) => logger.warn("⚠️ [ChannelBus] Error while stopping the channel bus", { error }));
|
|
12340
|
+
this.bus = new MemoryChannelBus();
|
|
11491
12341
|
this.clients.clear();
|
|
11492
12342
|
this.debugLog("🧹 [RealtimeService] destroy() complete — all resources released.");
|
|
11493
12343
|
}
|
|
@@ -12463,11 +13313,57 @@ function buildPgRestoreArgs(opts) {
|
|
|
12463
13313
|
];
|
|
12464
13314
|
if (opts.clean) args.push("--clean", "--if-exists");
|
|
12465
13315
|
if (opts.noOwner) args.push("--no-owner");
|
|
12466
|
-
if (opts.exitOnError) args.push("--exit-on-error");
|
|
13316
|
+
if (opts.exitOnError !== false) args.push("--exit-on-error");
|
|
12467
13317
|
args.push(opts.inputFile);
|
|
12468
13318
|
return args;
|
|
12469
13319
|
}
|
|
12470
13320
|
/**
|
|
13321
|
+
* Assemble the `pg_restore --list` argument vector. Reading a dump's table
|
|
13322
|
+
* of contents parses the whole archive without touching a database, so it is
|
|
13323
|
+
* a cheap integrity check that the file isn't truncated or corrupt.
|
|
13324
|
+
*/
|
|
13325
|
+
function buildPgRestoreListArgs(inputFile) {
|
|
13326
|
+
return ["--list", inputFile];
|
|
13327
|
+
}
|
|
13328
|
+
/**
|
|
13329
|
+
* Assemble the `pg_dumpall --globals-only` argument vector. Roles (and other
|
|
13330
|
+
* cluster-wide objects) live outside any single database, so a per-database
|
|
13331
|
+
* `pg_dump` omits them. Without the `rebase_user` role the RLS GRANT
|
|
13332
|
+
* statements in the main dump fail on restore and RLS is silently lost — so
|
|
13333
|
+
* every backup captures the globals into a sidecar `.globals.sql`.
|
|
13334
|
+
*
|
|
13335
|
+
* `--no-role-passwords` keeps role secrets out of the artifact (backups may
|
|
13336
|
+
* be shipped off-box); roles are recreated password-less and re-secured by
|
|
13337
|
+
* the operator.
|
|
13338
|
+
*/
|
|
13339
|
+
function buildPgDumpallGlobalsArgs(opts) {
|
|
13340
|
+
return [
|
|
13341
|
+
"--globals-only",
|
|
13342
|
+
"--no-role-passwords",
|
|
13343
|
+
"--no-password",
|
|
13344
|
+
`--file=${opts.outFile}`,
|
|
13345
|
+
`--dbname=${opts.connectionString}`
|
|
13346
|
+
];
|
|
13347
|
+
}
|
|
13348
|
+
/**
|
|
13349
|
+
* Derive the globals sidecar path/key for a given `.dump` file. Keeps the
|
|
13350
|
+
* two artifacts adjacent so listing, uploading and pruning can find one from
|
|
13351
|
+
* the other. A name that doesn't end in `.dump` is returned unchanged with a
|
|
13352
|
+
* `.globals.sql` suffix appended.
|
|
13353
|
+
*/
|
|
13354
|
+
function globalsFileForDump(dumpPath) {
|
|
13355
|
+
return dumpPath.endsWith(".dump") ? dumpPath.slice(0, -5) + ".globals.sql" : dumpPath + ".globals.sql";
|
|
13356
|
+
}
|
|
13357
|
+
/**
|
|
13358
|
+
* Split a `pg_dumpall --globals-only` script into individual statements.
|
|
13359
|
+
* Used when replaying globals on restore so each `CREATE ROLE` / `GRANT`
|
|
13360
|
+
* can run independently and a benign "role already exists" on one doesn't
|
|
13361
|
+
* abort the rest. Drops `--` comment lines and blank statements.
|
|
13362
|
+
*/
|
|
13363
|
+
function splitGlobalsStatements(sql) {
|
|
13364
|
+
return sql.split("\n").filter((line) => !line.trim().startsWith("--")).join("\n").split(";").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
13365
|
+
}
|
|
13366
|
+
/**
|
|
12471
13367
|
* Resolve the Postgres connection string the backup commands should use,
|
|
12472
13368
|
* mirroring the precedence the branch command already relies on.
|
|
12473
13369
|
*/
|
|
@@ -20756,6 +21652,58 @@ function resolveLocalBin(binName) {
|
|
|
20756
21652
|
return null;
|
|
20757
21653
|
}
|
|
20758
21654
|
//#endregion
|
|
21655
|
+
//#region src/backup/backup-logic.ts
|
|
21656
|
+
/**
|
|
21657
|
+
* Pure-ish orchestration for the backup/restore paths, kept free of `execa`
|
|
21658
|
+
* and `pg` value imports so it can be unit-tested under jest (the impure
|
|
21659
|
+
* edges — spawning processes, opening connections — live in
|
|
21660
|
+
* `backup-service.ts`, which is vitest/runtime only).
|
|
21661
|
+
*
|
|
21662
|
+
* The functions here take their side-effecting dependency as an argument
|
|
21663
|
+
* (a statement runner, an object deleter) so tests can inject fakes.
|
|
21664
|
+
*/
|
|
21665
|
+
/**
|
|
21666
|
+
* Replay a `pg_dumpall --globals-only` script one statement at a time,
|
|
21667
|
+
* tolerating per-statement failures. On a same-cluster restore the roles
|
|
21668
|
+
* usually already exist (`CREATE ROLE` → "already exists") and on a managed
|
|
21669
|
+
* provider an `ALTER ROLE <superuser>` may be refused; neither should abort
|
|
21670
|
+
* recreation of the roles that *are* missing. Returns how many statements
|
|
21671
|
+
* applied vs were skipped.
|
|
21672
|
+
*
|
|
21673
|
+
* `runStatement` executes one SQL statement and rejects on error.
|
|
21674
|
+
*/
|
|
21675
|
+
async function applyGlobalsWith(runStatement, globalsSql, log = () => {}) {
|
|
21676
|
+
let applied = 0;
|
|
21677
|
+
let skipped = 0;
|
|
21678
|
+
for (const statement of splitGlobalsStatements(globalsSql)) try {
|
|
21679
|
+
await runStatement(statement);
|
|
21680
|
+
applied++;
|
|
21681
|
+
} catch (err) {
|
|
21682
|
+
skipped++;
|
|
21683
|
+
log(` • Skipped global: ${statement.split("\n")[0].slice(0, 80)} (${err instanceof Error ? err.message : String(err)})`);
|
|
21684
|
+
}
|
|
21685
|
+
return {
|
|
21686
|
+
applied,
|
|
21687
|
+
skipped
|
|
21688
|
+
};
|
|
21689
|
+
}
|
|
21690
|
+
/**
|
|
21691
|
+
* Delete each pruned dump together with its `.globals.sql` roles sidecar, so
|
|
21692
|
+
* pruning never orphans the roles file. The sidecar is best-effort — older
|
|
21693
|
+
* backups predate it, so a missing-sidecar failure is swallowed while a
|
|
21694
|
+
* failure deleting the dump itself propagates.
|
|
21695
|
+
*
|
|
21696
|
+
* `deleteObject` removes one key and rejects if it cannot (e.g. not found).
|
|
21697
|
+
*/
|
|
21698
|
+
async function pruneWith(keys, deleteObject) {
|
|
21699
|
+
for (const key of keys) {
|
|
21700
|
+
await deleteObject(key);
|
|
21701
|
+
try {
|
|
21702
|
+
await deleteObject(globalsFileForDump(key));
|
|
21703
|
+
} catch {}
|
|
21704
|
+
}
|
|
21705
|
+
}
|
|
21706
|
+
//#endregion
|
|
20759
21707
|
//#region src/backup/backup-service.ts
|
|
20760
21708
|
/**
|
|
20761
21709
|
* Backup / restore orchestration: thin, well-typed wrappers around
|
|
@@ -20768,6 +21716,7 @@ function resolveLocalBin(binName) {
|
|
|
20768
21716
|
*/
|
|
20769
21717
|
var backup_service_exports = /* @__PURE__ */ __exportAll({
|
|
20770
21718
|
BackupToolError: () => BackupToolError,
|
|
21719
|
+
applyGlobals: () => applyGlobals,
|
|
20771
21720
|
createDump: () => createDump,
|
|
20772
21721
|
detectToolMajor: () => detectToolMajor,
|
|
20773
21722
|
ensureDatabaseExists: () => ensureDatabaseExists,
|
|
@@ -20777,7 +21726,8 @@ var backup_service_exports = /* @__PURE__ */ __exportAll({
|
|
|
20777
21726
|
pruneBackups: () => pruneBackups,
|
|
20778
21727
|
resolvePgBinary: () => resolvePgBinary,
|
|
20779
21728
|
restoreDump: () => restoreDump,
|
|
20780
|
-
uploadBackup: () => uploadBackup
|
|
21729
|
+
uploadBackup: () => uploadBackup,
|
|
21730
|
+
validateDump: () => validateDump
|
|
20781
21731
|
});
|
|
20782
21732
|
var BackupToolError = class extends Error {
|
|
20783
21733
|
hint;
|
|
@@ -20787,10 +21737,10 @@ var BackupToolError = class extends Error {
|
|
|
20787
21737
|
this.name = "BackupToolError";
|
|
20788
21738
|
}
|
|
20789
21739
|
};
|
|
20790
|
-
/** Locate `pg_dump` / `pg_restore`, honouring an env override. */
|
|
21740
|
+
/** Locate `pg_dump` / `pg_restore` / `pg_dumpall`, honouring an env override. */
|
|
20791
21741
|
function resolvePgBinary(tool, env = process.env) {
|
|
20792
|
-
const
|
|
20793
|
-
if (
|
|
21742
|
+
const overrideVar = tool === "pg_dump" ? env.PG_DUMP_PATH : tool === "pg_restore" ? env.PG_RESTORE_PATH : env.PG_DUMPALL_PATH;
|
|
21743
|
+
if (overrideVar && fs.existsSync(overrideVar)) return overrideVar;
|
|
20794
21744
|
return resolveLocalBin(tool);
|
|
20795
21745
|
}
|
|
20796
21746
|
/** Run `<bin> --version` and extract the major version. */
|
|
@@ -20834,6 +21784,11 @@ async function preflight(tool, connectionString, env = process.env) {
|
|
|
20834
21784
|
* Produce a custom-format dump on local disk. When `outDir` is omitted the
|
|
20835
21785
|
* file is written to the OS temp directory (used by the upload path, which
|
|
20836
21786
|
* cleans it up afterwards).
|
|
21787
|
+
*
|
|
21788
|
+
* Alongside the `-Fc` dump it writes a `<name>.globals.sql` sidecar via
|
|
21789
|
+
* `pg_dumpall --globals-only` so the roles the dump's GRANT/RLS statements
|
|
21790
|
+
* depend on can be recreated on restore. Set `includeGlobals: false` to skip
|
|
21791
|
+
* it (e.g. when the caller has no privilege to read cluster globals).
|
|
20837
21792
|
*/
|
|
20838
21793
|
async function createDump(opts) {
|
|
20839
21794
|
const env = opts.env ?? process.env;
|
|
@@ -20852,17 +21807,92 @@ async function createDump(opts) {
|
|
|
20852
21807
|
stdio: opts.inheritStdio ? "inherit" : "pipe",
|
|
20853
21808
|
env: { ...env }
|
|
20854
21809
|
});
|
|
20855
|
-
|
|
21810
|
+
const result = {
|
|
20856
21811
|
localFile,
|
|
20857
21812
|
fileName,
|
|
20858
21813
|
sizeBytes: fs.existsSync(localFile) ? fs.statSync(localFile).size : 0
|
|
20859
21814
|
};
|
|
21815
|
+
if (opts.includeGlobals !== false) {
|
|
21816
|
+
const dumpallBin = resolvePgBinary("pg_dumpall", env);
|
|
21817
|
+
if (!dumpallBin) throw new BackupToolError("Could not find the 'pg_dumpall' binary needed to capture cluster roles.", "Install the PostgreSQL client tools or set PG_DUMPALL_PATH. To take a role-incomplete backup anyway, pass includeGlobals: false.");
|
|
21818
|
+
const globalsFile = globalsFileForDump(localFile);
|
|
21819
|
+
await execa(dumpallBin, buildPgDumpallGlobalsArgs({
|
|
21820
|
+
connectionString: opts.connectionString,
|
|
21821
|
+
outFile: globalsFile
|
|
21822
|
+
}), {
|
|
21823
|
+
stdio: opts.inheritStdio ? "inherit" : "pipe",
|
|
21824
|
+
env: { ...env }
|
|
21825
|
+
});
|
|
21826
|
+
result.globalsFile = globalsFile;
|
|
21827
|
+
result.globalsSizeBytes = fs.existsSync(globalsFile) ? fs.statSync(globalsFile).size : 0;
|
|
21828
|
+
}
|
|
21829
|
+
return result;
|
|
21830
|
+
}
|
|
21831
|
+
/**
|
|
21832
|
+
* Cheap integrity check on a freshly written dump: it must be non-empty and
|
|
21833
|
+
* `pg_restore --list` must parse its table of contents without error. Used
|
|
21834
|
+
* before pruning older backups so a corrupt-but-exit-0 dump never becomes
|
|
21835
|
+
* the reason the last good backup is deleted.
|
|
21836
|
+
*/
|
|
21837
|
+
async function validateDump(localFile, env = process.env) {
|
|
21838
|
+
if (!fs.existsSync(localFile)) return {
|
|
21839
|
+
ok: false,
|
|
21840
|
+
reason: `Dump file does not exist: ${localFile}`
|
|
21841
|
+
};
|
|
21842
|
+
if (fs.statSync(localFile).size === 0) return {
|
|
21843
|
+
ok: false,
|
|
21844
|
+
reason: "Dump file is empty (0 bytes)."
|
|
21845
|
+
};
|
|
21846
|
+
const bin = resolvePgBinary("pg_restore", env);
|
|
21847
|
+
if (!bin) return {
|
|
21848
|
+
ok: false,
|
|
21849
|
+
reason: "Could not find 'pg_restore' to verify the dump."
|
|
21850
|
+
};
|
|
21851
|
+
try {
|
|
21852
|
+
await execa(bin, buildPgRestoreListArgs(localFile), {
|
|
21853
|
+
stdio: "pipe",
|
|
21854
|
+
env: { ...env }
|
|
21855
|
+
});
|
|
21856
|
+
return { ok: true };
|
|
21857
|
+
} catch (err) {
|
|
21858
|
+
return {
|
|
21859
|
+
ok: false,
|
|
21860
|
+
reason: `pg_restore --list failed: ${err instanceof Error ? err.message : String(err)}`
|
|
21861
|
+
};
|
|
21862
|
+
}
|
|
21863
|
+
}
|
|
21864
|
+
/**
|
|
21865
|
+
* Replay a `pg_dumpall --globals-only` script to recreate cluster roles
|
|
21866
|
+
* before a restore, so the dump's GRANT/RLS statements (which reference
|
|
21867
|
+
* `rebase_user` and any owner roles) actually apply. Runs statement by
|
|
21868
|
+
* statement and tolerates per-statement failures — on a same-cluster restore
|
|
21869
|
+
* the roles usually already exist (`CREATE ROLE` → "already exists"), and on
|
|
21870
|
+
* a managed provider an `ALTER ROLE <superuser>` may be refused; neither
|
|
21871
|
+
* should abort role recreation. Returns how many statements applied vs were
|
|
21872
|
+
* skipped.
|
|
21873
|
+
*/
|
|
21874
|
+
async function applyGlobals(connectionString, globalsSql, log = () => {}) {
|
|
21875
|
+
const { Client } = await import("pg");
|
|
21876
|
+
const client = new Client({ connectionString });
|
|
21877
|
+
await client.connect();
|
|
21878
|
+
try {
|
|
21879
|
+
return await applyGlobalsWith(async (sql) => {
|
|
21880
|
+
await client.query(sql);
|
|
21881
|
+
}, globalsSql, log);
|
|
21882
|
+
} finally {
|
|
21883
|
+
await client.end();
|
|
21884
|
+
}
|
|
20860
21885
|
}
|
|
20861
21886
|
/**
|
|
20862
21887
|
* Restore a custom-format dump into the database named by
|
|
20863
21888
|
* `connectionString`. Destructive when `clean` is set (drops objects
|
|
20864
21889
|
* first). Never called automatically — the CLI gates it behind explicit
|
|
20865
21890
|
* confirmation.
|
|
21891
|
+
*
|
|
21892
|
+
* Runs with `--exit-on-error` by default: a restore that logs-and-continues
|
|
21893
|
+
* past a failed GRANT (because a role was missing) reports success with RLS
|
|
21894
|
+
* un-enforced. Callers should recreate roles first (see {@link applyGlobals})
|
|
21895
|
+
* and only set `exitOnError: false` deliberately.
|
|
20866
21896
|
*/
|
|
20867
21897
|
async function restoreDump(opts) {
|
|
20868
21898
|
const env = opts.env ?? process.env;
|
|
@@ -20872,7 +21902,8 @@ async function restoreDump(opts) {
|
|
|
20872
21902
|
connectionString: opts.connectionString,
|
|
20873
21903
|
inputFile: opts.inputFile,
|
|
20874
21904
|
clean: opts.clean,
|
|
20875
|
-
noOwner: opts.noOwner
|
|
21905
|
+
noOwner: opts.noOwner,
|
|
21906
|
+
exitOnError: opts.exitOnError
|
|
20876
21907
|
}), {
|
|
20877
21908
|
stdio: opts.inheritStdio ? "inherit" : "pipe",
|
|
20878
21909
|
env: { ...env }
|
|
@@ -20949,9 +21980,13 @@ async function listBackups(dest, storage) {
|
|
|
20949
21980
|
*/
|
|
20950
21981
|
async function pruneBackups(dest, options, storage) {
|
|
20951
21982
|
const toDelete = selectBackupsToPrune(await listBackups(dest, storage), options);
|
|
20952
|
-
|
|
20953
|
-
if (fs.existsSync(key))
|
|
20954
|
-
|
|
21983
|
+
await pruneWith(toDelete, dest.kind === "local" ? async (key) => {
|
|
21984
|
+
if (!fs.existsSync(key)) throw new Error(`not found: ${key}`);
|
|
21985
|
+
fs.unlinkSync(key);
|
|
21986
|
+
} : async (key) => {
|
|
21987
|
+
if (!storage) throw new BackupToolError("Storage backend required to prune object backups.");
|
|
21988
|
+
await storage.deleteObject(key, dest.bucket);
|
|
21989
|
+
});
|
|
20955
21990
|
return toDelete;
|
|
20956
21991
|
}
|
|
20957
21992
|
//#endregion
|
|
@@ -21016,7 +22051,7 @@ function createBackupCron(config) {
|
|
|
21016
22051
|
enabled: config.enabled ?? true,
|
|
21017
22052
|
timeoutSeconds: 3600,
|
|
21018
22053
|
async handler({ log }) {
|
|
21019
|
-
const { createDump, pruneBackups, uploadBackup } = await Promise.resolve().then(() => backup_service_exports);
|
|
22054
|
+
const { createDump, pruneBackups, uploadBackup, validateDump } = await Promise.resolve().then(() => backup_service_exports);
|
|
21020
22055
|
const { destination } = config;
|
|
21021
22056
|
if (destination.kind !== "local" && !config.storage) throw new Error(`Backup destination is ${destination.kind} but no storage controller was provided. Pass the backend's configured StorageController to createBackupCron({ storage }).`);
|
|
21022
22057
|
log(`Starting backup of "${dbName}"…`);
|
|
@@ -21028,15 +22063,23 @@ function createBackupCron(config) {
|
|
|
21028
22063
|
excludeSchemas
|
|
21029
22064
|
});
|
|
21030
22065
|
log(`Dump created: ${dump.fileName} (${formatBytes(dump.sizeBytes)})`);
|
|
22066
|
+
const check = await validateDump(dump.localFile);
|
|
22067
|
+
if (!check.ok) {
|
|
22068
|
+
if (destination.kind !== "local" && fs.existsSync(dump.localFile)) fs.unlinkSync(dump.localFile);
|
|
22069
|
+
if (dump.globalsFile && destination.kind !== "local" && fs.existsSync(dump.globalsFile)) fs.unlinkSync(dump.globalsFile);
|
|
22070
|
+
throw new Error(`New backup failed validation — skipping upload and pruning to protect existing backups. ${check.reason}`);
|
|
22071
|
+
}
|
|
21031
22072
|
let storedKey = dump.localFile;
|
|
21032
22073
|
try {
|
|
21033
22074
|
if (destination.kind !== "local") {
|
|
21034
22075
|
const uploaded = await uploadBackup(config.storage, dump.localFile, destination);
|
|
21035
22076
|
storedKey = uploaded.storageUrl;
|
|
21036
22077
|
log(`Uploaded to ${uploaded.storageUrl}`);
|
|
22078
|
+
if (dump.globalsFile && fs.existsSync(dump.globalsFile)) log(`Uploaded roles sidecar to ${(await uploadBackup(config.storage, dump.globalsFile, destination)).storageUrl}`);
|
|
21037
22079
|
}
|
|
21038
22080
|
} finally {
|
|
21039
22081
|
if (destination.kind !== "local" && fs.existsSync(dump.localFile)) fs.unlinkSync(dump.localFile);
|
|
22082
|
+
if (dump.globalsFile && destination.kind !== "local" && fs.existsSync(dump.globalsFile)) fs.unlinkSync(dump.globalsFile);
|
|
21040
22083
|
}
|
|
21041
22084
|
let pruned = [];
|
|
21042
22085
|
if (config.retentionDays && config.retentionDays > 0) {
|
|
@@ -21153,6 +22196,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
21153
22196
|
email_verification_sent_at TIMESTAMP WITH TIME ZONE,
|
|
21154
22197
|
is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
|
|
21155
22198
|
metadata JSONB DEFAULT '{}' NOT NULL,
|
|
22199
|
+
tokens_valid_after TIMESTAMP WITH TIME ZONE,
|
|
21156
22200
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
|
21157
22201
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
|
21158
22202
|
)
|
|
@@ -21233,12 +22277,15 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
21233
22277
|
CREATE TABLE IF NOT EXISTS ${sql.raw(refreshTokensTableName)} (
|
|
21234
22278
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
21235
22279
|
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
22280
|
+
session_id TEXT NOT NULL DEFAULT gen_random_uuid()::text,
|
|
21236
22281
|
token_hash TEXT NOT NULL UNIQUE,
|
|
21237
22282
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
22283
|
+
revoked BOOLEAN DEFAULT FALSE NOT NULL,
|
|
22284
|
+
rotated_at TIMESTAMP WITH TIME ZONE,
|
|
22285
|
+
session_started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
|
21238
22286
|
user_agent TEXT,
|
|
21239
22287
|
ip_address TEXT,
|
|
21240
|
-
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
21241
|
-
CONSTRAINT unique_device_session UNIQUE (uid, user_agent, ip_address)
|
|
22288
|
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
21242
22289
|
)
|
|
21243
22290
|
`);
|
|
21244
22291
|
await db.execute(sql`
|
|
@@ -21328,6 +22375,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
21328
22375
|
"email_verification_sent_at TIMESTAMP WITH TIME ZONE",
|
|
21329
22376
|
"is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
|
|
21330
22377
|
"metadata JSONB DEFAULT '{}' NOT NULL",
|
|
22378
|
+
"tokens_valid_after TIMESTAMP WITH TIME ZONE",
|
|
21331
22379
|
"created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
|
|
21332
22380
|
"updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
|
|
21333
22381
|
]) await db.execute(sql`
|
|
@@ -21344,42 +22392,36 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
21344
22392
|
for (const { table_schema } of found) {
|
|
21345
22393
|
const qualified = `"${table_schema}"."refresh_tokens"`;
|
|
21346
22394
|
try {
|
|
21347
|
-
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS
|
|
21348
|
-
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS
|
|
21349
|
-
await db.execute(sql`
|
|
21350
|
-
await db.execute(sql`
|
|
22395
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_id TEXT`);
|
|
22396
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);
|
|
22397
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);
|
|
22398
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_started_at TIMESTAMP WITH TIME ZONE`);
|
|
21351
22399
|
await db.execute(sql`
|
|
21352
|
-
|
|
21353
|
-
|
|
21354
|
-
WHERE
|
|
21355
|
-
AND a.uid = b.uid
|
|
21356
|
-
AND a.user_agent = b.user_agent
|
|
21357
|
-
AND a.ip_address = b.ip_address
|
|
22400
|
+
UPDATE ${sql.raw(qualified)}
|
|
22401
|
+
SET session_id = gen_random_uuid()::text
|
|
22402
|
+
WHERE session_id IS NULL
|
|
21358
22403
|
`);
|
|
21359
|
-
|
|
21360
|
-
|
|
21361
|
-
|
|
21362
|
-
WHERE
|
|
22404
|
+
await db.execute(sql`
|
|
22405
|
+
UPDATE ${sql.raw(qualified)}
|
|
22406
|
+
SET session_started_at = COALESCE(created_at, NOW())
|
|
22407
|
+
WHERE session_started_at IS NULL
|
|
21363
22408
|
`);
|
|
21364
|
-
|
|
21365
|
-
|
|
21366
|
-
|
|
21367
|
-
|
|
21368
|
-
|
|
21369
|
-
|
|
21370
|
-
|
|
21371
|
-
|
|
21372
|
-
|
|
21373
|
-
|
|
21374
|
-
`);
|
|
21375
|
-
logger.info(`✅ Added correct unique_device_session constraint to ${qualified}`);
|
|
21376
|
-
}
|
|
22409
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET DEFAULT gen_random_uuid()::text`);
|
|
22410
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET DEFAULT NOW()`);
|
|
22411
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET NOT NULL`);
|
|
22412
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET NOT NULL`);
|
|
22413
|
+
await db.execute(sql`
|
|
22414
|
+
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_session
|
|
22415
|
+
ON ${sql.raw(qualified)}(session_id)
|
|
22416
|
+
`);
|
|
22417
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);
|
|
22418
|
+
logger.info(`✅ refresh_tokens reconciled for session-scoped rotation: ${qualified}`);
|
|
21377
22419
|
} catch (perTableError) {
|
|
21378
22420
|
logger.warn(`⚠️ refresh_tokens reconcile failed for ${qualified}: ${perTableError instanceof Error ? perTableError.message : String(perTableError)}`);
|
|
21379
22421
|
}
|
|
21380
22422
|
}
|
|
21381
22423
|
} catch (migrationError) {
|
|
21382
|
-
logger.warn(`⚠️ refresh_tokens
|
|
22424
|
+
logger.warn(`⚠️ refresh_tokens session migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);
|
|
21383
22425
|
}
|
|
21384
22426
|
try {
|
|
21385
22427
|
if ((await db.execute(sql`
|
|
@@ -21871,34 +22913,31 @@ var UserService = class {
|
|
|
21871
22913
|
var RefreshTokenService = class {
|
|
21872
22914
|
db;
|
|
21873
22915
|
refreshTokensTable;
|
|
22916
|
+
usersTable;
|
|
21874
22917
|
constructor(db, tableOrTables) {
|
|
21875
22918
|
this.db = db;
|
|
21876
|
-
if (tableOrTables && (tableOrTables.refreshTokens || tableOrTables.users))
|
|
21877
|
-
|
|
22919
|
+
if (tableOrTables && (tableOrTables.refreshTokens || tableOrTables.users)) {
|
|
22920
|
+
this.refreshTokensTable = tableOrTables.refreshTokens || refreshTokens;
|
|
22921
|
+
this.usersTable = tableOrTables.users || users;
|
|
22922
|
+
} else {
|
|
22923
|
+
this.refreshTokensTable = tableOrTables || refreshTokens;
|
|
22924
|
+
this.usersTable = users;
|
|
22925
|
+
}
|
|
21878
22926
|
}
|
|
21879
|
-
|
|
21880
|
-
|
|
21881
|
-
|
|
21882
|
-
|
|
21883
|
-
|
|
21884
|
-
|
|
21885
|
-
|
|
21886
|
-
userAgent: safeUserAgent,
|
|
21887
|
-
ipAddress: safeIpAddress
|
|
21888
|
-
}).onConflictDoUpdate({
|
|
21889
|
-
target: [
|
|
21890
|
-
this.refreshTokensTable.uid,
|
|
21891
|
-
this.refreshTokensTable.userAgent,
|
|
21892
|
-
this.refreshTokensTable.ipAddress
|
|
21893
|
-
],
|
|
21894
|
-
set: {
|
|
21895
|
-
tokenHash,
|
|
21896
|
-
expiresAt
|
|
21897
|
-
}
|
|
21898
|
-
});
|
|
22927
|
+
/**
|
|
22928
|
+
* Whether the table actually carries a column, so a host application that
|
|
22929
|
+
* supplied its own `refresh_tokens` table — one that predates session
|
|
22930
|
+
* grouping — degrades instead of throwing on every sign-in.
|
|
22931
|
+
*/
|
|
22932
|
+
has(column) {
|
|
22933
|
+
return Boolean(this.refreshTokensTable[column]);
|
|
21899
22934
|
}
|
|
21900
|
-
|
|
21901
|
-
|
|
22935
|
+
col(column) {
|
|
22936
|
+
return this.refreshTokensTable[column];
|
|
22937
|
+
}
|
|
22938
|
+
/** The columns to read back, narrowed to the ones this table has. */
|
|
22939
|
+
selection() {
|
|
22940
|
+
const selection = {
|
|
21902
22941
|
id: this.refreshTokensTable.id,
|
|
21903
22942
|
uid: this.refreshTokensTable.uid,
|
|
21904
22943
|
tokenHash: this.refreshTokensTable.tokenHash,
|
|
@@ -21906,9 +22945,94 @@ var RefreshTokenService = class {
|
|
|
21906
22945
|
createdAt: this.refreshTokensTable.createdAt,
|
|
21907
22946
|
userAgent: this.refreshTokensTable.userAgent,
|
|
21908
22947
|
ipAddress: this.refreshTokensTable.ipAddress
|
|
21909
|
-
}
|
|
22948
|
+
};
|
|
22949
|
+
for (const optional of [
|
|
22950
|
+
"sessionId",
|
|
22951
|
+
"rotatedAt",
|
|
22952
|
+
"revoked",
|
|
22953
|
+
"sessionStartedAt"
|
|
22954
|
+
]) if (this.has(optional)) selection[optional] = this.col(optional);
|
|
22955
|
+
return selection;
|
|
22956
|
+
}
|
|
22957
|
+
async createToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session) {
|
|
22958
|
+
const values = {
|
|
22959
|
+
uid,
|
|
22960
|
+
tokenHash,
|
|
22961
|
+
expiresAt,
|
|
22962
|
+
userAgent: userAgent || "",
|
|
22963
|
+
ipAddress: ipAddress || ""
|
|
22964
|
+
};
|
|
22965
|
+
if (session && this.has("sessionId")) values.sessionId = session.id;
|
|
22966
|
+
if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
|
|
22967
|
+
await this.db.insert(this.refreshTokensTable).values(values);
|
|
22968
|
+
}
|
|
22969
|
+
async findByHash(tokenHash) {
|
|
22970
|
+
const [token] = await this.db.select(this.selection()).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
|
|
21910
22971
|
return token || null;
|
|
21911
22972
|
}
|
|
22973
|
+
/**
|
|
22974
|
+
* Record that a token was rotated away, keeping the row.
|
|
22975
|
+
*
|
|
22976
|
+
* The row is what lets `/auth/refresh` distinguish "you already used this,
|
|
22977
|
+
* here is a fresh one" from "no idea what this is". Deleting it — which is
|
|
22978
|
+
* what this used to do — collapsed both into a 401 and signed the user out
|
|
22979
|
+
* for the crime of losing a response.
|
|
22980
|
+
*/
|
|
22981
|
+
async markRotated(tokenHash) {
|
|
22982
|
+
if (!this.has("rotatedAt")) {
|
|
22983
|
+
await this.deleteByHash(tokenHash);
|
|
22984
|
+
return;
|
|
22985
|
+
}
|
|
22986
|
+
await this.db.update(this.refreshTokensTable).set({ rotatedAt: /* @__PURE__ */ new Date() }).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
|
|
22987
|
+
}
|
|
22988
|
+
/** Final kill of one sign-in: logout, or revoking a device remotely. */
|
|
22989
|
+
async revokeSession(sessionId) {
|
|
22990
|
+
if (!this.has("sessionId")) return;
|
|
22991
|
+
if (this.has("revoked")) {
|
|
22992
|
+
await this.db.update(this.refreshTokensTable).set({
|
|
22993
|
+
revoked: true,
|
|
22994
|
+
...this.has("rotatedAt") ? { rotatedAt: /* @__PURE__ */ new Date() } : {}
|
|
22995
|
+
}).where(eq(this.col("sessionId"), sessionId));
|
|
22996
|
+
return;
|
|
22997
|
+
}
|
|
22998
|
+
await this.db.delete(this.refreshTokensTable).where(eq(this.col("sessionId"), sessionId));
|
|
22999
|
+
}
|
|
23000
|
+
/**
|
|
23001
|
+
* Housekeeping: rotation would otherwise leave a row per refresh forever.
|
|
23002
|
+
* Superseded rows are only needed for as long as a straggler might still
|
|
23003
|
+
* present them, and expired ones are dead weight everywhere.
|
|
23004
|
+
*/
|
|
23005
|
+
async prune(uid, sessionId, supersededBefore) {
|
|
23006
|
+
const uidCol = this.refreshTokensTable.uid;
|
|
23007
|
+
const expiresCol = this.refreshTokensTable.expiresAt;
|
|
23008
|
+
if (!this.has("rotatedAt") || !this.has("sessionId")) {
|
|
23009
|
+
await this.db.delete(this.refreshTokensTable).where(sql`${uidCol} = ${uid} AND ${expiresCol} < NOW()`);
|
|
23010
|
+
return;
|
|
23011
|
+
}
|
|
23012
|
+
const rotatedCol = this.col("rotatedAt");
|
|
23013
|
+
const sessionCol = this.col("sessionId");
|
|
23014
|
+
await this.db.delete(this.refreshTokensTable).where(sql`
|
|
23015
|
+
${uidCol} = ${uid}
|
|
23016
|
+
AND (
|
|
23017
|
+
${expiresCol} < NOW()
|
|
23018
|
+
OR (
|
|
23019
|
+
${sessionCol} = ${sessionId}
|
|
23020
|
+
AND ${rotatedCol} IS NOT NULL
|
|
23021
|
+
AND ${rotatedCol} < ${supersededBefore}
|
|
23022
|
+
)
|
|
23023
|
+
)
|
|
23024
|
+
`);
|
|
23025
|
+
}
|
|
23026
|
+
async getTokensValidAfter(uid) {
|
|
23027
|
+
if (!this.usersTable || !this.usersTable.tokensValidAfter) return null;
|
|
23028
|
+
const [row] = await this.db.select({ tokensValidAfter: this.usersTable.tokensValidAfter }).from(this.usersTable).where(eq(this.usersTable.id, uid));
|
|
23029
|
+
const value = row?.tokensValidAfter;
|
|
23030
|
+
return value ? new Date(value) : null;
|
|
23031
|
+
}
|
|
23032
|
+
async setTokensValidAfter(uid, at) {
|
|
23033
|
+
if (!this.usersTable || !this.usersTable.tokensValidAfter) return;
|
|
23034
|
+
await this.db.update(this.usersTable).set({ tokensValidAfter: at }).where(eq(this.usersTable.id, uid));
|
|
23035
|
+
}
|
|
21912
23036
|
async deleteByHash(tokenHash) {
|
|
21913
23037
|
await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
|
|
21914
23038
|
}
|
|
@@ -21916,15 +23040,7 @@ var RefreshTokenService = class {
|
|
|
21916
23040
|
await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid));
|
|
21917
23041
|
}
|
|
21918
23042
|
async listForUser(uid) {
|
|
21919
|
-
return await this.db.select(
|
|
21920
|
-
id: this.refreshTokensTable.id,
|
|
21921
|
-
uid: this.refreshTokensTable.uid,
|
|
21922
|
-
tokenHash: this.refreshTokensTable.tokenHash,
|
|
21923
|
-
expiresAt: this.refreshTokensTable.expiresAt,
|
|
21924
|
-
createdAt: this.refreshTokensTable.createdAt,
|
|
21925
|
-
userAgent: this.refreshTokensTable.userAgent,
|
|
21926
|
-
ipAddress: this.refreshTokensTable.ipAddress
|
|
21927
|
-
}).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid)).orderBy(this.refreshTokensTable.createdAt);
|
|
23043
|
+
return await this.db.select(this.selection()).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid)).orderBy(this.refreshTokensTable.createdAt);
|
|
21928
23044
|
}
|
|
21929
23045
|
async deleteById(id, uid) {
|
|
21930
23046
|
await this.db.delete(this.refreshTokensTable).where(sql`${this.refreshTokensTable.id} = ${id} AND ${this.refreshTokensTable.uid} = ${uid}`);
|
|
@@ -22069,8 +23185,23 @@ var PostgresTokenRepository = class {
|
|
|
22069
23185
|
this.passwordResetTokenService = new PasswordResetTokenService(db, tableOrTables);
|
|
22070
23186
|
this.magicLinkTokenService = new MagicLinkTokenService(db, tableOrTables);
|
|
22071
23187
|
}
|
|
22072
|
-
async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress) {
|
|
22073
|
-
await this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress);
|
|
23188
|
+
async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session) {
|
|
23189
|
+
await this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
|
|
23190
|
+
}
|
|
23191
|
+
async markRefreshTokenRotated(tokenHash) {
|
|
23192
|
+
await this.refreshTokenService.markRotated(tokenHash);
|
|
23193
|
+
}
|
|
23194
|
+
async revokeRefreshTokenSession(sessionId) {
|
|
23195
|
+
await this.refreshTokenService.revokeSession(sessionId);
|
|
23196
|
+
}
|
|
23197
|
+
async pruneRefreshTokens(uid, sessionId, supersededBefore) {
|
|
23198
|
+
await this.refreshTokenService.prune(uid, sessionId, supersededBefore);
|
|
23199
|
+
}
|
|
23200
|
+
async getTokensValidAfter(uid) {
|
|
23201
|
+
return this.refreshTokenService.getTokensValidAfter(uid);
|
|
23202
|
+
}
|
|
23203
|
+
async setTokensValidAfter(uid, at) {
|
|
23204
|
+
await this.refreshTokenService.setTokensValidAfter(uid, at);
|
|
22074
23205
|
}
|
|
22075
23206
|
async findRefreshTokenByHash(tokenHash) {
|
|
22076
23207
|
return this.refreshTokenService.findByHash(tokenHash);
|
|
@@ -22236,8 +23367,23 @@ var PostgresAuthRepository = class {
|
|
|
22236
23367
|
};
|
|
22237
23368
|
}
|
|
22238
23369
|
async deleteRole(_id) {}
|
|
22239
|
-
async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress) {
|
|
22240
|
-
await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress);
|
|
23370
|
+
async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session) {
|
|
23371
|
+
await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
|
|
23372
|
+
}
|
|
23373
|
+
async markRefreshTokenRotated(tokenHash) {
|
|
23374
|
+
await this.tokenRepository.markRefreshTokenRotated(tokenHash);
|
|
23375
|
+
}
|
|
23376
|
+
async revokeRefreshTokenSession(sessionId) {
|
|
23377
|
+
await this.tokenRepository.revokeRefreshTokenSession(sessionId);
|
|
23378
|
+
}
|
|
23379
|
+
async pruneRefreshTokens(uid, sessionId, supersededBefore) {
|
|
23380
|
+
await this.tokenRepository.pruneRefreshTokens(uid, sessionId, supersededBefore);
|
|
23381
|
+
}
|
|
23382
|
+
async getTokensValidAfter(uid) {
|
|
23383
|
+
return this.tokenRepository.getTokensValidAfter(uid);
|
|
23384
|
+
}
|
|
23385
|
+
async setTokensValidAfter(uid, at) {
|
|
23386
|
+
await this.tokenRepository.setTokensValidAfter(uid, at);
|
|
22241
23387
|
}
|
|
22242
23388
|
async findRefreshTokenByHash(tokenHash) {
|
|
22243
23389
|
return this.tokenRepository.findRefreshTokenByHash(tokenHash);
|
|
@@ -23322,6 +24468,15 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
23322
24468
|
logger.warn("⚠️ Could not initialize channel history tables — retained channels will not replay", { error: err });
|
|
23323
24469
|
}
|
|
23324
24470
|
const directUrl = process.env.DATABASE_DIRECT_URL || pgConfig.connectionString;
|
|
24471
|
+
try {
|
|
24472
|
+
const busSetting = resolveChannelBusSetting(pgConfig.realtime?.bus);
|
|
24473
|
+
if (isChannelBusInstance(busSetting) || busSetting.type !== "memory") await realtimeService.configureChannelBus(createChannelBus(busSetting, {
|
|
24474
|
+
db: schemaAwareDb,
|
|
24475
|
+
directUrl
|
|
24476
|
+
}));
|
|
24477
|
+
} catch (err) {
|
|
24478
|
+
logger.warn("⚠️ [ChannelBus] Could not configure the channel bus — channel broadcast and presence stay per-instance", { error: err });
|
|
24479
|
+
}
|
|
23325
24480
|
const validModes = new Set([
|
|
23326
24481
|
"auto",
|
|
23327
24482
|
"wal",
|
|
@@ -23516,6 +24671,6 @@ function createPostgresAdapter(pgConfig) {
|
|
|
23516
24671
|
};
|
|
23517
24672
|
}
|
|
23518
24673
|
//#endregion
|
|
23519
|
-
export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgRestoreArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, guardPoolAgainstDirtyRelease, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, withDatabaseName };
|
|
24674
|
+
export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, CHANNEL_BUS_NOTIFY_CHANNEL, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, frameByteLength, generateSchema, getServerVersionMajor, globalsFileForDump, guardPoolAgainstDirtyRelease, isChannelBusInstance, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseChannelBusFrame, parseChannelBusPayload, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveChannelBusConfig, resolveChannelBusSetting, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
|
|
23520
24675
|
|
|
23521
24676
|
//# sourceMappingURL=index.es.js.map
|