@rebasepro/server-postgres 0.14.0 → 0.14.1
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/PostgresBackendDriver.d.ts +1 -1
- package/dist/PostgresBootstrapper.d.ts +19 -0
- package/dist/auth/services.d.ts +10 -0
- package/dist/{auth-users-columns-BfQHf9JE.js → auth-users-columns-C-FDnL_e.js} +245 -15
- package/dist/auth-users-columns-C-FDnL_e.js.map +1 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -1
- package/dist/{ensure-collection-policies-8vuu-n4r.js → ensure-collection-policies-DoHwhVf8.js} +3 -3
- package/dist/{ensure-collection-policies-8vuu-n4r.js.map → ensure-collection-policies-DoHwhVf8.js.map} +1 -1
- package/dist/{ensure-collection-tables-CbvaGuVn.js → ensure-collection-tables-DT2eq859.js} +45 -5
- package/dist/{ensure-collection-tables-CbvaGuVn.js.map → ensure-collection-tables-DT2eq859.js.map} +1 -1
- package/dist/index.es.js +543 -105
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-enforcement-BJ_3wxwg.js → rls-enforcement-gUNDfm7l.js} +2 -2
- package/dist/{rls-enforcement-BJ_3wxwg.js.map → rls-enforcement-gUNDfm7l.js.map} +1 -1
- package/dist/schema/drizzle-ddl.d.ts +9 -0
- package/dist/services/FetchService.d.ts +81 -5
- package/dist/services/RelationService.d.ts +3 -3
- package/dist/services/channel-presence.d.ts +16 -1
- package/dist/services/dataService.d.ts +6 -4
- package/dist/services/realtimeService.d.ts +54 -10
- package/dist/src-DCdn3Val.js.map +1 -1
- package/dist/utils/drizzle-conditions.d.ts +25 -0
- package/dist/{websocket-C8ZqVBiV.js → websocket-D2jXv0Ds.js} +29 -2
- package/dist/websocket-D2jXv0Ds.js.map +1 -0
- package/package.json +6 -6
- package/src/PostgresBackendDriver.ts +7 -3
- package/src/PostgresBootstrapper.ts +105 -41
- package/src/auth/services.ts +26 -5
- package/src/schema/drizzle-ddl.ts +33 -0
- package/src/schema/ensure-collection-tables.test.ts +99 -0
- package/src/schema/ensure-collection-tables.ts +65 -3
- package/src/schema/generate-drizzle-schema-logic.ts +19 -1
- package/src/services/FetchService.ts +310 -63
- package/src/services/RelationService.ts +3 -3
- package/src/services/channel-history.ts +38 -6
- package/src/services/channel-presence.ts +31 -7
- package/src/services/dataService.ts +6 -4
- package/src/services/pg-notify-listener.ts +14 -0
- package/src/services/realtimeService.ts +161 -41
- package/src/utils/drizzle-conditions.ts +155 -5
- package/src/websocket.ts +44 -1
- package/dist/auth-users-columns-BfQHf9JE.js.map +0 -1
- package/dist/websocket-C8ZqVBiV.js.map +0 -1
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { sql } from "drizzle-orm";
|
|
30
30
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
31
31
|
import { revokeInternalTableSql } from "@rebasepro/common";
|
|
32
|
+
import { drizzleDdlBootstrapper } from "../schema/drizzle-ddl";
|
|
32
33
|
|
|
33
34
|
/** A tracked client, as any instance sees it. */
|
|
34
35
|
export interface PresenceRow {
|
|
@@ -45,17 +46,34 @@ export class ChannelPresenceStore {
|
|
|
45
46
|
private readonly instanceId: string
|
|
46
47
|
) {}
|
|
47
48
|
|
|
48
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Create the roster table. Idempotent, and safe to run on every instance at
|
|
51
|
+
* once.
|
|
52
|
+
*
|
|
53
|
+
* Written as separate contained steps rather than one straight sequence for
|
|
54
|
+
* a reason that only bites with more than one replica, which is exactly the
|
|
55
|
+
* deployment shape this table exists to serve: `CREATE … IF NOT EXISTS`
|
|
56
|
+
* reads the catalog and then writes to it non-atomically, so peers booting
|
|
57
|
+
* together collide, and the loser used to abandon everything after it —
|
|
58
|
+
* including the trailing `REVOKE`. That revoke is the only thing keeping the
|
|
59
|
+
* roster off the end-user role, so losing a boot race silently left the
|
|
60
|
+
* whole channel roster readable by every signed-in user.
|
|
61
|
+
*
|
|
62
|
+
* `tablesReady` is now set from a probe of what exists, not from having been
|
|
63
|
+
* the instance that created it.
|
|
64
|
+
*/
|
|
49
65
|
async ensureTables(): Promise<void> {
|
|
50
66
|
if (this.tablesReady) return;
|
|
51
67
|
|
|
52
|
-
|
|
68
|
+
const ddl = drizzleDdlBootstrapper(this.db, "channel-presence");
|
|
69
|
+
|
|
70
|
+
await ddl.ensureObject("rebase schema", "CREATE SCHEMA IF NOT EXISTS rebase");
|
|
53
71
|
|
|
54
72
|
// Keyed by (channel, client_id): a client id is globally unique, so the
|
|
55
73
|
// instance is a column rather than part of the identity — a client that
|
|
56
74
|
// reconnects onto another replica replaces its own row instead of
|
|
57
75
|
// appearing twice in the roster.
|
|
58
|
-
await
|
|
76
|
+
await ddl.ensureObject("channel_presence table", `
|
|
59
77
|
CREATE TABLE IF NOT EXISTS rebase.channel_presence (
|
|
60
78
|
channel TEXT NOT NULL,
|
|
61
79
|
client_id TEXT NOT NULL,
|
|
@@ -67,7 +85,7 @@ export class ChannelPresenceStore {
|
|
|
67
85
|
`);
|
|
68
86
|
|
|
69
87
|
// The sweep's access path; the roster read rides the primary key.
|
|
70
|
-
await
|
|
88
|
+
await ddl.ensureObject("channel_presence last_seen index", `
|
|
71
89
|
CREATE INDEX IF NOT EXISTS idx_channel_presence_last_seen
|
|
72
90
|
ON rebase.channel_presence (last_seen)
|
|
73
91
|
`);
|
|
@@ -82,9 +100,15 @@ export class ChannelPresenceStore {
|
|
|
82
100
|
// see `docs/channel-authorization.md` for what it does *not*
|
|
83
101
|
// yet decide. Revoke the schema-wide grant the driver handed out
|
|
84
102
|
// before this table existed.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
this
|
|
103
|
+
//
|
|
104
|
+
// Driven off the probe, not off who won the create: the privilege has to
|
|
105
|
+
// come off whether this instance created the table or found it.
|
|
106
|
+
if (await ddl.isReadable("rebase.channel_presence")) {
|
|
107
|
+
await ddl.step("channel_presence revoke", () =>
|
|
108
|
+
this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_presence")))
|
|
109
|
+
);
|
|
110
|
+
this.tablesReady = true;
|
|
111
|
+
}
|
|
88
112
|
}
|
|
89
113
|
|
|
90
114
|
/** Record (or refresh) a client's presence. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
2
|
-
import { FilterValues, LogicalCondition } from "@rebasepro/types";
|
|
2
|
+
import { FilterValues, LogicalCondition, OrderByTuple } from "@rebasepro/types";
|
|
3
3
|
import type { VectorSearchParams } from "@rebasepro/types";
|
|
4
4
|
import { FetchService } from "./FetchService";
|
|
5
5
|
import { PersistService } from "./PersistService";
|
|
@@ -62,7 +62,7 @@ export class DataService implements DataRepository {
|
|
|
62
62
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
63
63
|
/** An `or(...)`/`and(...)` group, applied alongside `filter`. */
|
|
64
64
|
logical?: LogicalCondition;
|
|
65
|
-
orderBy?: string;
|
|
65
|
+
orderBy?: string | OrderByTuple[];
|
|
66
66
|
order?: "desc" | "asc";
|
|
67
67
|
limit?: number;
|
|
68
68
|
offset?: number;
|
|
@@ -85,7 +85,7 @@ export class DataService implements DataRepository {
|
|
|
85
85
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
86
86
|
/** An `or(...)`/`and(...)` group, applied alongside `filter`. */
|
|
87
87
|
logical?: LogicalCondition;
|
|
88
|
-
orderBy?: string;
|
|
88
|
+
orderBy?: string | OrderByTuple[];
|
|
89
89
|
order?: "desc" | "asc";
|
|
90
90
|
limit?: number;
|
|
91
91
|
databaseId?: string;
|
|
@@ -106,6 +106,8 @@ export class DataService implements DataRepository {
|
|
|
106
106
|
logical?: LogicalCondition;
|
|
107
107
|
searchString?: string;
|
|
108
108
|
databaseId?: string;
|
|
109
|
+
/** Only the `threshold` narrows the count — see `FetchService.count`. */
|
|
110
|
+
vectorSearch?: VectorSearchParams;
|
|
109
111
|
} = {}
|
|
110
112
|
): Promise<number> {
|
|
111
113
|
return this.fetchService.count<M>(collectionPath, options);
|
|
@@ -133,7 +135,7 @@ export class DataService implements DataRepository {
|
|
|
133
135
|
relationKey: string,
|
|
134
136
|
options: {
|
|
135
137
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
136
|
-
orderBy?: string;
|
|
138
|
+
orderBy?: string | OrderByTuple[];
|
|
137
139
|
order?: "desc" | "asc";
|
|
138
140
|
limit?: number;
|
|
139
141
|
startAfter?: Record<string, unknown>;
|
|
@@ -84,8 +84,16 @@ export class PgNotifyListener {
|
|
|
84
84
|
|
|
85
85
|
private async connect({ initial = false }: { initial?: boolean } = {}): Promise<void> {
|
|
86
86
|
const { connectionString, channel, onPayload, logLabel } = this.options;
|
|
87
|
+
// Held here rather than only inside the `try` so the failure path can
|
|
88
|
+
// still reach it: everything below `connect()` can throw, and until
|
|
89
|
+
// `this.client` is assigned nothing else in this class knows the
|
|
90
|
+
// connection exists. Left unreleased it stays open on the server while
|
|
91
|
+
// `scheduleReconnect` opens another — one leaked backend per attempt,
|
|
92
|
+
// every few seconds, for as long as the failure lasts.
|
|
93
|
+
let pending: PgClient | undefined;
|
|
87
94
|
try {
|
|
88
95
|
const client = new PgClient({ connectionString });
|
|
96
|
+
pending = client;
|
|
89
97
|
|
|
90
98
|
client.on("error", (err) => {
|
|
91
99
|
logger.error(`❌ ${logLabel} LISTEN client error`, { detail: err.message });
|
|
@@ -111,8 +119,14 @@ export class PgNotifyListener {
|
|
|
111
119
|
await client.connect();
|
|
112
120
|
await client.query(`LISTEN ${channel}`);
|
|
113
121
|
this.client = client;
|
|
122
|
+
// Adopted: `stop()` and `scheduleReconnect` will close it now.
|
|
123
|
+
pending = undefined;
|
|
114
124
|
logger.debug(`📡 ${logLabel} Listening on channel "${channel}".`);
|
|
115
125
|
} catch (err) {
|
|
126
|
+
// Never adopted, so nothing else will ever close it.
|
|
127
|
+
if (pending) {
|
|
128
|
+
try { await pending.end(); } catch { /* already dead */ }
|
|
129
|
+
}
|
|
116
130
|
// Surface the initial failure so callers can choose to fall back;
|
|
117
131
|
// for reconnects, keep retrying quietly in the background.
|
|
118
132
|
if (initial) throw err;
|
|
@@ -4,12 +4,12 @@ import { Client as PgClient } from "pg";
|
|
|
4
4
|
import { randomUUID } from "crypto";
|
|
5
5
|
import { DataService } from "./dataService";
|
|
6
6
|
|
|
7
|
-
import { ANONYMOUS_USER_ID, FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, LogicalCondition, CollectionConfig, RebaseCallContext, resolveClientListLimit, ListLimitError } from "@rebasepro/types";
|
|
7
|
+
import { ANONYMOUS_USER_ID, FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, LogicalCondition, OrderByTuple, CollectionConfig, RebaseCallContext, resolveClientListLimit, ListLimitError } from "@rebasepro/types";
|
|
8
8
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
9
9
|
import { sql as drizzleSql } from "drizzle-orm";
|
|
10
10
|
import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
|
|
11
11
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
12
|
-
import { buildPropertyCallbacks, getTableName } from "@rebasepro/common";
|
|
12
|
+
import { buildPropertyCallbacks, getTableName, OrderBySpecError, parseOrderBySpecStrict } from "@rebasepro/common";
|
|
13
13
|
import { applyAuthContext } from "../security/rls-enforcement";
|
|
14
14
|
import { buildJunctionLinkMap, type JunctionLink } from "./cdc/junction-tables";
|
|
15
15
|
import { logger } from "@rebasepro/server";
|
|
@@ -85,7 +85,7 @@ type RealTimeListenCollectionProps = ListenCollectionProps & {
|
|
|
85
85
|
type StoredCollectionRequest = {
|
|
86
86
|
filter?: Record<string, unknown>;
|
|
87
87
|
logical?: LogicalCondition;
|
|
88
|
-
orderBy?: string;
|
|
88
|
+
orderBy?: string | OrderByTuple[];
|
|
89
89
|
order?: "desc" | "asc";
|
|
90
90
|
limit?: number;
|
|
91
91
|
offset?: number;
|
|
@@ -98,6 +98,42 @@ type StoredCollectionRequest = {
|
|
|
98
98
|
|
|
99
99
|
type RealTimeListenEntityProps = ListenOneProps & { subscriptionId: string };
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* A registered subscription, plus the two counters that order its deliveries.
|
|
103
|
+
*
|
|
104
|
+
* Every update a subscription delivers is a full re-fetch, and more than one
|
|
105
|
+
* thing starts one for the same subscription without coordinating: the initial
|
|
106
|
+
* fetch at subscribe time, and a debounced refetch per notification (app
|
|
107
|
+
* mutation, cross-instance NOTIFY, or CDC). A fetch that started earlier can
|
|
108
|
+
* finish later, and the delivery replaces everything the subscriber has — so
|
|
109
|
+
* the subscriber goes back to the state before the change and stays there,
|
|
110
|
+
* silently, until the next write to that collection.
|
|
111
|
+
*
|
|
112
|
+
* The debounce is not a fix for this. It collapses a burst into one refetch and
|
|
113
|
+
* does nothing about two refetches that overlap: notification A fires its timer
|
|
114
|
+
* and starts fetch A, notification B arrives while A is still in flight, and B's
|
|
115
|
+
* timer fires and starts fetch B regardless. See class 44 in
|
|
116
|
+
* `docs/bug-classes.md`.
|
|
117
|
+
*
|
|
118
|
+
* `started` is taken before the work, `delivered` after it — which makes the
|
|
119
|
+
* last delivery *started* the last one *delivered*.
|
|
120
|
+
*/
|
|
121
|
+
type Subscription = {
|
|
122
|
+
clientId: string;
|
|
123
|
+
type: "collection" | "single";
|
|
124
|
+
path: string;
|
|
125
|
+
id?: string | number;
|
|
126
|
+
// Store full collection request parameters for proper refetching
|
|
127
|
+
collectionRequest?: StoredCollectionRequest;
|
|
128
|
+
// Auth context for RLS — when set, refetches run in a transaction
|
|
129
|
+
// with set_config('app.uid', ...) / set_config('app.user_roles', ...)
|
|
130
|
+
authContext?: SubscriptionAuthContext;
|
|
131
|
+
/** How many deliveries have been started for this subscription. */
|
|
132
|
+
started: number;
|
|
133
|
+
/** The highest started-sequence that has already reached the subscriber. */
|
|
134
|
+
delivered: number;
|
|
135
|
+
};
|
|
136
|
+
|
|
101
137
|
/**
|
|
102
138
|
* PostgreSQL-specific realtime service.
|
|
103
139
|
* Handles WebSocket connections and subscriptions for real-time row updates.
|
|
@@ -195,17 +231,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
195
231
|
private static readonly PRESENCE_SWEEP_INTERVAL_MS = 10000; // 10s
|
|
196
232
|
private dataService: DataService;
|
|
197
233
|
// Enhanced subscriptions storage with full request parameters
|
|
198
|
-
private _subscriptions = new Map<string,
|
|
199
|
-
clientId: string;
|
|
200
|
-
type: "collection" | "single";
|
|
201
|
-
path: string;
|
|
202
|
-
id?: string | number;
|
|
203
|
-
// Store full collection request parameters for proper refetching
|
|
204
|
-
collectionRequest?: StoredCollectionRequest;
|
|
205
|
-
// Auth context for RLS — when set, refetches run in a transaction
|
|
206
|
-
// with set_config('app.uid', ...) / set_config('app.user_roles', ...)
|
|
207
|
-
authContext?: SubscriptionAuthContext;
|
|
208
|
-
}>();
|
|
234
|
+
private _subscriptions = new Map<string, Subscription>();
|
|
209
235
|
|
|
210
236
|
// Add callback storage for DataDriver subscriptions
|
|
211
237
|
private subscriptionCallbacks = new Map<string, (data: Record<string, unknown>[] | Record<string, unknown> | null) => void>();
|
|
@@ -279,6 +305,34 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
279
305
|
return this._subscriptions;
|
|
280
306
|
}
|
|
281
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Claim a delivery slot for a subscription, before doing the work.
|
|
310
|
+
*
|
|
311
|
+
* Returns the check to run immediately before delivering. It refuses in
|
|
312
|
+
* three cases, all of which used to deliver:
|
|
313
|
+
*
|
|
314
|
+
* - **Out of order.** A newer refetch has already delivered, so this one is
|
|
315
|
+
* stale — the subscriber would go back to the state before the change.
|
|
316
|
+
* - **Unsubscribed.** The subscription was cancelled while the fetch was in
|
|
317
|
+
* flight. The `has(subscriptionId)` check the debounced refetches ran
|
|
318
|
+
* *before* the await cannot answer this; only a check after it can.
|
|
319
|
+
* - **Replaced.** The same id can name a *different* subscription by the
|
|
320
|
+
* time a fetch lands — a re-subscribe overwrites the map entry, and the
|
|
321
|
+
* old filter's rows would be delivered to the new subscriber.
|
|
322
|
+
*
|
|
323
|
+
* The last two are identity, not presence: the map has to still hold *this
|
|
324
|
+
* exact object*, not merely something under this id.
|
|
325
|
+
*/
|
|
326
|
+
private beginDelivery(subscriptionId: string, subscription: Subscription): () => boolean {
|
|
327
|
+
const seq = ++subscription.started;
|
|
328
|
+
return () => {
|
|
329
|
+
if (this._subscriptions.get(subscriptionId) !== subscription) return false;
|
|
330
|
+
if (seq <= subscription.delivered) return false;
|
|
331
|
+
subscription.delivered = seq;
|
|
332
|
+
return true;
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
282
336
|
// Add public method to register DataDriver subscriptions
|
|
283
337
|
registerDataDriverSubscription(subscriptionId: string, subscription: {
|
|
284
338
|
clientId: string;
|
|
@@ -289,7 +343,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
289
343
|
authContext?: SubscriptionAuthContext;
|
|
290
344
|
}) {
|
|
291
345
|
this.debugLog("📋 [RealtimeService] Registering DataDriver subscription:", subscriptionId, subscription.authContext ? "(with auth)" : "(no auth)");
|
|
292
|
-
this._subscriptions.set(subscriptionId, subscription);
|
|
346
|
+
this._subscriptions.set(subscriptionId, { ...subscription, started: 0, delivered: 0 });
|
|
293
347
|
}
|
|
294
348
|
|
|
295
349
|
// Add callback management methods
|
|
@@ -328,7 +382,9 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
328
382
|
databaseId: config.databaseId,
|
|
329
383
|
searchString: config.searchString,
|
|
330
384
|
searchExplain: config.searchExplain
|
|
331
|
-
}
|
|
385
|
+
},
|
|
386
|
+
started: 0,
|
|
387
|
+
delivered: 0
|
|
332
388
|
});
|
|
333
389
|
|
|
334
390
|
if (callback) {
|
|
@@ -348,7 +404,9 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
348
404
|
clientId: config.clientId,
|
|
349
405
|
type: "single",
|
|
350
406
|
path: config.path,
|
|
351
|
-
id: config.id
|
|
407
|
+
id: config.id,
|
|
408
|
+
started: 0,
|
|
409
|
+
delivered: 0
|
|
352
410
|
});
|
|
353
411
|
|
|
354
412
|
if (callback) {
|
|
@@ -507,15 +565,31 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
507
565
|
return;
|
|
508
566
|
}
|
|
509
567
|
|
|
568
|
+
// The sort arrives as whatever JSON the client put in the frame, so
|
|
569
|
+
// its *shape* is checked here the way the REST ingress checks the
|
|
570
|
+
// query parameter. Unchecked, a malformed entry reads as a field
|
|
571
|
+
// name that resolves to no column, and under lenient unknown-field
|
|
572
|
+
// handling the subscription then streams rows in no order at all
|
|
573
|
+
// while reporting nothing wrong.
|
|
574
|
+
let orderBy: OrderByTuple[] | undefined;
|
|
575
|
+
try {
|
|
576
|
+
orderBy = parseOrderBySpecStrict(request.orderBy, request.order);
|
|
577
|
+
} catch (e) {
|
|
578
|
+
if (!(e instanceof OrderBySpecError)) throw e;
|
|
579
|
+
logger.warn(`[RealtimeService] Refused subscription to '${request.path}': ${e.message}`);
|
|
580
|
+
this.sendError(clientId, e.message, subscriptionId, e.code);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
510
584
|
// Store subscription with full request parameters and auth context for RLS
|
|
511
|
-
|
|
585
|
+
const subscription: Subscription = {
|
|
512
586
|
clientId,
|
|
513
587
|
type: "collection",
|
|
514
588
|
path: request.path,
|
|
515
589
|
collectionRequest: {
|
|
516
590
|
filter: request.filter,
|
|
517
591
|
logical: request.logical,
|
|
518
|
-
orderBy
|
|
592
|
+
orderBy,
|
|
519
593
|
order: request.order,
|
|
520
594
|
limit: boundedLimit,
|
|
521
595
|
offset: request.offset,
|
|
@@ -524,19 +598,30 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
524
598
|
searchString: request.searchString,
|
|
525
599
|
searchExplain: request.searchExplain
|
|
526
600
|
},
|
|
527
|
-
authContext
|
|
528
|
-
|
|
601
|
+
authContext,
|
|
602
|
+
started: 0,
|
|
603
|
+
delivered: 0
|
|
604
|
+
};
|
|
605
|
+
this._subscriptions.set(subscriptionId, subscription);
|
|
606
|
+
|
|
607
|
+
// The subscription is registered before this fetch runs, so a write
|
|
608
|
+
// arriving in that window starts a refetch of its own — with nothing
|
|
609
|
+
// ordering the two. Claim a slot first: this fetch is the oldest, so
|
|
610
|
+
// if the refetch answers first, this one no longer delivers.
|
|
611
|
+
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
529
612
|
|
|
530
613
|
// Send initial data. Built from the request the subscription just
|
|
531
614
|
// stored, so the first answer and every refetch after it cannot
|
|
532
615
|
// describe different queries.
|
|
533
616
|
const rows = await this.fetchCollectionWithAuth(
|
|
534
617
|
request.path,
|
|
535
|
-
|
|
618
|
+
subscription.collectionRequest!,
|
|
536
619
|
authContext
|
|
537
620
|
);
|
|
538
621
|
|
|
539
|
-
|
|
622
|
+
if (canDeliver()) {
|
|
623
|
+
this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
|
|
624
|
+
}
|
|
540
625
|
|
|
541
626
|
} catch (error) {
|
|
542
627
|
const sanitized = sanitizeErrorForClient(error, request.path);
|
|
@@ -559,13 +644,21 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
559
644
|
}
|
|
560
645
|
|
|
561
646
|
// Store subscription in memory with auth context for RLS
|
|
562
|
-
|
|
647
|
+
const subscription: Subscription = {
|
|
563
648
|
clientId,
|
|
564
649
|
type: "single",
|
|
565
650
|
path: request.path,
|
|
566
651
|
id: request.id,
|
|
567
|
-
authContext
|
|
568
|
-
|
|
652
|
+
authContext,
|
|
653
|
+
started: 0,
|
|
654
|
+
delivered: 0
|
|
655
|
+
};
|
|
656
|
+
this._subscriptions.set(subscriptionId, subscription);
|
|
657
|
+
|
|
658
|
+
// Same race as the collection case: a write landing between the
|
|
659
|
+
// registration above and this fetch starts a refetch that can answer
|
|
660
|
+
// first, and this one must not overwrite it afterwards.
|
|
661
|
+
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
569
662
|
|
|
570
663
|
// Send initial data
|
|
571
664
|
const row = await this.fetchEntityWithAuth(
|
|
@@ -574,7 +667,9 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
574
667
|
authContext
|
|
575
668
|
);
|
|
576
669
|
|
|
577
|
-
|
|
670
|
+
if (canDeliver()) {
|
|
671
|
+
this.sendSingleUpdate(clientId, subscriptionId, row || null);
|
|
672
|
+
}
|
|
578
673
|
|
|
579
674
|
} catch (error) {
|
|
580
675
|
const sanitized = sanitizeErrorForClient(error, request.path);
|
|
@@ -757,7 +852,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
757
852
|
private debouncedCollectionRefetch(
|
|
758
853
|
subscriptionId: string,
|
|
759
854
|
notifyPath: string,
|
|
760
|
-
subscription:
|
|
855
|
+
subscription: Subscription
|
|
761
856
|
) {
|
|
762
857
|
const timerKey = `ws_${subscriptionId}`;
|
|
763
858
|
const existing = this.refetchTimers.get(timerKey);
|
|
@@ -765,11 +860,19 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
765
860
|
|
|
766
861
|
this.refetchTimers.set(timerKey, setTimeout(async () => {
|
|
767
862
|
this.refetchTimers.delete(timerKey);
|
|
768
|
-
//
|
|
769
|
-
|
|
863
|
+
// Cheap bail before spending a query: the client may have
|
|
864
|
+
// disconnected, or re-subscribed under the same id. It is only an
|
|
865
|
+
// optimisation — `canDeliver()` after the await is what makes the
|
|
866
|
+
// delivery safe, because the same things can happen *during* it.
|
|
867
|
+
if (this._subscriptions.get(subscriptionId) !== subscription) return;
|
|
868
|
+
// Claimed here rather than when the timer was scheduled: the
|
|
869
|
+
// debounce coalesces, and no work exists to order until it fires.
|
|
870
|
+
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
770
871
|
try {
|
|
771
872
|
const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest!, subscription.authContext);
|
|
772
|
-
|
|
873
|
+
if (canDeliver()) {
|
|
874
|
+
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
|
|
875
|
+
}
|
|
773
876
|
} catch (error) {
|
|
774
877
|
const sanitized = sanitizeErrorForClient(error, notifyPath);
|
|
775
878
|
this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -783,7 +886,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
783
886
|
private debouncedDriverRefetch(
|
|
784
887
|
subscriptionId: string,
|
|
785
888
|
notifyPath: string,
|
|
786
|
-
subscription:
|
|
889
|
+
subscription: Subscription,
|
|
787
890
|
callback: (data: Record<string, unknown>[] | Record<string, unknown> | null) => void
|
|
788
891
|
) {
|
|
789
892
|
const timerKey = `drv_${subscriptionId}`;
|
|
@@ -792,10 +895,11 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
792
895
|
|
|
793
896
|
this.refetchTimers.set(timerKey, setTimeout(async () => {
|
|
794
897
|
this.refetchTimers.delete(timerKey);
|
|
795
|
-
if (
|
|
898
|
+
if (this._subscriptions.get(subscriptionId) !== subscription) return;
|
|
899
|
+
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
796
900
|
try {
|
|
797
901
|
const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest!, subscription.authContext);
|
|
798
|
-
callback(rows);
|
|
902
|
+
if (canDeliver()) callback(rows);
|
|
799
903
|
} catch (error) {
|
|
800
904
|
logger.error(`❌ [RealtimeService] Error in debounced driver refetch for ${subscriptionId}`, { error: error });
|
|
801
905
|
}
|
|
@@ -960,7 +1064,7 @@ roles: activeAuth.roles },
|
|
|
960
1064
|
subscriptionId: string,
|
|
961
1065
|
notifyPath: string,
|
|
962
1066
|
id: string,
|
|
963
|
-
subscription:
|
|
1067
|
+
subscription: Subscription
|
|
964
1068
|
) {
|
|
965
1069
|
const timerKey = `wse_${subscriptionId}`;
|
|
966
1070
|
const existing = this.refetchTimers.get(timerKey);
|
|
@@ -968,10 +1072,13 @@ roles: activeAuth.roles },
|
|
|
968
1072
|
|
|
969
1073
|
this.refetchTimers.set(timerKey, setTimeout(async () => {
|
|
970
1074
|
this.refetchTimers.delete(timerKey);
|
|
971
|
-
if (
|
|
1075
|
+
if (this._subscriptions.get(subscriptionId) !== subscription) return;
|
|
1076
|
+
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
972
1077
|
try {
|
|
973
1078
|
const row = await this.fetchEntityWithAuth(notifyPath, id, subscription.authContext);
|
|
974
|
-
|
|
1079
|
+
if (canDeliver()) {
|
|
1080
|
+
this.sendSingleUpdate(subscription.clientId, subscriptionId, row || null);
|
|
1081
|
+
}
|
|
975
1082
|
} catch (error) {
|
|
976
1083
|
const sanitized = sanitizeErrorForClient(error, notifyPath);
|
|
977
1084
|
this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -986,7 +1093,7 @@ roles: activeAuth.roles },
|
|
|
986
1093
|
subscriptionId: string,
|
|
987
1094
|
notifyPath: string,
|
|
988
1095
|
id: string,
|
|
989
|
-
subscription:
|
|
1096
|
+
subscription: Subscription,
|
|
990
1097
|
callback: (data: Record<string, unknown>[] | Record<string, unknown> | null) => void
|
|
991
1098
|
) {
|
|
992
1099
|
const timerKey = `drve_${subscriptionId}`;
|
|
@@ -995,10 +1102,11 @@ roles: activeAuth.roles },
|
|
|
995
1102
|
|
|
996
1103
|
this.refetchTimers.set(timerKey, setTimeout(async () => {
|
|
997
1104
|
this.refetchTimers.delete(timerKey);
|
|
998
|
-
if (
|
|
1105
|
+
if (this._subscriptions.get(subscriptionId) !== subscription) return;
|
|
1106
|
+
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
999
1107
|
try {
|
|
1000
1108
|
const row = await this.fetchEntityWithAuth(notifyPath, id, subscription.authContext);
|
|
1001
|
-
callback(row || null);
|
|
1109
|
+
if (canDeliver()) callback(row || null);
|
|
1002
1110
|
} catch (error) {
|
|
1003
1111
|
logger.error(`❌ [RealtimeService] Error in debounced row driver refetch for ${subscriptionId}`, { error: error });
|
|
1004
1112
|
}
|
|
@@ -1362,7 +1470,7 @@ roles: activeAuth.roles },
|
|
|
1362
1470
|
logger.warn(
|
|
1363
1471
|
"⚠️ [ChannelBus] Channels are in use with the in-memory bus, but notifications from another " +
|
|
1364
1472
|
"instance have been seen — this deployment runs more than one process. Broadcast and presence " +
|
|
1365
|
-
"reach only the clients connected to this one. Set `realtime.bus` (or
|
|
1473
|
+
"reach only the clients connected to this one. Set `realtime.bus` (or REALTIME_CHANNEL_BUS=postgres) " +
|
|
1366
1474
|
"to make channels cross-instance."
|
|
1367
1475
|
);
|
|
1368
1476
|
}
|
|
@@ -2296,8 +2404,15 @@ lastSeen: Date.now() });
|
|
|
2296
2404
|
private async connectListenClient(): Promise<void> {
|
|
2297
2405
|
if (!this.listenConnectionString) return;
|
|
2298
2406
|
|
|
2407
|
+
let pending: PgClient | undefined;
|
|
2299
2408
|
try {
|
|
2409
|
+
// See `PgNotifyListener.connect` — same shape, same reason. Until
|
|
2410
|
+
// `this.listenClient` is assigned, nothing else in this class knows
|
|
2411
|
+
// the connection exists, so a throw between `connect()` and that
|
|
2412
|
+
// assignment leaks a live backend and `scheduleReconnect` opens
|
|
2413
|
+
// another one three seconds later.
|
|
2300
2414
|
const client = new PgClient({ connectionString: this.listenConnectionString });
|
|
2415
|
+
pending = client;
|
|
2301
2416
|
|
|
2302
2417
|
client.on("error", (err) => {
|
|
2303
2418
|
logger.error("❌ [RealtimeService] LISTEN client error", { detail: err.message });
|
|
@@ -2365,9 +2480,14 @@ lastSeen: Date.now() });
|
|
|
2365
2480
|
await client.connect();
|
|
2366
2481
|
await client.query(`LISTEN ${PG_NOTIFY_CHANNEL}`);
|
|
2367
2482
|
this.listenClient = client;
|
|
2483
|
+
// Adopted: `destroy()` and `scheduleReconnect` close it now.
|
|
2484
|
+
pending = undefined;
|
|
2368
2485
|
|
|
2369
2486
|
this.debugLog(`📡 [RealtimeService] LISTEN client connected on channel "${PG_NOTIFY_CHANNEL}"`);
|
|
2370
2487
|
} catch (err) {
|
|
2488
|
+
if (pending) {
|
|
2489
|
+
try { await pending.end(); } catch { /* already dead */ }
|
|
2490
|
+
}
|
|
2371
2491
|
logger.error("❌ [RealtimeService] Failed to connect LISTEN client", { error: err });
|
|
2372
2492
|
this.scheduleReconnect();
|
|
2373
2493
|
}
|