@rebasepro/server-postgres 0.9.1-canary.73476f2 → 0.9.1-canary.74adfbe
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/README.md +21 -0
- package/dist/PostgresBackendDriver.d.ts +43 -2
- package/dist/PostgresBootstrapper.d.ts +17 -1
- package/dist/auth/services.d.ts +68 -52
- package/dist/collections/buildRegistry.d.ts +27 -0
- package/dist/connection.d.ts +21 -0
- package/dist/data-transformer.d.ts +9 -2
- package/dist/index.es.js +2185 -775
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +1 -1
- package/dist/schema/auth-schema.d.ts +24 -24
- package/dist/schema/doctor.d.ts +1 -1
- package/dist/schema/introspect-db-logic.d.ts +0 -5
- package/dist/schema/introspect-db-naming.d.ts +10 -0
- package/dist/security/policy-drift.d.ts +30 -0
- package/dist/security/rls-enforcement.d.ts +2 -2
- package/dist/services/FetchService.d.ts +4 -24
- package/dist/services/PersistService.d.ts +27 -1
- package/dist/services/RelationService.d.ts +34 -1
- package/dist/services/channel-history.d.ts +118 -0
- package/dist/services/collection-helpers.d.ts +79 -14
- package/dist/services/dataService.d.ts +3 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +76 -2
- package/dist/services/row-pipeline.d.ts +63 -0
- package/package.json +13 -34
- package/src/PostgresBackendDriver.ts +183 -18
- package/src/PostgresBootstrapper.ts +80 -26
- package/src/auth/ensure-tables.ts +170 -28
- package/src/auth/services.ts +181 -150
- package/src/cli.ts +60 -0
- package/src/collections/buildRegistry.ts +59 -0
- package/src/connection.ts +61 -1
- package/src/data-transformer.ts +11 -9
- package/src/databasePoolManager.ts +2 -0
- package/src/schema/auth-bootstrap-sql.ts +7 -1
- package/src/schema/auth-schema.ts +13 -13
- package/src/schema/doctor.ts +45 -20
- package/src/schema/generate-drizzle-schema-logic.ts +24 -29
- package/src/schema/generate-postgres-ddl-logic.ts +76 -28
- package/src/schema/introspect-db-inference.ts +1 -1
- package/src/schema/introspect-db-logic.ts +1 -10
- package/src/schema/introspect-db-naming.ts +15 -0
- package/src/schema/introspect-db.ts +19 -2
- package/src/schema/introspect-runtime.ts +1 -1
- package/src/security/policy-drift.test.ts +106 -1
- package/src/security/policy-drift.ts +56 -0
- package/src/security/rls-enforcement.ts +11 -5
- package/src/services/BranchService.ts +42 -10
- package/src/services/FetchService.ts +65 -270
- package/src/services/PersistService.ts +130 -14
- package/src/services/RelationService.ts +153 -94
- package/src/services/channel-history.ts +343 -0
- package/src/services/collection-helpers.ts +164 -47
- package/src/services/dataService.ts +3 -2
- package/src/services/index.ts +1 -0
- package/src/services/realtimeService.ts +238 -29
- package/src/services/row-pipeline.ts +239 -0
- package/src/utils/drizzle-conditions.ts +13 -0
- package/src/websocket.ts +34 -12
- package/dist/schema/auth-default-policies.d.ts +0 -10
- package/src/schema/auth-default-policies.ts +0 -132
|
@@ -14,7 +14,9 @@ import { applyAuthContext } from "../security/rls-enforcement";
|
|
|
14
14
|
import { logger } from "@rebasepro/server";
|
|
15
15
|
import { sanitizeErrorForClient } from "../utils/pg-error-utils";
|
|
16
16
|
import { CdcListener, type CdcChangeEvent } from "./cdc/CdcListener";
|
|
17
|
-
import { getPrimaryKeys,
|
|
17
|
+
import { deriveRowAddress, getPrimaryKeys, type PrimaryKeyInfo } from "./collection-helpers";
|
|
18
|
+
import { ChannelHistoryStore, type ResolvedRetention } from "./channel-history";
|
|
19
|
+
import type { ChannelHistoryEntry, ChannelRetentionRule } from "@rebasepro/types";
|
|
18
20
|
|
|
19
21
|
/** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
|
|
20
22
|
const PG_NOTIFY_CHANNEL = "rebase_entity_changes";
|
|
@@ -24,7 +26,7 @@ const PG_NOTIFY_CHANNEL = "rebase_entity_changes";
|
|
|
24
26
|
* Mirrors the session variables set by PostgresBackendDriver.withAuth().
|
|
25
27
|
*/
|
|
26
28
|
export interface SubscriptionAuthContext {
|
|
27
|
-
|
|
29
|
+
uid: string;
|
|
28
30
|
roles: string[];
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -52,6 +54,28 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
52
54
|
|
|
53
55
|
// Presence: channel → Map<clientId, { state, lastSeen }>
|
|
54
56
|
private presence = new Map<string, Map<string, { state: Record<string, unknown>; lastSeen: number }>>();
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Ordered, replayable history for channels that opt into it.
|
|
60
|
+
*
|
|
61
|
+
* Undefined until {@link configureChannelHistory} is called, and inert even
|
|
62
|
+
* then unless retention rules were supplied — so presence and ephemeral
|
|
63
|
+
* notification channels never touch it. See `channel-history.ts`.
|
|
64
|
+
*/
|
|
65
|
+
private channelHistory?: ChannelHistoryStore;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One promise chain per retained channel, so that assigning a sequence
|
|
69
|
+
* number and fanning the message out happen in the same order for every
|
|
70
|
+
* message on that channel.
|
|
71
|
+
*
|
|
72
|
+
* Without it, two concurrent broadcasts can be numbered 4 and 5 by the
|
|
73
|
+
* database and still reach subscribers as 5 then 4 — live order and replay
|
|
74
|
+
* order would disagree, which is exactly the divergence sequence numbers
|
|
75
|
+
* are supposed to rule out. Keyed by channel, so unrelated channels never
|
|
76
|
+
* wait on each other.
|
|
77
|
+
*/
|
|
78
|
+
private channelSendQueues = new Map<string, Promise<void>>();
|
|
55
79
|
private presenceInterval?: ReturnType<typeof setInterval>;
|
|
56
80
|
private static readonly PRESENCE_TIMEOUT_MS = 30000; // 30s
|
|
57
81
|
private dataService: DataService;
|
|
@@ -73,7 +97,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
73
97
|
searchString?: string;
|
|
74
98
|
};
|
|
75
99
|
// Auth context for RLS — when set, refetches run in a transaction
|
|
76
|
-
// with set_config('app.
|
|
100
|
+
// with set_config('app.uid', ...) / set_config('app.user_roles', ...)
|
|
77
101
|
authContext?: SubscriptionAuthContext;
|
|
78
102
|
}>();
|
|
79
103
|
|
|
@@ -322,6 +346,14 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
322
346
|
payload?.payload
|
|
323
347
|
);
|
|
324
348
|
break;
|
|
349
|
+
case "channel_history":
|
|
350
|
+
await this.handleChannelHistoryRequest(
|
|
351
|
+
clientId,
|
|
352
|
+
payload?.channel as string,
|
|
353
|
+
payload?.sinceSeq as number | undefined,
|
|
354
|
+
payload?.limit as number | undefined
|
|
355
|
+
);
|
|
356
|
+
break;
|
|
325
357
|
|
|
326
358
|
// ── Presence ──
|
|
327
359
|
case "presence_track":
|
|
@@ -390,7 +422,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
390
422
|
authContext
|
|
391
423
|
);
|
|
392
424
|
|
|
393
|
-
this.sendCollectionUpdate(clientId, subscriptionId, rows);
|
|
425
|
+
this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
|
|
394
426
|
|
|
395
427
|
} catch (error) {
|
|
396
428
|
const sanitized = sanitizeErrorForClient(error, request.path);
|
|
@@ -554,7 +586,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
554
586
|
// Phase 1: Send instant row-level patch (no DB query)
|
|
555
587
|
// This gives immediate cross-tab feedback
|
|
556
588
|
if (!row || !(row as Record<string, unknown>)?._rebase_invalidated) {
|
|
557
|
-
this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row);
|
|
589
|
+
this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
|
|
558
590
|
}
|
|
559
591
|
|
|
560
592
|
// Phase 2: Schedule a deferred full refetch for correctness
|
|
@@ -609,7 +641,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
609
641
|
if (!this._subscriptions.has(subscriptionId)) return;
|
|
610
642
|
try {
|
|
611
643
|
const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest!, subscription.authContext);
|
|
612
|
-
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows);
|
|
644
|
+
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
|
|
613
645
|
} catch (error) {
|
|
614
646
|
const sanitized = sanitizeErrorForClient(error, notifyPath);
|
|
615
647
|
this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -669,10 +701,10 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
669
701
|
// Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
|
|
670
702
|
// Refetches are reads: apply the same GUCs + reader-role downgrade as the
|
|
671
703
|
// driver's read path, so realtime cannot leak rows the initial fetch hid.
|
|
672
|
-
const activeAuth = authContext || {
|
|
704
|
+
const activeAuth = authContext || { uid: "anon",
|
|
673
705
|
roles: ["anon"] };
|
|
674
706
|
return await this.db.transaction(async (tx) => {
|
|
675
|
-
await applyAuthContext(tx, {
|
|
707
|
+
await applyAuthContext(tx, { uid: activeAuth.uid, roles: activeAuth.roles }, this.rlsUserRole);
|
|
676
708
|
const txEntityService = new DataService(tx, this.registry);
|
|
677
709
|
let fetchedEntities;
|
|
678
710
|
if (collectionRequest.searchString) {
|
|
@@ -711,7 +743,7 @@ roles: ["anon"] };
|
|
|
711
743
|
|
|
712
744
|
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
713
745
|
const contextForCallback = {
|
|
714
|
-
user: { uid: activeAuth.
|
|
746
|
+
user: { uid: activeAuth.uid,
|
|
715
747
|
roles: activeAuth.roles },
|
|
716
748
|
driver: this.driver,
|
|
717
749
|
data: (this.driver && "data" in this.driver) ? (this.driver as DataDriverWithData).data : undefined
|
|
@@ -849,10 +881,10 @@ roles: activeAuth.roles },
|
|
|
849
881
|
|
|
850
882
|
// Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
|
|
851
883
|
// Same read isolation as collection refetches: GUCs + reader-role downgrade.
|
|
852
|
-
const activeAuth = authContext || {
|
|
884
|
+
const activeAuth = authContext || { uid: "anon",
|
|
853
885
|
roles: ["anon"] };
|
|
854
886
|
return await this.db.transaction(async (tx) => {
|
|
855
|
-
await applyAuthContext(tx, {
|
|
887
|
+
await applyAuthContext(tx, { uid: activeAuth.uid, roles: activeAuth.roles }, this.rlsUserRole);
|
|
856
888
|
const txEntityService = new DataService(tx, this.registry);
|
|
857
889
|
let processedEntity = await txEntityService.fetchOne(notifyPath, id, collection?.databaseId);
|
|
858
890
|
|
|
@@ -867,7 +899,7 @@ roles: ["anon"] };
|
|
|
867
899
|
|
|
868
900
|
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
869
901
|
const contextForCallback = {
|
|
870
|
-
user: { uid: activeAuth.
|
|
902
|
+
user: { uid: activeAuth.uid,
|
|
871
903
|
roles: activeAuth.roles },
|
|
872
904
|
driver: this.driver,
|
|
873
905
|
data: (this.driver && "data" in this.driver) ? (this.driver as DataDriverWithData).data : undefined
|
|
@@ -910,11 +942,12 @@ roles: activeAuth.roles },
|
|
|
910
942
|
return await this.dataService.fetchOne(notifyPath, id);
|
|
911
943
|
}
|
|
912
944
|
|
|
913
|
-
private sendCollectionUpdate(clientId: string, subscriptionId: string, rows: Record<string, unknown>[]) {
|
|
945
|
+
private sendCollectionUpdate(clientId: string, subscriptionId: string, rows: Record<string, unknown>[], path: string) {
|
|
914
946
|
const message: CollectionUpdateMessage = {
|
|
915
947
|
type: "collection_update",
|
|
916
948
|
subscriptionId,
|
|
917
|
-
rows: rows
|
|
949
|
+
rows: rows,
|
|
950
|
+
pks: this.primaryKeysForPath(path)
|
|
918
951
|
};
|
|
919
952
|
this.sendMessage(clientId, message);
|
|
920
953
|
}
|
|
@@ -931,17 +964,45 @@ roles: activeAuth.roles },
|
|
|
931
964
|
/**
|
|
932
965
|
* Send a lightweight row-level patch to a collection subscriber.
|
|
933
966
|
* The client can merge this into its cached data for instant feedback.
|
|
967
|
+
*
|
|
968
|
+
* The key columns ride along: the patch names a row by address, and the
|
|
969
|
+
* client has to find that row among the ones it cached — which carry
|
|
970
|
+
* columns and no address. The SDK holds no collection config to derive one
|
|
971
|
+
* from, so this is the only place the mapping can come from.
|
|
934
972
|
*/
|
|
935
|
-
private sendCollectionPatch(
|
|
973
|
+
private sendCollectionPatch(
|
|
974
|
+
clientId: string,
|
|
975
|
+
subscriptionId: string,
|
|
976
|
+
id: string,
|
|
977
|
+
row: Record<string, unknown> | null,
|
|
978
|
+
notifyPath: string
|
|
979
|
+
) {
|
|
936
980
|
const message: CollectionPatchMessage = {
|
|
937
981
|
type: "collection_patch",
|
|
938
982
|
subscriptionId,
|
|
939
983
|
id,
|
|
940
|
-
row: row
|
|
984
|
+
row: row,
|
|
985
|
+
pks: this.primaryKeysForPath(notifyPath)
|
|
941
986
|
};
|
|
942
987
|
this.sendMessage(clientId, message);
|
|
943
988
|
}
|
|
944
989
|
|
|
990
|
+
/** The key columns of the collection at `path`, if they can be resolved. */
|
|
991
|
+
private primaryKeysForPath(path: string): PrimaryKeyInfo[] | undefined {
|
|
992
|
+
try {
|
|
993
|
+
const collection = this.registry.getCollectionByPath(path);
|
|
994
|
+
if (!collection) return undefined;
|
|
995
|
+
const keys = getPrimaryKeys(collection, this.registry);
|
|
996
|
+
return keys.length > 0 ? keys : undefined;
|
|
997
|
+
} catch {
|
|
998
|
+
// `getCollectionByPath` throws on a path it cannot walk — and this
|
|
999
|
+
// is called for parent paths too, which include entity paths like
|
|
1000
|
+
// `posts/1` that name no collection. Telling the subscriber nothing
|
|
1001
|
+
// is right here; letting it throw would drop the notification.
|
|
1002
|
+
return undefined;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
945
1006
|
private sendError(clientId: string, error: string, subscriptionId?: string, code?: string) {
|
|
946
1007
|
const message = {
|
|
947
1008
|
type: "error" as const,
|
|
@@ -1010,8 +1071,85 @@ roles: activeAuth.roles },
|
|
|
1010
1071
|
this.removePresence(clientId, channel);
|
|
1011
1072
|
}
|
|
1012
1073
|
|
|
1013
|
-
/**
|
|
1074
|
+
/**
|
|
1075
|
+
* Broadcast a message to all clients in a channel except the sender.
|
|
1076
|
+
*
|
|
1077
|
+
* On a channel with no retention rule this is what it always was: a
|
|
1078
|
+
* synchronous fan-out to whoever is connected, with no sequence number, no
|
|
1079
|
+
* SQL and no await — the body below runs to completion before returning.
|
|
1080
|
+
*
|
|
1081
|
+
* On a retained channel the message is durably numbered first and only then
|
|
1082
|
+
* delivered, through a per-channel queue so that delivery order matches
|
|
1083
|
+
* sequence order. That ordering is the whole point: a client that catches up
|
|
1084
|
+
* with `sinceSeq` has to arrive at the same state as one that never
|
|
1085
|
+
* disconnected.
|
|
1086
|
+
*/
|
|
1014
1087
|
broadcastToChannel(clientId: string, channel: string, event: string, payload: unknown): void {
|
|
1088
|
+
const retention = this.channelHistory?.retentionFor(channel);
|
|
1089
|
+
if (!retention) {
|
|
1090
|
+
this.fanOutBroadcast(clientId, channel, event, payload);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const previous = this.channelSendQueues.get(channel) ?? Promise.resolve();
|
|
1095
|
+
const next = previous
|
|
1096
|
+
// A failed predecessor must not poison the chain — the next message
|
|
1097
|
+
// on this channel is independent and still deserves to be sent.
|
|
1098
|
+
.catch(() => { /* already reported below */ })
|
|
1099
|
+
.then(() => this.persistAndFanOut(clientId, channel, event, payload, retention));
|
|
1100
|
+
|
|
1101
|
+
this.channelSendQueues.set(channel, next);
|
|
1102
|
+
void next.finally(() => {
|
|
1103
|
+
// Only clear if nothing has queued behind us in the meantime.
|
|
1104
|
+
if (this.channelSendQueues.get(channel) === next) this.channelSendQueues.delete(channel);
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Number a broadcast, store it, then deliver it.
|
|
1110
|
+
*
|
|
1111
|
+
* A message that cannot be stored is **not** delivered. Delivering it would
|
|
1112
|
+
* put it in front of live subscribers while leaving it absent from every
|
|
1113
|
+
* future replay — the two views of the channel would disagree permanently,
|
|
1114
|
+
* and no later message could repair the gap. Failing loudly to the sender
|
|
1115
|
+
* instead lets it retry, which for an operation stream is the only outcome
|
|
1116
|
+
* that keeps clients convergent.
|
|
1117
|
+
*/
|
|
1118
|
+
private async persistAndFanOut(
|
|
1119
|
+
clientId: string,
|
|
1120
|
+
channel: string,
|
|
1121
|
+
event: string,
|
|
1122
|
+
payload: unknown,
|
|
1123
|
+
retention: ResolvedRetention
|
|
1124
|
+
): Promise<void> {
|
|
1125
|
+
let seq: number;
|
|
1126
|
+
try {
|
|
1127
|
+
({ seq } = await this.channelHistory!.append(channel, event, payload, clientId));
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
logger.error(`❌ [ChannelHistory] Could not persist broadcast on "${channel}" — message dropped`, { error });
|
|
1130
|
+
this.sendError(
|
|
1131
|
+
clientId,
|
|
1132
|
+
`Could not persist broadcast on retained channel "${channel}"`,
|
|
1133
|
+
undefined,
|
|
1134
|
+
"CHANNEL_HISTORY_WRITE_FAILED"
|
|
1135
|
+
);
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
this.fanOutBroadcast(clientId, channel, event, payload, seq);
|
|
1140
|
+
|
|
1141
|
+
try {
|
|
1142
|
+
await this.channelHistory!.prune(channel, retention);
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
// Retention is a housekeeping concern; the message is already
|
|
1145
|
+
// delivered and durable, so a failed prune must not surface as a
|
|
1146
|
+
// broadcast failure. It will be retried on the next message.
|
|
1147
|
+
logger.warn(`⚠️ [ChannelHistory] Prune failed for "${channel}"`, { error });
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** Deliver a broadcast frame to every member of a channel but the sender. */
|
|
1152
|
+
private fanOutBroadcast(clientId: string, channel: string, event: string, payload: unknown, seq?: number): void {
|
|
1015
1153
|
const members = this.channels.get(channel);
|
|
1016
1154
|
if (!members) return;
|
|
1017
1155
|
|
|
@@ -1019,7 +1157,8 @@ roles: activeAuth.roles },
|
|
|
1019
1157
|
type: "broadcast",
|
|
1020
1158
|
channel,
|
|
1021
1159
|
event,
|
|
1022
|
-
payload
|
|
1160
|
+
payload,
|
|
1161
|
+
...(seq !== undefined ? { seq } : {})
|
|
1023
1162
|
});
|
|
1024
1163
|
|
|
1025
1164
|
for (const memberId of members) {
|
|
@@ -1031,6 +1170,79 @@ roles: activeAuth.roles },
|
|
|
1031
1170
|
}
|
|
1032
1171
|
}
|
|
1033
1172
|
|
|
1173
|
+
// =============================================================================
|
|
1174
|
+
// Channel History
|
|
1175
|
+
// =============================================================================
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Install retention rules and create the tables they need.
|
|
1179
|
+
*
|
|
1180
|
+
* Safe to call with no rules (and safe not to call at all): the store stays
|
|
1181
|
+
* inert, no schema is created, and broadcast keeps its original
|
|
1182
|
+
* fire-and-forget path.
|
|
1183
|
+
*/
|
|
1184
|
+
async configureChannelHistory(rules: ChannelRetentionRule[] | undefined): Promise<void> {
|
|
1185
|
+
this.channelHistory = new ChannelHistoryStore(this.db, rules ?? []);
|
|
1186
|
+
if (!this.channelHistory.enabled) return;
|
|
1187
|
+
await this.channelHistory.ensureTables();
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** Whether any channel is configured to retain messages. */
|
|
1191
|
+
public isChannelHistoryEnabled(): boolean {
|
|
1192
|
+
return this.channelHistory?.enabled ?? false;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Answer a client's catch-up request.
|
|
1197
|
+
*
|
|
1198
|
+
* A channel with no retention rule is answered with `retained: false`
|
|
1199
|
+
* rather than an empty list, so the client can tell "you missed nothing"
|
|
1200
|
+
* apart from "this channel never keeps anything" — the second means its
|
|
1201
|
+
* reconnect strategy has to be a full resync, and silence would leave it
|
|
1202
|
+
* guessing.
|
|
1203
|
+
*/
|
|
1204
|
+
private async handleChannelHistoryRequest(
|
|
1205
|
+
clientId: string,
|
|
1206
|
+
channel: string,
|
|
1207
|
+
sinceSeq?: number,
|
|
1208
|
+
limit?: number
|
|
1209
|
+
): Promise<void> {
|
|
1210
|
+
if (!channel) return;
|
|
1211
|
+
|
|
1212
|
+
const retention = this.channelHistory?.retentionFor(channel);
|
|
1213
|
+
if (!retention) {
|
|
1214
|
+
this.sendChannelHistory(clientId, channel, [], false);
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
try {
|
|
1219
|
+
const { messages, latestSeq } = await this.channelHistory!.replay(channel, sinceSeq, limit);
|
|
1220
|
+
this.sendChannelHistory(clientId, channel, messages, true, latestSeq);
|
|
1221
|
+
} catch (error) {
|
|
1222
|
+
logger.error(`❌ [ChannelHistory] Replay failed for "${channel}"`, { error });
|
|
1223
|
+
this.sendError(clientId, `Could not replay history for channel "${channel}"`, undefined, "CHANNEL_HISTORY_READ_FAILED");
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
private sendChannelHistory(
|
|
1228
|
+
clientId: string,
|
|
1229
|
+
channel: string,
|
|
1230
|
+
messages: ChannelHistoryEntry[],
|
|
1231
|
+
retained: boolean,
|
|
1232
|
+
latestSeq?: number
|
|
1233
|
+
): void {
|
|
1234
|
+
const ws = this.clients.get(clientId);
|
|
1235
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1236
|
+
ws.send(JSON.stringify({
|
|
1237
|
+
type: "channel_history",
|
|
1238
|
+
channel,
|
|
1239
|
+
messages,
|
|
1240
|
+
retained,
|
|
1241
|
+
...(latestSeq !== undefined ? { latestSeq } : {})
|
|
1242
|
+
}));
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1034
1246
|
// =============================================================================
|
|
1035
1247
|
// Presence
|
|
1036
1248
|
// =============================================================================
|
|
@@ -1162,6 +1374,11 @@ lastSeen: Date.now() });
|
|
|
1162
1374
|
// 3. Clear broadcast channels and presence
|
|
1163
1375
|
this.channels.clear();
|
|
1164
1376
|
this.presence.clear();
|
|
1377
|
+
// Pending history writes hold the pool open; let them settle before the
|
|
1378
|
+
// caller closes it, but never let a rejected one break shutdown.
|
|
1379
|
+
await Promise.allSettled([...this.channelSendQueues.values()]);
|
|
1380
|
+
this.channelSendQueues.clear();
|
|
1381
|
+
this.channelHistory?.clear();
|
|
1165
1382
|
if (this.presenceInterval) {
|
|
1166
1383
|
clearInterval(this.presenceInterval);
|
|
1167
1384
|
this.presenceInterval = undefined;
|
|
@@ -1294,17 +1511,9 @@ lastSeen: Date.now() });
|
|
|
1294
1511
|
|
|
1295
1512
|
/** Compute the canonical (possibly composite) id string from a captured row. */
|
|
1296
1513
|
private extractIdFromCdcRow(collection: CollectionConfig, row: Record<string, unknown>): string {
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
if (composite && composite !== ":::") return composite;
|
|
1301
|
-
} catch {
|
|
1302
|
-
/* fall through to id-field / wildcard */
|
|
1303
|
-
}
|
|
1304
|
-
// Fallbacks: a plain `id` column (common), else a collection-level
|
|
1305
|
-
// invalidation ("*") — single-row subs won't match but collection subs refetch.
|
|
1306
|
-
if (row.id !== undefined && row.id !== null) return String(row.id);
|
|
1307
|
-
return "*";
|
|
1514
|
+
// Unaddressable falls back to a collection-level invalidation: single-row
|
|
1515
|
+
// subs won't match, but collection subs still refetch.
|
|
1516
|
+
return deriveRowAddress(row, collection, this.registry) || "*";
|
|
1308
1517
|
}
|
|
1309
1518
|
|
|
1310
1519
|
// ── App/CDC de-duplication ──
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { CollectionConfig, Property, Relation } from "@rebasepro/types";
|
|
2
|
+
import { resolveCollectionRelations, findRelation, createRelationRefWithData } from "@rebasepro/common";
|
|
3
|
+
import { normalizeDbValues } from "../data-transformer";
|
|
4
|
+
import { deriveRowAddress } from "./collection-helpers";
|
|
5
|
+
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Turning a drizzle result into a row we serve.
|
|
9
|
+
*
|
|
10
|
+
* There are two shapes, and they are the same walk. Both take a row whose
|
|
11
|
+
* relation fields hold nested objects, both unwrap junction rows to reach the
|
|
12
|
+
* target behind them, and both leave every other column alone. They differ only
|
|
13
|
+
* in what they put where the relation was:
|
|
14
|
+
*
|
|
15
|
+
* - `"ref"` — a `{ id, path, __type: "relation" }` reference carrying the
|
|
16
|
+
* target's values. This is what the admin renders.
|
|
17
|
+
* - `"inline"` — the target's own columns, flat. This is what REST serves.
|
|
18
|
+
*
|
|
19
|
+
* They used to be two functions that happened to agree, and the agreement was
|
|
20
|
+
* not enforced by anything: the row-identity bug had to be fixed five times
|
|
21
|
+
* across differently-shaped copies of this walk, and one of the copies was
|
|
22
|
+
* dead code nobody had noticed. Whatever the next cross-cutting change is, it
|
|
23
|
+
* is one edit here.
|
|
24
|
+
*/
|
|
25
|
+
export type RelationStyle = "ref" | "inline";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether a many-relation reaches its target through a junction table.
|
|
29
|
+
*
|
|
30
|
+
* Also used to build the drizzle `with` config, which is why it is exported:
|
|
31
|
+
* the query has to nest one level deeper for a junction, and the row walk has
|
|
32
|
+
* to unwrap that same level back out.
|
|
33
|
+
*/
|
|
34
|
+
export function isJunctionRelation(relation: Relation): boolean {
|
|
35
|
+
// An explicit `through` says so outright.
|
|
36
|
+
if (relation.through) return true;
|
|
37
|
+
// A joinPath with an intermediate table is the same thing, spelled longhand.
|
|
38
|
+
if (relation.joinPath && relation.joinPath.length > 1) return true;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reach the target row inside a junction row.
|
|
44
|
+
*
|
|
45
|
+
* A junction row looks like `{ post_id: 1, tag_id: { id: 5, name: "ts" } }` —
|
|
46
|
+
* the foreign keys, and the target nested under one of them. The target is the
|
|
47
|
+
* only object among them, so that is how it is found. A junction row that has
|
|
48
|
+
* not been nested (no `with` on the join) has no object and is returned as-is.
|
|
49
|
+
*/
|
|
50
|
+
function unwrapJunctionRow(item: Record<string, unknown>): Record<string, unknown> {
|
|
51
|
+
const nestedKey = Object.keys(item).find(
|
|
52
|
+
key => typeof item[key] === "object" && item[key] !== null && !Array.isArray(item[key])
|
|
53
|
+
);
|
|
54
|
+
return nestedKey ? item[nestedKey] as Record<string, unknown> : item;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Give back the number a `number` property was declared to be.
|
|
59
|
+
*
|
|
60
|
+
* Postgres returns NUMERIC as a string and drizzle keeps it that way, since a
|
|
61
|
+
* numeric can hold more precision than a double. But the collection declared
|
|
62
|
+
* this property `number` and the OpenAPI spec this server publishes says
|
|
63
|
+
* `type: number`, so serving `"9.99"` breaks its own contract — and breaks it
|
|
64
|
+
* asymmetrically, because a create answers with the number and the read that
|
|
65
|
+
* follows answers with the string. Any client that multiplies a price works
|
|
66
|
+
* until the first refresh.
|
|
67
|
+
*
|
|
68
|
+
* Declaring a property `number` already accepts double precision — that is what
|
|
69
|
+
* the admin has always parsed it to. Columns REST serves that no property
|
|
70
|
+
* declares are left exactly as the database returned them.
|
|
71
|
+
*/
|
|
72
|
+
function coerceDeclaredNumber(value: unknown, property: Property | undefined): unknown {
|
|
73
|
+
if (property?.type !== "number" || typeof value !== "string") return value;
|
|
74
|
+
const parsed = parseFloat(value);
|
|
75
|
+
return isNaN(parsed) ? null : parsed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Apply {@link coerceDeclaredNumber} across a row, leaving every other column alone. */
|
|
79
|
+
function coerceDeclaredNumbers(
|
|
80
|
+
row: Record<string, unknown>,
|
|
81
|
+
collection: CollectionConfig
|
|
82
|
+
): Record<string, unknown> {
|
|
83
|
+
const properties = collection.properties;
|
|
84
|
+
if (!properties) return row;
|
|
85
|
+
|
|
86
|
+
const out: Record<string, unknown> = {};
|
|
87
|
+
for (const [key, value] of Object.entries(row)) {
|
|
88
|
+
out[key] = coerceDeclaredNumber(value, properties[key] as Property | undefined);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Render one target row in the requested style. */
|
|
94
|
+
/**
|
|
95
|
+
* Drop every column the collection marked `excludeFromApi`.
|
|
96
|
+
*
|
|
97
|
+
* Password hashes and verification tokens have to be readable server-side but
|
|
98
|
+
* must never reach a client — and "never" has to mean every exit from this
|
|
99
|
+
* pipeline, including relation targets, or a secret leaks through whichever
|
|
100
|
+
* path was overlooked. Keyed by both the property name and its column name,
|
|
101
|
+
* since a row can arrive keyed either way depending on the caller.
|
|
102
|
+
*/
|
|
103
|
+
function stripExcluded(
|
|
104
|
+
row: Record<string, unknown>,
|
|
105
|
+
collection: CollectionConfig
|
|
106
|
+
): Record<string, unknown> {
|
|
107
|
+
const properties = collection.properties as Record<string, Property> | undefined;
|
|
108
|
+
if (!properties) return row;
|
|
109
|
+
|
|
110
|
+
for (const [key, property] of Object.entries(properties)) {
|
|
111
|
+
if (!property?.excludeFromApi) continue;
|
|
112
|
+
delete row[key];
|
|
113
|
+
if (property.columnName) delete row[property.columnName];
|
|
114
|
+
}
|
|
115
|
+
return row;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function renderTarget(
|
|
119
|
+
targetRow: Record<string, unknown>,
|
|
120
|
+
targetCollection: CollectionConfig,
|
|
121
|
+
style: RelationStyle,
|
|
122
|
+
registry: PostgresCollectionRegistry
|
|
123
|
+
): unknown {
|
|
124
|
+
if (style === "inline") {
|
|
125
|
+
// The target's columns, and only those: its address is the consumer's
|
|
126
|
+
// to derive, and merging one in overwrites a real `id` column.
|
|
127
|
+
return stripExcluded(coerceDeclaredNumbers({ ...targetRow }, targetCollection), targetCollection);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const address = relationTargetAddress(targetRow, targetCollection, registry);
|
|
131
|
+
const path = targetCollection.slug;
|
|
132
|
+
return createRelationRefWithData(address, path, {
|
|
133
|
+
id: address,
|
|
134
|
+
path,
|
|
135
|
+
values: normalizeDbValues(targetRow, targetCollection)
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The address a relation ref points at.
|
|
141
|
+
*
|
|
142
|
+
* The whole key, not its first column: a composite-keyed target addressed by
|
|
143
|
+
* `tenant_id` alone points at every row that shares it. A target whose key
|
|
144
|
+
* cannot be resolved at all used to throw here — reading `[0]` of an empty
|
|
145
|
+
* array — taking down the parent's fetch over a relation it may not even have
|
|
146
|
+
* asked for. The first column is a guess, but a ref that resolves to nothing
|
|
147
|
+
* beats no rows at all.
|
|
148
|
+
*/
|
|
149
|
+
export function relationTargetAddress(
|
|
150
|
+
targetRow: Record<string, unknown>,
|
|
151
|
+
targetCollection: CollectionConfig,
|
|
152
|
+
registry: PostgresCollectionRegistry
|
|
153
|
+
): string {
|
|
154
|
+
const address = deriveRowAddress(targetRow, targetCollection, registry);
|
|
155
|
+
if (address) return address;
|
|
156
|
+
return String(targetRow[Object.keys(targetRow)[0]] ?? "");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The row the admin renders: every column, with relations as references.
|
|
161
|
+
*
|
|
162
|
+
* Values are normalized (dates, numbers, NaN) because the admin's view-model
|
|
163
|
+
* expects real types. The row's own address is *not* among the columns — it is
|
|
164
|
+
* derived by the consumer from the collection's primary keys.
|
|
165
|
+
*/
|
|
166
|
+
export function toCmsRow(
|
|
167
|
+
row: Record<string, unknown>,
|
|
168
|
+
collection: CollectionConfig,
|
|
169
|
+
registry: PostgresCollectionRegistry
|
|
170
|
+
): Record<string, unknown> {
|
|
171
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
172
|
+
const normalized = normalizeDbValues(row, collection) as Record<string, unknown>;
|
|
173
|
+
|
|
174
|
+
// Keyed by relation, reading the drizzle relation name off the raw row: the
|
|
175
|
+
// relation's property key and the name drizzle nested it under are not
|
|
176
|
+
// always the same, and the value is written back under the property key.
|
|
177
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
178
|
+
const relData = row[relation.relationName || key];
|
|
179
|
+
if (relData === undefined || relData === null) continue;
|
|
180
|
+
|
|
181
|
+
if (relation.cardinality === "many" && Array.isArray(relData)) {
|
|
182
|
+
const targetCollection = relation.target();
|
|
183
|
+
normalized[key] = relData.map((item: Record<string, unknown>) =>
|
|
184
|
+
renderTarget(
|
|
185
|
+
isJunctionRelation(relation) ? unwrapJunctionRow(item) : item,
|
|
186
|
+
targetCollection,
|
|
187
|
+
"ref",
|
|
188
|
+
registry
|
|
189
|
+
));
|
|
190
|
+
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
|
|
191
|
+
normalized[key] = renderTarget(relData as Record<string, unknown>, relation.target(), "ref", registry);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return stripExcluded(normalized, collection);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The row REST serves: every column under its own name, with the value Postgres
|
|
200
|
+
* returned, and relations inlined as the target's columns.
|
|
201
|
+
*
|
|
202
|
+
* Values are the ones the database returned, except where that contradicts the
|
|
203
|
+
* declared type: a `number` property is served as a number (see
|
|
204
|
+
* {@link coerceDeclaredNumber}). Dates stay as the database returned them —
|
|
205
|
+
* JSON has its own opinions about dates that the admin's view-model does not
|
|
206
|
+
* share.
|
|
207
|
+
*
|
|
208
|
+
* Keyed by the row rather than by the relation list — a REST fetch only loads
|
|
209
|
+
* the relations `include` asked for, so the row is the authority on which are
|
|
210
|
+
* actually there.
|
|
211
|
+
*/
|
|
212
|
+
export function toRestRow(
|
|
213
|
+
row: Record<string, unknown>,
|
|
214
|
+
collection: CollectionConfig,
|
|
215
|
+
registry: PostgresCollectionRegistry
|
|
216
|
+
): Record<string, unknown> {
|
|
217
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
218
|
+
const flat: Record<string, unknown> = {};
|
|
219
|
+
|
|
220
|
+
for (const [key, value] of Object.entries(row)) {
|
|
221
|
+
const relation = findRelation(resolvedRelations, key);
|
|
222
|
+
|
|
223
|
+
if (relation && Array.isArray(value)) {
|
|
224
|
+
flat[key] = value.map((item: Record<string, unknown>) =>
|
|
225
|
+
renderTarget(
|
|
226
|
+
isJunctionRelation(relation) ? unwrapJunctionRow(item) : item,
|
|
227
|
+
relation.target(),
|
|
228
|
+
"inline",
|
|
229
|
+
registry
|
|
230
|
+
));
|
|
231
|
+
} else if (relation && typeof value === "object" && value !== null) {
|
|
232
|
+
flat[key] = renderTarget(value as Record<string, unknown>, relation.target(), "inline", registry);
|
|
233
|
+
} else {
|
|
234
|
+
flat[key] = coerceDeclaredNumber(value, collection.properties?.[key] as Property | undefined);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return stripExcluded(flat, collection);
|
|
239
|
+
}
|
|
@@ -1126,6 +1126,19 @@ export class DrizzleConditionBuilder {
|
|
|
1126
1126
|
throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
|
|
1127
1127
|
}
|
|
1128
1128
|
|
|
1129
|
+
// The vector is interpolated as a raw SQL literal below (pgvector has no
|
|
1130
|
+
// bind form for the `::vector` cast), so every element must be a finite
|
|
1131
|
+
// number. The REST query parser already enforces this, but this builder
|
|
1132
|
+
// is a shared entry point — validate here too so no future caller can
|
|
1133
|
+
// turn an unchecked value into SQL injection.
|
|
1134
|
+
if (
|
|
1135
|
+
!Array.isArray(vectorSearch.vector) ||
|
|
1136
|
+
vectorSearch.vector.length === 0 ||
|
|
1137
|
+
!vectorSearch.vector.every((n) => typeof n === "number" && Number.isFinite(n))
|
|
1138
|
+
) {
|
|
1139
|
+
throw new Error("Vector search requires a non-empty array of finite numbers");
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1129
1142
|
const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
|
|
1130
1143
|
const distanceFn = vectorSearch.distance || "cosine";
|
|
1131
1144
|
|