@rebasepro/server-postgres 0.14.0 → 0.14.1-canary.g7e666eb
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/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-BedO2aNX.js} +3 -3
- package/dist/{ensure-collection-policies-8vuu-n4r.js.map → ensure-collection-policies-BedO2aNX.js.map} +1 -1
- package/dist/{ensure-collection-tables-CbvaGuVn.js → ensure-collection-tables-B1qKdXA4.js} +2 -2
- package/dist/{ensure-collection-tables-CbvaGuVn.js.map → ensure-collection-tables-B1qKdXA4.js.map} +1 -1
- package/dist/index.es.js +220 -65
- 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/services/FetchService.d.ts +54 -5
- package/dist/services/RelationService.d.ts +3 -3
- 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/{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/auth/services.ts +26 -5
- package/src/schema/generate-drizzle-schema-logic.ts +19 -1
- package/src/services/FetchService.ts +185 -63
- package/src/services/RelationService.ts +3 -3
- package/src/services/dataService.ts +6 -4
- package/src/services/pg-notify-listener.ts +14 -0
- package/src/services/realtimeService.ts +160 -40
- package/src/websocket.ts +44 -1
- package/dist/auth-users-columns-BfQHf9JE.js.map +0 -1
- package/dist/websocket-C8ZqVBiV.js.map +0 -1
|
@@ -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
|
}
|
|
@@ -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
|
}
|
package/src/websocket.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { User } from "@rebasepro/types";
|
|
|
7
7
|
import { WebSocketServer, WebSocket } from "ws";
|
|
8
8
|
import { Server } from "http";
|
|
9
9
|
import { inspect } from "util";
|
|
10
|
-
import { extractUserFromToken, AccessTokenPayload, safeCompare, resolveRequireAuth } from "@rebasepro/server";
|
|
10
|
+
import { extractUserFromToken, AccessTokenPayload, safeCompare, resolveRequireAuth, assertWriteRequestValid, ApiError } from "@rebasepro/server";
|
|
11
11
|
import { logger } from "@rebasepro/server";
|
|
12
12
|
|
|
13
13
|
/** Minimal subset of RebaseAuthConfig used by the WebSocket layer. */
|
|
@@ -320,6 +320,20 @@ roles: verifiedUser.roles }
|
|
|
320
320
|
}
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
+
/**
|
|
324
|
+
* Apply the REST layer's write checks to a socket payload.
|
|
325
|
+
*
|
|
326
|
+
* Silent when the path names no registered collection: the
|
|
327
|
+
* driver decides what a path means, and refusing here would
|
|
328
|
+
* turn "unknown collection" into a validation error.
|
|
329
|
+
*/
|
|
330
|
+
const assertWriteRequest = (path: string | undefined, values: unknown): void => {
|
|
331
|
+
if (!path || !values || typeof values !== "object") return;
|
|
332
|
+
const collection = driver.registry?.getCollectionByPath(path);
|
|
333
|
+
if (!collection) return;
|
|
334
|
+
assertWriteRequestValid(values as Record<string, unknown>, collection);
|
|
335
|
+
};
|
|
336
|
+
|
|
323
337
|
// Helper to get correctly scoped delegate for the current request
|
|
324
338
|
const getScopedDelegate = async (): Promise<DataDriver> => {
|
|
325
339
|
const session = clientSessions.get(clientId);
|
|
@@ -404,6 +418,18 @@ roles: verifiedUser.roles }
|
|
|
404
418
|
const request: SaveProps = payload;
|
|
405
419
|
wsDebug("💾 [WebSocket Server] Saving row with request:", inspect(request, { depth: null,
|
|
406
420
|
colors: true }));
|
|
421
|
+
// The same two checks the REST write routes run, on the
|
|
422
|
+
// same input, at the same point. This socket is the
|
|
423
|
+
// other request boundary — the comment on `requireAuth`
|
|
424
|
+
// above says so — and it used to hand the client's
|
|
425
|
+
// payload straight to the driver, so a value the HTTP
|
|
426
|
+
// API answers 400 for was written when it arrived here.
|
|
427
|
+
//
|
|
428
|
+
// The collection comes from the registry by path, never
|
|
429
|
+
// from `request.collection`: that field is client-
|
|
430
|
+
// supplied, and reading the rules out of it would let
|
|
431
|
+
// the caller choose which rules to be checked against.
|
|
432
|
+
assertWriteRequest(request.path, request.values as Record<string, unknown>);
|
|
407
433
|
const delegate = await getScopedDelegate();
|
|
408
434
|
const row = await delegate.save(request);
|
|
409
435
|
wsDebug("💾 [WebSocket Server] SAVE_ENTITY result:", inspect(row, { depth: null,
|
|
@@ -741,6 +767,23 @@ code: "INVALID_LIMIT" } }
|
|
|
741
767
|
}));
|
|
742
768
|
return;
|
|
743
769
|
}
|
|
770
|
+
// A refused write is the caller's mistake, and its message is
|
|
771
|
+
// the only thing that says what to send instead — the same
|
|
772
|
+
// reasoning as `ListLimitError` above. Left to the generic
|
|
773
|
+
// branch it becomes INTERNAL_ERROR with the text dropped in
|
|
774
|
+
// production, so the socket would refuse the write and decline
|
|
775
|
+
// to say why.
|
|
776
|
+
if (error instanceof ApiError || (error as Error)?.name === "ApiError") {
|
|
777
|
+
const apiError = error as ApiError;
|
|
778
|
+
logger.warn(`[WebSocket Server] Refused a write: ${apiError.message}`);
|
|
779
|
+
ws.send(JSON.stringify({
|
|
780
|
+
type: "ERROR",
|
|
781
|
+
requestId,
|
|
782
|
+
payload: { error: { message: apiError.message,
|
|
783
|
+
code: apiError.code } }
|
|
784
|
+
}));
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
744
787
|
logger.error("💥 [WebSocket Server] Error handling message", { error: error });
|
|
745
788
|
if (error instanceof Error) {
|
|
746
789
|
logger.error("Stack trace", { detail: error.stack });
|