@rebasepro/server-postgresql 0.0.1-canary.09e5ec5
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/LICENSE +6 -0
- package/README.md +106 -0
- package/build-errors.txt +37 -0
- package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/data/buildRebaseData.d.ts +14 -0
- package/dist/common/src/index.d.ts +3 -0
- package/dist/common/src/util/builders.d.ts +57 -0
- package/dist/common/src/util/callbacks.d.ts +6 -0
- package/dist/common/src/util/collections.d.ts +11 -0
- package/dist/common/src/util/common.d.ts +2 -0
- package/dist/common/src/util/conditions.d.ts +26 -0
- package/dist/common/src/util/entities.d.ts +58 -0
- package/dist/common/src/util/enums.d.ts +3 -0
- package/dist/common/src/util/index.d.ts +16 -0
- package/dist/common/src/util/navigation_from_path.d.ts +34 -0
- package/dist/common/src/util/navigation_utils.d.ts +20 -0
- package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
- package/dist/common/src/util/paths.d.ts +14 -0
- package/dist/common/src/util/permissions.d.ts +5 -0
- package/dist/common/src/util/references.d.ts +2 -0
- package/dist/common/src/util/relations.d.ts +22 -0
- package/dist/common/src/util/resolutions.d.ts +72 -0
- package/dist/common/src/util/storage.d.ts +24 -0
- package/dist/index.es.js +11298 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +11306 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +100 -0
- package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
- package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
- package/dist/server-postgresql/src/auth/services.d.ts +192 -0
- package/dist/server-postgresql/src/cli.d.ts +1 -0
- package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
- package/dist/server-postgresql/src/connection.d.ts +40 -0
- package/dist/server-postgresql/src/data-transformer.d.ts +58 -0
- package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
- package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
- package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
- package/dist/server-postgresql/src/index.d.ts +13 -0
- package/dist/server-postgresql/src/interfaces.d.ts +18 -0
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +868 -0
- package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
- package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +82 -0
- package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
- package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
- package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
- package/dist/server-postgresql/src/services/EntityFetchService.d.ts +209 -0
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
- package/dist/server-postgresql/src/services/RelationService.d.ts +98 -0
- package/dist/server-postgresql/src/services/entity-helpers.d.ts +38 -0
- package/dist/server-postgresql/src/services/entityService.d.ts +104 -0
- package/dist/server-postgresql/src/services/index.d.ts +4 -0
- package/dist/server-postgresql/src/services/realtimeService.d.ts +188 -0
- package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
- package/dist/server-postgresql/src/websocket.d.ts +5 -0
- package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
- package/dist/types/src/controllers/auth.d.ts +119 -0
- package/dist/types/src/controllers/client.d.ts +170 -0
- package/dist/types/src/controllers/collection_registry.d.ts +45 -0
- package/dist/types/src/controllers/customization_controller.d.ts +60 -0
- package/dist/types/src/controllers/data.d.ts +168 -0
- package/dist/types/src/controllers/data_driver.d.ts +160 -0
- package/dist/types/src/controllers/database_admin.d.ts +11 -0
- package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
- package/dist/types/src/controllers/effective_role.d.ts +4 -0
- package/dist/types/src/controllers/email.d.ts +34 -0
- package/dist/types/src/controllers/index.d.ts +18 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
- package/dist/types/src/controllers/navigation.d.ts +213 -0
- package/dist/types/src/controllers/registry.d.ts +54 -0
- package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
- package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
- package/dist/types/src/controllers/snackbar.d.ts +24 -0
- package/dist/types/src/controllers/storage.d.ts +171 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/rebase_context.d.ts +105 -0
- package/dist/types/src/types/backend.d.ts +536 -0
- package/dist/types/src/types/builders.d.ts +15 -0
- package/dist/types/src/types/chips.d.ts +5 -0
- package/dist/types/src/types/collections.d.ts +856 -0
- package/dist/types/src/types/cron.d.ts +102 -0
- package/dist/types/src/types/data_source.d.ts +64 -0
- package/dist/types/src/types/entities.d.ts +145 -0
- package/dist/types/src/types/entity_actions.d.ts +98 -0
- package/dist/types/src/types/entity_callbacks.d.ts +173 -0
- package/dist/types/src/types/entity_link_builder.d.ts +7 -0
- package/dist/types/src/types/entity_overrides.d.ts +10 -0
- package/dist/types/src/types/entity_views.d.ts +61 -0
- package/dist/types/src/types/export_import.d.ts +21 -0
- package/dist/types/src/types/index.d.ts +23 -0
- package/dist/types/src/types/locales.d.ts +4 -0
- package/dist/types/src/types/modify_collections.d.ts +5 -0
- package/dist/types/src/types/plugins.d.ts +279 -0
- package/dist/types/src/types/properties.d.ts +1176 -0
- package/dist/types/src/types/property_config.d.ts +70 -0
- package/dist/types/src/types/relations.d.ts +336 -0
- package/dist/types/src/types/slots.d.ts +252 -0
- package/dist/types/src/types/translations.d.ts +870 -0
- package/dist/types/src/types/user_management_delegate.d.ts +121 -0
- package/dist/types/src/types/websockets.d.ts +78 -0
- package/dist/types/src/users/index.d.ts +2 -0
- package/dist/types/src/users/roles.d.ts +22 -0
- package/dist/types/src/users/user.d.ts +46 -0
- package/drizzle-test/0000_woozy_junta.sql +6 -0
- package/drizzle-test/0001_youthful_arachne.sql +1 -0
- package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
- package/drizzle-test/0003_mean_king_cobra.sql +2 -0
- package/drizzle-test/meta/0000_snapshot.json +47 -0
- package/drizzle-test/meta/0001_snapshot.json +48 -0
- package/drizzle-test/meta/0002_snapshot.json +38 -0
- package/drizzle-test/meta/0003_snapshot.json +48 -0
- package/drizzle-test/meta/_journal.json +34 -0
- package/drizzle-test-out/0000_tan_trauma.sql +6 -0
- package/drizzle-test-out/0001_rapid_drax.sql +1 -0
- package/drizzle-test-out/meta/0000_snapshot.json +44 -0
- package/drizzle-test-out/meta/0001_snapshot.json +54 -0
- package/drizzle-test-out/meta/_journal.json +20 -0
- package/drizzle.test.config.ts +10 -0
- package/jest-all.log +3128 -0
- package/jest.log +49 -0
- package/package.json +92 -0
- package/scratch.ts +41 -0
- package/src/PostgresBackendDriver.ts +1008 -0
- package/src/PostgresBootstrapper.ts +231 -0
- package/src/auth/ensure-tables.ts +381 -0
- package/src/auth/services.ts +799 -0
- package/src/cli.ts +648 -0
- package/src/collections/PostgresCollectionRegistry.ts +96 -0
- package/src/connection.ts +84 -0
- package/src/data-transformer.ts +608 -0
- package/src/databasePoolManager.ts +85 -0
- package/src/history/HistoryService.ts +248 -0
- package/src/history/ensure-history-table.ts +45 -0
- package/src/index.ts +13 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-schema.ts +169 -0
- package/src/schema/doctor-cli.ts +47 -0
- package/src/schema/doctor.ts +595 -0
- package/src/schema/generate-drizzle-schema-logic.ts +765 -0
- package/src/schema/generate-drizzle-schema.ts +151 -0
- package/src/schema/introspect-db-logic.ts +542 -0
- package/src/schema/introspect-db.ts +211 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/services/BranchService.ts +237 -0
- package/src/services/EntityFetchService.ts +1576 -0
- package/src/services/EntityPersistService.ts +349 -0
- package/src/services/RelationService.ts +1274 -0
- package/src/services/entity-helpers.ts +147 -0
- package/src/services/entityService.ts +211 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1034 -0
- package/src/utils/drizzle-conditions.ts +1000 -0
- package/src/websocket.ts +518 -0
- package/test/auth-services.test.ts +661 -0
- package/test/batch-many-to-many-regression.test.ts +573 -0
- package/test/branchService.test.ts +367 -0
- package/test/data-transformer-hardening.test.ts +417 -0
- package/test/data-transformer.test.ts +175 -0
- package/test/doctor.test.ts +182 -0
- package/test/drizzle-conditions.test.ts +895 -0
- package/test/entityService.errors.test.ts +367 -0
- package/test/entityService.relations.test.ts +1008 -0
- package/test/entityService.subcollection-search.test.ts +566 -0
- package/test/entityService.test.ts +1035 -0
- package/test/generate-drizzle-schema.test.ts +988 -0
- package/test/historyService.test.ts +141 -0
- package/test/introspect-db-generation.test.ts +436 -0
- package/test/introspect-db-utils.test.ts +389 -0
- package/test/n-plus-one-regression.test.ts +314 -0
- package/test/postgresDataDriver.test.ts +648 -0
- package/test/realtimeService.test.ts +307 -0
- package/test/relation-pipeline-gaps.test.ts +637 -0
- package/test/relations.test.ts +1115 -0
- package/test/unmapped-tables-safety.test.ts +345 -0
- package/test-drizzle-bug.ts +18 -0
- package/test-drizzle-out/0000_cultured_freak.sql +7 -0
- package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
- package/test-drizzle-out/meta/0000_snapshot.json +55 -0
- package/test-drizzle-out/meta/0001_snapshot.json +63 -0
- package/test-drizzle-out/meta/_journal.json +20 -0
- package/test-drizzle-prompt.sh +2 -0
- package/test-policy-prompt.sh +3 -0
- package/test-programmatic.ts +30 -0
- package/test-programmatic2.ts +59 -0
- package/test-schema-no-policies.ts +12 -0
- package/test_drizzle_mock.js +3 -0
- package/test_find_changed.mjs +32 -0
- package/test_hash.js +14 -0
- package/test_output.txt +3145 -0
- package/tsconfig.json +49 -0
- package/tsconfig.prod.json +20 -0
- package/vite.config.ts +82 -0
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
import { WebSocket } from "ws";
|
|
2
|
+
import { EventEmitter } from "events";
|
|
3
|
+
import { Client as PgClient } from "pg";
|
|
4
|
+
import { randomUUID } from "crypto";
|
|
5
|
+
import { EntityService } from "./entityService";
|
|
6
|
+
|
|
7
|
+
import { Entity, FetchCollectionProps, ListenCollectionProps, ListenEntityProps, DataDriver, CollectionUpdateMessage, EntityUpdateMessage, CollectionEntityPatchMessage, WebSocketMessage, FilterValues, EntityCollection, RebaseCallContext } from "@rebasepro/types";
|
|
8
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
9
|
+
import { sql as drizzleSql } from "drizzle-orm";
|
|
10
|
+
import { RealtimeProvider, CollectionSubscriptionConfig, EntitySubscriptionConfig } from "../interfaces";
|
|
11
|
+
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
12
|
+
import { buildPropertyCallbacks } from "@rebasepro/common";
|
|
13
|
+
|
|
14
|
+
/** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
|
|
15
|
+
const PG_NOTIFY_CHANNEL = "rebase_entity_changes";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Auth context stored per-subscription so real-time refetches respect RLS.
|
|
19
|
+
* Mirrors the session variables set by PostgresBackendDriver.withAuth().
|
|
20
|
+
*/
|
|
21
|
+
export interface SubscriptionAuthContext {
|
|
22
|
+
userId: string;
|
|
23
|
+
roles: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type RealTimeListenCollectionProps = ListenCollectionProps & {
|
|
27
|
+
subscriptionId: string
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type RealTimeListenEntityProps = ListenEntityProps & { subscriptionId: string };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* PostgreSQL-specific realtime service.
|
|
34
|
+
* Handles WebSocket connections and subscriptions for real-time entity updates.
|
|
35
|
+
*
|
|
36
|
+
* Implements the RealtimeProvider interface for database abstraction.
|
|
37
|
+
*/
|
|
38
|
+
export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
39
|
+
private clients = new Map<string, WebSocket>();
|
|
40
|
+
private entityService: EntityService;
|
|
41
|
+
// Enhanced subscriptions storage with full request parameters
|
|
42
|
+
private _subscriptions = new Map<string, {
|
|
43
|
+
clientId: string;
|
|
44
|
+
type: "collection" | "entity";
|
|
45
|
+
path: string;
|
|
46
|
+
entityId?: string | number;
|
|
47
|
+
// Store full collection request parameters for proper refetching
|
|
48
|
+
collectionRequest?: {
|
|
49
|
+
filter?: Record<string, unknown>;
|
|
50
|
+
orderBy?: string;
|
|
51
|
+
order?: "desc" | "asc";
|
|
52
|
+
limit?: number;
|
|
53
|
+
offset?: number;
|
|
54
|
+
startAfter?: Record<string, unknown>;
|
|
55
|
+
databaseId?: string;
|
|
56
|
+
searchString?: string;
|
|
57
|
+
};
|
|
58
|
+
// Auth context for RLS — when set, refetches run in a transaction
|
|
59
|
+
// with set_config('app.user_id', ...) / set_config('app.user_roles', ...)
|
|
60
|
+
authContext?: SubscriptionAuthContext;
|
|
61
|
+
}>();
|
|
62
|
+
|
|
63
|
+
// Add callback storage for DataDriver subscriptions
|
|
64
|
+
private subscriptionCallbacks = new Map<string, (data: Entity[] | Entity | null) => void>();
|
|
65
|
+
|
|
66
|
+
private driver?: DataDriver;
|
|
67
|
+
|
|
68
|
+
// ── Cross-instance LISTEN/NOTIFY ──
|
|
69
|
+
/** Unique identifier for this process instance, used to skip own notifications. */
|
|
70
|
+
private readonly instanceId = `inst_${randomUUID().slice(0, 8)}`;
|
|
71
|
+
/** Dedicated pg.Client for LISTEN (outside the Drizzle pool). */
|
|
72
|
+
private listenClient?: PgClient;
|
|
73
|
+
/** Connection string used for reconnecting the LISTEN client. */
|
|
74
|
+
private listenConnectionString?: string;
|
|
75
|
+
/** Whether cross-instance broadcasting is active. */
|
|
76
|
+
private broadcasting = false;
|
|
77
|
+
/** Reconnection timer handle. */
|
|
78
|
+
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
|
79
|
+
/** Debounce timers for collection refetches to prevent refetch storms. */
|
|
80
|
+
private refetchTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
81
|
+
/** Debounce window (ms) for coalescing rapid entity updates into a single correctness refetch. */
|
|
82
|
+
private static readonly REFETCH_DEBOUNCE_MS = 300;
|
|
83
|
+
|
|
84
|
+
constructor(private db: NodePgDatabase<any>, private registry: PostgresCollectionRegistry) {
|
|
85
|
+
super();
|
|
86
|
+
this.entityService = new EntityService(db, registry);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether to emit verbose debug logs (disabled in production). */
|
|
90
|
+
private static readonly DEBUG = process.env.NODE_ENV !== "production";
|
|
91
|
+
private debugLog(...args: unknown[]) {
|
|
92
|
+
if (RealtimeService.DEBUG) console.debug(...args);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setDataDriver(driver: DataDriver) {
|
|
96
|
+
this.driver = driver;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Make subscriptions accessible for DataDriver
|
|
100
|
+
get subscriptions() {
|
|
101
|
+
return this._subscriptions;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Add public method to register DataDriver subscriptions
|
|
105
|
+
registerDataDriverSubscription(subscriptionId: string, subscription: {
|
|
106
|
+
clientId: string;
|
|
107
|
+
type: "collection" | "entity";
|
|
108
|
+
path: string;
|
|
109
|
+
entityId?: string | number;
|
|
110
|
+
collectionRequest?: {
|
|
111
|
+
filter?: Record<string, unknown>;
|
|
112
|
+
orderBy?: string;
|
|
113
|
+
order?: "desc" | "asc";
|
|
114
|
+
limit?: number;
|
|
115
|
+
offset?: number;
|
|
116
|
+
startAfter?: Record<string, unknown>;
|
|
117
|
+
databaseId?: string;
|
|
118
|
+
searchString?: string;
|
|
119
|
+
};
|
|
120
|
+
authContext?: SubscriptionAuthContext;
|
|
121
|
+
}) {
|
|
122
|
+
this.debugLog("📋 [RealtimeService] Registering DataDriver subscription:", subscriptionId, subscription.authContext ? "(with auth)" : "(no auth)");
|
|
123
|
+
this._subscriptions.set(subscriptionId, subscription);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Add callback management methods
|
|
127
|
+
addSubscriptionCallback(subscriptionId: string, callback: (data: Entity[] | Entity | null) => void) {
|
|
128
|
+
this.debugLog("📋 [RealtimeService] Adding callback for subscription:", subscriptionId);
|
|
129
|
+
this.subscriptionCallbacks.set(subscriptionId, callback);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
removeSubscriptionCallback(subscriptionId: string) {
|
|
133
|
+
this.debugLog("📋 [RealtimeService] Removing callback for subscription:", subscriptionId);
|
|
134
|
+
this.subscriptionCallbacks.delete(subscriptionId);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// =============================================================================
|
|
138
|
+
// RealtimeProvider Interface Methods
|
|
139
|
+
// =============================================================================
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Subscribe to collection changes (RealtimeProvider interface)
|
|
143
|
+
*/
|
|
144
|
+
subscribeToCollection(
|
|
145
|
+
subscriptionId: string,
|
|
146
|
+
config: CollectionSubscriptionConfig,
|
|
147
|
+
callback?: (entities: Entity[]) => void
|
|
148
|
+
): void {
|
|
149
|
+
this._subscriptions.set(subscriptionId, {
|
|
150
|
+
clientId: config.clientId,
|
|
151
|
+
type: "collection",
|
|
152
|
+
path: config.path,
|
|
153
|
+
collectionRequest: {
|
|
154
|
+
filter: config.filter as Record<string, unknown> | undefined,
|
|
155
|
+
orderBy: config.orderBy,
|
|
156
|
+
order: config.order,
|
|
157
|
+
limit: config.limit,
|
|
158
|
+
startAfter: config.startAfter as Record<string, unknown> | undefined,
|
|
159
|
+
databaseId: config.databaseId,
|
|
160
|
+
searchString: config.searchString
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (callback) {
|
|
165
|
+
this.subscriptionCallbacks.set(subscriptionId, callback as (data: Entity[] | Entity | null) => void);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Subscribe to single entity changes (RealtimeProvider interface)
|
|
171
|
+
*/
|
|
172
|
+
subscribeToEntity(
|
|
173
|
+
subscriptionId: string,
|
|
174
|
+
config: EntitySubscriptionConfig,
|
|
175
|
+
callback?: (entity: Entity | null) => void
|
|
176
|
+
): void {
|
|
177
|
+
this._subscriptions.set(subscriptionId, {
|
|
178
|
+
clientId: config.clientId,
|
|
179
|
+
type: "entity",
|
|
180
|
+
path: config.path,
|
|
181
|
+
entityId: config.entityId
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (callback) {
|
|
185
|
+
this.subscriptionCallbacks.set(subscriptionId, callback as (data: Entity[] | Entity | null) => void);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Unsubscribe from a subscription (RealtimeProvider interface)
|
|
191
|
+
*/
|
|
192
|
+
unsubscribe(subscriptionId: string): void {
|
|
193
|
+
this._subscriptions.delete(subscriptionId);
|
|
194
|
+
this.subscriptionCallbacks.delete(subscriptionId);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// =============================================================================
|
|
198
|
+
// WebSocket Client Management
|
|
199
|
+
// =============================================================================
|
|
200
|
+
|
|
201
|
+
addClient(clientId: string, ws: WebSocket) {
|
|
202
|
+
this.clients.set(clientId, ws);
|
|
203
|
+
|
|
204
|
+
ws.on("close", () => {
|
|
205
|
+
this.removeClient(clientId);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
ws.on("error", (error) => {
|
|
209
|
+
console.error("WebSocket error for client", clientId, error);
|
|
210
|
+
this.removeClient(clientId);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Public method to handle messages from external sources (like main WebSocket handler)
|
|
215
|
+
async handleClientMessage(clientId: string, message: WebSocketMessage, authContext?: SubscriptionAuthContext) {
|
|
216
|
+
await this.handleMessage(clientId, message, authContext);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async removeClient(clientId: string) {
|
|
220
|
+
this.clients.delete(clientId);
|
|
221
|
+
|
|
222
|
+
// Remove all subscriptions, callbacks, and pending refetch timers for this client
|
|
223
|
+
for (const [subscriptionId, subscription] of this._subscriptions.entries()) {
|
|
224
|
+
if (subscription.clientId === clientId) {
|
|
225
|
+
this._subscriptions.delete(subscriptionId);
|
|
226
|
+
this.subscriptionCallbacks.delete(subscriptionId);
|
|
227
|
+
|
|
228
|
+
// Cancel any pending debounced refetch timers
|
|
229
|
+
for (const prefix of ["ws_", "drv_", "wse_", "drve_"]) {
|
|
230
|
+
const key = `${prefix}${subscriptionId}`;
|
|
231
|
+
const timer = this.refetchTimers.get(key);
|
|
232
|
+
if (timer) { clearTimeout(timer); this.refetchTimers.delete(key); }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private async handleMessage(clientId: string, message: WebSocketMessage, authContext?: SubscriptionAuthContext) {
|
|
239
|
+
switch (message.type) {
|
|
240
|
+
case "subscribe_collection":
|
|
241
|
+
await this.handleCollectionSubscription(clientId, message.payload as RealTimeListenCollectionProps, authContext);
|
|
242
|
+
break;
|
|
243
|
+
case "subscribe_entity":
|
|
244
|
+
await this.handleEntitySubscription(clientId, message.payload as RealTimeListenEntityProps, authContext);
|
|
245
|
+
break;
|
|
246
|
+
case "unsubscribe":
|
|
247
|
+
await this.handleUnsubscribe(clientId, message.subscriptionId!);
|
|
248
|
+
break;
|
|
249
|
+
default:
|
|
250
|
+
this.sendError(clientId, "Unknown message type " + message.type, message.subscriptionId);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async handleCollectionSubscription(clientId: string, request: RealTimeListenCollectionProps, authContext?: SubscriptionAuthContext) {
|
|
255
|
+
const subscriptionId = request.subscriptionId;
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
// Early validation: ensure the requested collection exists in the registry
|
|
259
|
+
const collection = this.registry.getCollectionByPath(request.path);
|
|
260
|
+
if (!collection) {
|
|
261
|
+
const registered = this.registry.getCollections().map(c => c.slug).join(", ");
|
|
262
|
+
const msg = `Collection not found: '${request.path}'. Registered: [${registered}]`;
|
|
263
|
+
console.error(`[RealtimeService] ${msg}`);
|
|
264
|
+
this.sendError(clientId, msg, subscriptionId);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Store subscription with full request parameters and auth context for RLS
|
|
269
|
+
this._subscriptions.set(subscriptionId, {
|
|
270
|
+
clientId,
|
|
271
|
+
type: "collection",
|
|
272
|
+
path: request.path,
|
|
273
|
+
collectionRequest: {
|
|
274
|
+
filter: request.filter,
|
|
275
|
+
orderBy: request.orderBy,
|
|
276
|
+
order: request.order,
|
|
277
|
+
limit: request.limit,
|
|
278
|
+
startAfter: request.startAfter as Record<string, unknown> | undefined,
|
|
279
|
+
databaseId: request.collection?.databaseId,
|
|
280
|
+
searchString: request.searchString
|
|
281
|
+
},
|
|
282
|
+
authContext
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Send initial data
|
|
286
|
+
let entities;
|
|
287
|
+
if (this.driver) {
|
|
288
|
+
entities = await this.driver.fetchCollection({
|
|
289
|
+
path: request.path,
|
|
290
|
+
collection: collection,
|
|
291
|
+
filter: request.filter,
|
|
292
|
+
orderBy: request.orderBy,
|
|
293
|
+
order: request.order,
|
|
294
|
+
limit: request.limit,
|
|
295
|
+
startAfter: request.startAfter,
|
|
296
|
+
searchString: request.searchString
|
|
297
|
+
});
|
|
298
|
+
} else {
|
|
299
|
+
entities = await this.entityService.fetchCollection(request.path, {
|
|
300
|
+
filter: request.filter,
|
|
301
|
+
orderBy: request.orderBy,
|
|
302
|
+
order: request.order,
|
|
303
|
+
limit: request.limit,
|
|
304
|
+
startAfter: request.startAfter as Record<string, unknown> | undefined,
|
|
305
|
+
databaseId: request.collection?.databaseId,
|
|
306
|
+
searchString: request.searchString
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
this.sendCollectionUpdate(clientId, subscriptionId, entities);
|
|
311
|
+
|
|
312
|
+
} catch (error) {
|
|
313
|
+
this.sendError(clientId, `Failed to subscribe to collection: ${error}`, subscriptionId);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private async handleEntitySubscription(clientId: string, request: RealTimeListenEntityProps, authContext?: SubscriptionAuthContext) {
|
|
318
|
+
const subscriptionId = request.subscriptionId;
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
// Early validation: ensure the requested collection exists in the registry
|
|
322
|
+
const collection = this.registry.getCollectionByPath(request.path);
|
|
323
|
+
if (!collection) {
|
|
324
|
+
const registered = this.registry.getCollections().map(c => c.slug).join(", ");
|
|
325
|
+
const msg = `Collection not found: '${request.path}'. Registered: [${registered}]`;
|
|
326
|
+
console.error(`[RealtimeService] ${msg}`);
|
|
327
|
+
this.sendError(clientId, msg, subscriptionId);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Store subscription in memory with auth context for RLS
|
|
332
|
+
this._subscriptions.set(subscriptionId, {
|
|
333
|
+
clientId,
|
|
334
|
+
type: "entity",
|
|
335
|
+
path: request.path,
|
|
336
|
+
entityId: request.entityId,
|
|
337
|
+
authContext
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Send initial data
|
|
341
|
+
let entity;
|
|
342
|
+
if (this.driver) {
|
|
343
|
+
entity = await this.driver.fetchEntity({
|
|
344
|
+
path: request.path,
|
|
345
|
+
entityId: request.entityId,
|
|
346
|
+
collection: collection
|
|
347
|
+
});
|
|
348
|
+
} else {
|
|
349
|
+
entity = await this.entityService.fetchEntity(
|
|
350
|
+
request.path,
|
|
351
|
+
request.entityId,
|
|
352
|
+
request.collection?.databaseId
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
this.sendEntityUpdate(clientId, subscriptionId, entity || null);
|
|
357
|
+
|
|
358
|
+
} catch (error) {
|
|
359
|
+
this.sendError(clientId, `Failed to subscribe to entity: ${request.path} ${request.entityId} ${error}`, subscriptionId);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private async handleUnsubscribe(_clientId: string, subscriptionId: string) {
|
|
364
|
+
this._subscriptions.delete(subscriptionId);
|
|
365
|
+
this.subscriptionCallbacks.delete(subscriptionId);
|
|
366
|
+
// Cancel any pending debounced refetch
|
|
367
|
+
for (const prefix of ["ws_", "drv_", "wse_", "drve_"]) {
|
|
368
|
+
const key = `${prefix}${subscriptionId}`;
|
|
369
|
+
const timer = this.refetchTimers.get(key);
|
|
370
|
+
if (timer) { clearTimeout(timer); this.refetchTimers.delete(key); }
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Enhanced notification method that handles nested relation updates.
|
|
376
|
+
* @param broadcast When true (default), also sends a pg_notify so other instances
|
|
377
|
+
* pick up the change. Set to false when handling an incoming
|
|
378
|
+
* cross-instance notification to avoid infinite loops.
|
|
379
|
+
*/
|
|
380
|
+
async notifyEntityUpdate(path: string, entityId: string, entity: Entity | null, databaseId?: string, broadcast = true) {
|
|
381
|
+
this.debugLog("🔔 [RealtimeService] notifyEntityUpdate called for path:", path, "entityId:", entityId, "isDelete:", entity === null);
|
|
382
|
+
|
|
383
|
+
// Get all paths that need to be notified - the direct path plus any parent paths
|
|
384
|
+
const pathsToNotify = [path];
|
|
385
|
+
|
|
386
|
+
// If this is a nested relation path (like "posts/70/tags"), also notify parent paths
|
|
387
|
+
if (path.includes("/") && path.split("/").length > 1) {
|
|
388
|
+
const parentPaths = this.getParentPaths(path);
|
|
389
|
+
pathsToNotify.push(...parentPaths);
|
|
390
|
+
this.debugLog(`🔗 [RealtimeService] Nested path detected. Will notify paths: ${pathsToNotify.join(", ")}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Process each path that needs notification
|
|
394
|
+
for (const notifyPath of pathsToNotify) {
|
|
395
|
+
await this.notifyPathUpdate(notifyPath, path, entityId, entity, databaseId);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Broadcast to other instances via pg_notify (only for local mutations)
|
|
399
|
+
if (broadcast && this.broadcasting) {
|
|
400
|
+
try {
|
|
401
|
+
await this.broadcastChange(path, entityId, databaseId);
|
|
402
|
+
} catch (err) {
|
|
403
|
+
console.error("❌ [RealtimeService] Failed to broadcast change via pg_notify:", err);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
this.debugLog("🔔 [RealtimeService] notifyEntityUpdate completed for path:", path);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Notify subscriptions for a specific path
|
|
412
|
+
*/
|
|
413
|
+
private async notifyPathUpdate(notifyPath: string, originalPath: string, entityId: string, entity: Entity | null, _databaseId?: string) {
|
|
414
|
+
this.debugLog(`📡 [RealtimeService] Notifying path: ${notifyPath} (original: ${originalPath})`);
|
|
415
|
+
|
|
416
|
+
// Find all relevant subscriptions for this specific path
|
|
417
|
+
const allSubscriptions = Array.from(this._subscriptions.entries()).filter(([, sub]) => {
|
|
418
|
+
const isPathMatch = sub.path === notifyPath;
|
|
419
|
+
|
|
420
|
+
// For entity subscriptions, check if the entityId matches (only for exact path matches)
|
|
421
|
+
if (sub.type === "entity") {
|
|
422
|
+
return isPathMatch && (notifyPath === originalPath ? sub.entityId === entityId : true);
|
|
423
|
+
}
|
|
424
|
+
// For collection subscriptions, it's always relevant if the path matches
|
|
425
|
+
if (sub.type === "collection") {
|
|
426
|
+
return isPathMatch;
|
|
427
|
+
}
|
|
428
|
+
return false;
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
this.debugLog(`📡 [RealtimeService] Found ${allSubscriptions.length} subscriptions for path: ${notifyPath}`);
|
|
432
|
+
|
|
433
|
+
// Separate WebSocket subscriptions from DataDriver callback subscriptions
|
|
434
|
+
const webSocketSubscriptions = allSubscriptions.filter(([, sub]) =>
|
|
435
|
+
sub.clientId !== "driver" && this.clients.has(sub.clientId)
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
const driverSubscriptions = allSubscriptions.filter(([subscriptionId, sub]) =>
|
|
439
|
+
sub.clientId === "driver" && this.subscriptionCallbacks.has(subscriptionId)
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
// Handle WebSocket subscriptions
|
|
443
|
+
for (const [subscriptionId, subscription] of webSocketSubscriptions) {
|
|
444
|
+
try {
|
|
445
|
+
if (subscription.type === "entity" && notifyPath === originalPath) {
|
|
446
|
+
// Send entity update directly (only for exact path matches)
|
|
447
|
+
if (entity && (entity as unknown as Record<string, unknown>).values && ((entity as unknown as Record<string, unknown>).values as Record<string, unknown>)?._rebase_invalidated) {
|
|
448
|
+
this.debouncedEntityRefetch(subscriptionId, notifyPath, entityId, subscription);
|
|
449
|
+
} else {
|
|
450
|
+
this.sendEntityUpdate(subscription.clientId, subscriptionId, entity);
|
|
451
|
+
}
|
|
452
|
+
} else if (subscription.type === "collection" && subscription.collectionRequest) {
|
|
453
|
+
// Phase 1: Send instant entity-level patch (no DB query)
|
|
454
|
+
// This gives immediate cross-tab feedback
|
|
455
|
+
if (!entity || !((entity as unknown as Record<string, unknown>).values && ((entity as unknown as Record<string, unknown>).values as Record<string, unknown>)?._rebase_invalidated)) {
|
|
456
|
+
this.sendCollectionEntityPatch(subscription.clientId, subscriptionId, entityId, entity);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Phase 2: Schedule a deferred full refetch for correctness
|
|
460
|
+
// Handles filter/sort changes and ensures consistency
|
|
461
|
+
this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
|
|
462
|
+
}
|
|
463
|
+
} catch (error) {
|
|
464
|
+
console.error(`❌ [RealtimeService] Error processing WebSocket subscription ${subscriptionId}:`, error);
|
|
465
|
+
this.sendError(subscription.clientId, `Failed to process update for subscription ${subscriptionId}`, subscriptionId);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Handle DataDriver callback subscriptions
|
|
470
|
+
for (const [subscriptionId, subscription] of driverSubscriptions) {
|
|
471
|
+
try {
|
|
472
|
+
const callback = this.subscriptionCallbacks.get(subscriptionId);
|
|
473
|
+
if (!callback) continue;
|
|
474
|
+
|
|
475
|
+
if (subscription.type === "entity" && notifyPath === originalPath) {
|
|
476
|
+
if (entity && (entity as unknown as Record<string, unknown>).values && ((entity as unknown as Record<string, unknown>).values as Record<string, unknown>)?._rebase_invalidated) {
|
|
477
|
+
this.debouncedEntityDriverRefetch(subscriptionId, notifyPath, entityId, subscription, callback);
|
|
478
|
+
} else {
|
|
479
|
+
// Call the callback directly with the entity (only for exact path matches)
|
|
480
|
+
callback(entity);
|
|
481
|
+
}
|
|
482
|
+
} else if (subscription.type === "collection" && subscription.collectionRequest) {
|
|
483
|
+
// Debounce collection refetches for DataDriver subscriptions too
|
|
484
|
+
this.debouncedDriverRefetch(subscriptionId, notifyPath, subscription, callback);
|
|
485
|
+
}
|
|
486
|
+
} catch (error) {
|
|
487
|
+
console.error(`❌ [RealtimeService] Error processing DataDriver subscription ${subscriptionId}:`, error);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Debounce a collection refetch for a WebSocket subscription.
|
|
494
|
+
* Coalesces rapid entity mutations into a single database query.
|
|
495
|
+
*/
|
|
496
|
+
private debouncedCollectionRefetch(
|
|
497
|
+
subscriptionId: string,
|
|
498
|
+
notifyPath: string,
|
|
499
|
+
subscription: { clientId: string; collectionRequest?: { filter?: Record<string, unknown>; orderBy?: string; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record<string, unknown>; databaseId?: string; searchString?: string }; authContext?: SubscriptionAuthContext }
|
|
500
|
+
) {
|
|
501
|
+
const timerKey = `ws_${subscriptionId}`;
|
|
502
|
+
const existing = this.refetchTimers.get(timerKey);
|
|
503
|
+
if (existing) clearTimeout(existing);
|
|
504
|
+
|
|
505
|
+
this.refetchTimers.set(timerKey, setTimeout(async () => {
|
|
506
|
+
this.refetchTimers.delete(timerKey);
|
|
507
|
+
// Verify subscription still exists (client may have disconnected)
|
|
508
|
+
if (!this._subscriptions.has(subscriptionId)) return;
|
|
509
|
+
try {
|
|
510
|
+
const entities = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest!, subscription.authContext);
|
|
511
|
+
this.sendCollectionUpdate(subscription.clientId, subscriptionId, entities);
|
|
512
|
+
} catch (error) {
|
|
513
|
+
console.error(`❌ [RealtimeService] Error in debounced refetch for ${subscriptionId}:`, error);
|
|
514
|
+
this.sendError(subscription.clientId, `Failed to process update for subscription ${subscriptionId}`, subscriptionId);
|
|
515
|
+
}
|
|
516
|
+
}, RealtimeService.REFETCH_DEBOUNCE_MS));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Debounce a collection refetch for a DataDriver callback subscription.
|
|
521
|
+
*/
|
|
522
|
+
private debouncedDriverRefetch(
|
|
523
|
+
subscriptionId: string,
|
|
524
|
+
notifyPath: string,
|
|
525
|
+
subscription: { collectionRequest?: { filter?: Record<string, unknown>; orderBy?: string; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record<string, unknown>; databaseId?: string; searchString?: string }; authContext?: SubscriptionAuthContext },
|
|
526
|
+
callback: (data: Entity[] | Entity | null) => void
|
|
527
|
+
) {
|
|
528
|
+
const timerKey = `drv_${subscriptionId}`;
|
|
529
|
+
const existing = this.refetchTimers.get(timerKey);
|
|
530
|
+
if (existing) clearTimeout(existing);
|
|
531
|
+
|
|
532
|
+
this.refetchTimers.set(timerKey, setTimeout(async () => {
|
|
533
|
+
this.refetchTimers.delete(timerKey);
|
|
534
|
+
if (!this._subscriptions.has(subscriptionId)) return;
|
|
535
|
+
try {
|
|
536
|
+
const entities = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest!, subscription.authContext);
|
|
537
|
+
callback(entities);
|
|
538
|
+
} catch (error) {
|
|
539
|
+
console.error(`❌ [RealtimeService] Error in debounced driver refetch for ${subscriptionId}:`, error);
|
|
540
|
+
}
|
|
541
|
+
}, RealtimeService.REFETCH_DEBOUNCE_MS));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Fetch a collection with optional RLS auth context.
|
|
546
|
+
* When authContext is provided, the fetch runs inside a transaction
|
|
547
|
+
* with set_config calls so PostgreSQL RLS policies are enforced.
|
|
548
|
+
*/
|
|
549
|
+
private async fetchCollectionWithAuth(
|
|
550
|
+
notifyPath: string,
|
|
551
|
+
collectionRequest: { filter?: Record<string, unknown>; orderBy?: string; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record<string, unknown>; databaseId?: string; searchString?: string },
|
|
552
|
+
authContext?: SubscriptionAuthContext
|
|
553
|
+
): Promise<Entity[]> {
|
|
554
|
+
if (this.driver) {
|
|
555
|
+
const collection = this.registry.getCollectionByPath(notifyPath);
|
|
556
|
+
const fetchFn = async () => this.driver!.fetchCollection({
|
|
557
|
+
path: notifyPath,
|
|
558
|
+
collection: collection,
|
|
559
|
+
filter: collectionRequest.filter as FetchCollectionProps["filter"],
|
|
560
|
+
orderBy: collectionRequest.orderBy,
|
|
561
|
+
order: collectionRequest.order,
|
|
562
|
+
limit: collectionRequest.limit,
|
|
563
|
+
offset: collectionRequest.offset,
|
|
564
|
+
startAfter: collectionRequest.startAfter,
|
|
565
|
+
searchString: collectionRequest.searchString
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
// If we have auth context, wrap in a transaction with session vars
|
|
569
|
+
if (authContext) {
|
|
570
|
+
return await this.db.transaction(async (tx) => {
|
|
571
|
+
await tx.execute(drizzleSql`SELECT set_config('app.user_id', ${authContext.userId}, true)`);
|
|
572
|
+
await tx.execute(drizzleSql`SELECT set_config('app.user_roles', ${authContext.roles.join(",")}, true)`);
|
|
573
|
+
await tx.execute(drizzleSql`SELECT set_config('app.jwt', ${JSON.stringify({ sub: authContext.userId,
|
|
574
|
+
roles: authContext.roles })}, true)`);
|
|
575
|
+
const txEntityService = new EntityService(tx, this.registry);
|
|
576
|
+
let fetchedEntities;
|
|
577
|
+
if (collectionRequest.searchString) {
|
|
578
|
+
fetchedEntities = await txEntityService.searchEntities(
|
|
579
|
+
notifyPath,
|
|
580
|
+
collectionRequest.searchString,
|
|
581
|
+
{
|
|
582
|
+
filter: collectionRequest.filter as FilterValues<string>,
|
|
583
|
+
orderBy: collectionRequest.orderBy,
|
|
584
|
+
order: collectionRequest.order,
|
|
585
|
+
limit: collectionRequest.limit,
|
|
586
|
+
databaseId: collectionRequest.databaseId
|
|
587
|
+
}
|
|
588
|
+
);
|
|
589
|
+
} else {
|
|
590
|
+
fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
|
|
591
|
+
filter: collectionRequest.filter as FilterValues<string>,
|
|
592
|
+
orderBy: collectionRequest.orderBy,
|
|
593
|
+
order: collectionRequest.order,
|
|
594
|
+
limit: collectionRequest.limit,
|
|
595
|
+
offset: collectionRequest.offset,
|
|
596
|
+
startAfter: collectionRequest.startAfter,
|
|
597
|
+
databaseId: collectionRequest.databaseId
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Re-apply `afterRead` lifecycle hooks to ensure consistent data structures
|
|
602
|
+
// between the initial driver fetch and this RLS-bound refetch.
|
|
603
|
+
const registryCollection = this.registry.getCollectionByPath(notifyPath);
|
|
604
|
+
const resolvedCollection = collection ? { ...collection,
|
|
605
|
+
...registryCollection } as EntityCollection : registryCollection as EntityCollection;
|
|
606
|
+
|
|
607
|
+
const callbacks = resolvedCollection?.callbacks;
|
|
608
|
+
const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
|
|
609
|
+
|
|
610
|
+
if (callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
611
|
+
const contextForCallback = {
|
|
612
|
+
user: { uid: authContext.userId,
|
|
613
|
+
roles: authContext.roles },
|
|
614
|
+
driver: this.driver,
|
|
615
|
+
data: this.driver ? (this.driver as unknown as Record<string, unknown>).data : undefined
|
|
616
|
+
} as unknown as RebaseCallContext;
|
|
617
|
+
|
|
618
|
+
return await Promise.all(fetchedEntities.map(async (entity) => {
|
|
619
|
+
let processedEntity = entity;
|
|
620
|
+
if (callbacks?.afterRead) {
|
|
621
|
+
processedEntity = await callbacks.afterRead({
|
|
622
|
+
collection: resolvedCollection,
|
|
623
|
+
path: notifyPath,
|
|
624
|
+
entity: processedEntity,
|
|
625
|
+
context: contextForCallback
|
|
626
|
+
}) ?? processedEntity;
|
|
627
|
+
}
|
|
628
|
+
if (propertyCallbacks?.afterRead) {
|
|
629
|
+
processedEntity = await propertyCallbacks.afterRead({
|
|
630
|
+
collection: resolvedCollection,
|
|
631
|
+
path: notifyPath,
|
|
632
|
+
entity: processedEntity,
|
|
633
|
+
context: contextForCallback
|
|
634
|
+
}) ?? processedEntity;
|
|
635
|
+
}
|
|
636
|
+
return processedEntity;
|
|
637
|
+
}));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return fetchedEntities;
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return fetchFn();
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// No driver — use entityService directly (no auth wrapping possible)
|
|
648
|
+
if (collectionRequest.searchString) {
|
|
649
|
+
return await this.entityService.searchEntities(
|
|
650
|
+
notifyPath,
|
|
651
|
+
collectionRequest.searchString,
|
|
652
|
+
{
|
|
653
|
+
filter: collectionRequest.filter as FilterValues<string>,
|
|
654
|
+
orderBy: collectionRequest.orderBy,
|
|
655
|
+
order: collectionRequest.order,
|
|
656
|
+
limit: collectionRequest.limit,
|
|
657
|
+
databaseId: collectionRequest.databaseId
|
|
658
|
+
}
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
return await this.entityService.fetchCollection(notifyPath, {
|
|
662
|
+
filter: collectionRequest.filter as FilterValues<string>,
|
|
663
|
+
orderBy: collectionRequest.orderBy,
|
|
664
|
+
order: collectionRequest.order,
|
|
665
|
+
limit: collectionRequest.limit,
|
|
666
|
+
offset: collectionRequest.offset,
|
|
667
|
+
startAfter: collectionRequest.startAfter,
|
|
668
|
+
databaseId: collectionRequest.databaseId
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Debounce an entity refetch for a WebSocket subscription.
|
|
674
|
+
*/
|
|
675
|
+
private debouncedEntityRefetch(
|
|
676
|
+
subscriptionId: string,
|
|
677
|
+
notifyPath: string,
|
|
678
|
+
entityId: string,
|
|
679
|
+
subscription: { clientId: string; authContext?: SubscriptionAuthContext }
|
|
680
|
+
) {
|
|
681
|
+
const timerKey = `wse_${subscriptionId}`;
|
|
682
|
+
const existing = this.refetchTimers.get(timerKey);
|
|
683
|
+
if (existing) clearTimeout(existing);
|
|
684
|
+
|
|
685
|
+
this.refetchTimers.set(timerKey, setTimeout(async () => {
|
|
686
|
+
this.refetchTimers.delete(timerKey);
|
|
687
|
+
if (!this._subscriptions.has(subscriptionId)) return;
|
|
688
|
+
try {
|
|
689
|
+
const entity = await this.fetchEntityWithAuth(notifyPath, entityId, subscription.authContext);
|
|
690
|
+
this.sendEntityUpdate(subscription.clientId, subscriptionId, entity || null);
|
|
691
|
+
} catch (error) {
|
|
692
|
+
console.error(`❌ [RealtimeService] Error in debounced entity refetch for ${subscriptionId}:`, error);
|
|
693
|
+
this.sendError(subscription.clientId, `Failed to process entity update for subscription ${subscriptionId}`, subscriptionId);
|
|
694
|
+
}
|
|
695
|
+
}, RealtimeService.REFETCH_DEBOUNCE_MS));
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Debounce an entity refetch for a Driver callback subscription.
|
|
700
|
+
*/
|
|
701
|
+
private debouncedEntityDriverRefetch(
|
|
702
|
+
subscriptionId: string,
|
|
703
|
+
notifyPath: string,
|
|
704
|
+
entityId: string,
|
|
705
|
+
subscription: { clientId: string; authContext?: SubscriptionAuthContext },
|
|
706
|
+
callback: (data: Entity[] | Entity | null) => void
|
|
707
|
+
) {
|
|
708
|
+
const timerKey = `drve_${subscriptionId}`;
|
|
709
|
+
const existing = this.refetchTimers.get(timerKey);
|
|
710
|
+
if (existing) clearTimeout(existing);
|
|
711
|
+
|
|
712
|
+
this.refetchTimers.set(timerKey, setTimeout(async () => {
|
|
713
|
+
this.refetchTimers.delete(timerKey);
|
|
714
|
+
if (!this._subscriptions.has(subscriptionId)) return;
|
|
715
|
+
try {
|
|
716
|
+
const entity = await this.fetchEntityWithAuth(notifyPath, entityId, subscription.authContext);
|
|
717
|
+
callback(entity || null);
|
|
718
|
+
} catch (error) {
|
|
719
|
+
console.error(`❌ [RealtimeService] Error in debounced entity driver refetch for ${subscriptionId}:`, error);
|
|
720
|
+
}
|
|
721
|
+
}, RealtimeService.REFETCH_DEBOUNCE_MS));
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Fetch a single entity with optional RLS auth context.
|
|
726
|
+
*/
|
|
727
|
+
private async fetchEntityWithAuth(
|
|
728
|
+
notifyPath: string,
|
|
729
|
+
entityId: string,
|
|
730
|
+
authContext?: SubscriptionAuthContext
|
|
731
|
+
): Promise<Entity | undefined> {
|
|
732
|
+
if (this.driver) {
|
|
733
|
+
const collection = this.registry.getCollectionByPath(notifyPath);
|
|
734
|
+
const fetchFn = async () => this.driver!.fetchEntity({
|
|
735
|
+
path: notifyPath,
|
|
736
|
+
entityId,
|
|
737
|
+
collection
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// If we have auth context, wrap in a transaction with session vars
|
|
741
|
+
if (authContext) {
|
|
742
|
+
return await this.db.transaction(async (tx) => {
|
|
743
|
+
await tx.execute(drizzleSql`SELECT set_config('app.user_id', ${authContext.userId}, true)`);
|
|
744
|
+
await tx.execute(drizzleSql`SELECT set_config('app.user_roles', ${authContext.roles.join(",")}, true)`);
|
|
745
|
+
await tx.execute(drizzleSql`SELECT set_config('app.jwt', ${JSON.stringify({ sub: authContext.userId,
|
|
746
|
+
roles: authContext.roles })}, true)`);
|
|
747
|
+
const txEntityService = new EntityService(tx, this.registry);
|
|
748
|
+
let processedEntity = await txEntityService.fetchEntity(notifyPath, entityId, collection?.databaseId);
|
|
749
|
+
|
|
750
|
+
if (processedEntity) {
|
|
751
|
+
const registryCollection = this.registry.getCollectionByPath(notifyPath);
|
|
752
|
+
const resolvedCollection = collection ? { ...collection,
|
|
753
|
+
...registryCollection } as EntityCollection : registryCollection as EntityCollection;
|
|
754
|
+
|
|
755
|
+
const callbacks = resolvedCollection?.callbacks;
|
|
756
|
+
const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
|
|
757
|
+
|
|
758
|
+
if (callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
759
|
+
const contextForCallback = {
|
|
760
|
+
user: { uid: authContext.userId,
|
|
761
|
+
roles: authContext.roles },
|
|
762
|
+
driver: this.driver,
|
|
763
|
+
data: this.driver ? (this.driver as unknown as Record<string, unknown>).data : undefined
|
|
764
|
+
} as unknown as RebaseCallContext;
|
|
765
|
+
|
|
766
|
+
if (callbacks?.afterRead) {
|
|
767
|
+
processedEntity = await callbacks.afterRead({
|
|
768
|
+
collection: resolvedCollection,
|
|
769
|
+
path: notifyPath,
|
|
770
|
+
entity: processedEntity,
|
|
771
|
+
context: contextForCallback
|
|
772
|
+
}) ?? processedEntity;
|
|
773
|
+
}
|
|
774
|
+
if (propertyCallbacks?.afterRead) {
|
|
775
|
+
processedEntity = await propertyCallbacks.afterRead({
|
|
776
|
+
collection: resolvedCollection,
|
|
777
|
+
path: notifyPath,
|
|
778
|
+
entity: processedEntity,
|
|
779
|
+
context: contextForCallback
|
|
780
|
+
}) ?? processedEntity;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return processedEntity;
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return fetchFn();
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return await this.entityService.fetchEntity(notifyPath, entityId);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private sendCollectionUpdate(clientId: string, subscriptionId: string, entities: Entity[]) {
|
|
796
|
+
const message: CollectionUpdateMessage = {
|
|
797
|
+
type: "collection_update",
|
|
798
|
+
subscriptionId,
|
|
799
|
+
entities: entities as Entity<Record<string, unknown>>[]
|
|
800
|
+
};
|
|
801
|
+
this.sendMessage(clientId, message);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private sendEntityUpdate(clientId: string, subscriptionId: string, entity: Entity | null) {
|
|
805
|
+
const message: EntityUpdateMessage = {
|
|
806
|
+
type: "entity_update",
|
|
807
|
+
subscriptionId,
|
|
808
|
+
entity: entity as Entity<Record<string, unknown>> | null
|
|
809
|
+
};
|
|
810
|
+
this.sendMessage(clientId, message);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Send a lightweight entity-level patch to a collection subscriber.
|
|
815
|
+
* The client can merge this into its cached data for instant feedback.
|
|
816
|
+
*/
|
|
817
|
+
private sendCollectionEntityPatch(clientId: string, subscriptionId: string, entityId: string, entity: Entity | null) {
|
|
818
|
+
const message: CollectionEntityPatchMessage = {
|
|
819
|
+
type: "collection_entity_patch",
|
|
820
|
+
subscriptionId,
|
|
821
|
+
entityId,
|
|
822
|
+
entity: entity as Entity<Record<string, unknown>> | null
|
|
823
|
+
};
|
|
824
|
+
this.sendMessage(clientId, message);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
private sendError(clientId: string, error: string, subscriptionId?: string) {
|
|
828
|
+
console.error("Error handling collection subscription:", error);
|
|
829
|
+
const message = {
|
|
830
|
+
type: "error" as const,
|
|
831
|
+
subscriptionId,
|
|
832
|
+
error
|
|
833
|
+
};
|
|
834
|
+
this.sendMessage(clientId, message);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
private sendMessage(clientId: string, message: CollectionUpdateMessage | EntityUpdateMessage | CollectionEntityPatchMessage | { type: string; subscriptionId?: string; error?: string }) {
|
|
838
|
+
const client = this.clients.get(clientId);
|
|
839
|
+
if (client && client.readyState === WebSocket.OPEN) {
|
|
840
|
+
client.send(JSON.stringify(message));
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Extract parent paths from a nested path like "posts/70/tags"
|
|
846
|
+
* Returns ["posts", "posts/70"] for the example above
|
|
847
|
+
*/
|
|
848
|
+
private getParentPaths(path: string): string[] {
|
|
849
|
+
const segments = path.split("/").filter(s => s.length > 0);
|
|
850
|
+
const parentPaths: string[] = [];
|
|
851
|
+
|
|
852
|
+
// Build parent paths progressively
|
|
853
|
+
for (let i = 1; i < segments.length; i += 2) {
|
|
854
|
+
const parentPath = segments.slice(0, i).join("/");
|
|
855
|
+
if (parentPath) {
|
|
856
|
+
parentPaths.push(parentPath);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// If there's an entity ID, add the path including the entity
|
|
860
|
+
if (i + 1 < segments.length) {
|
|
861
|
+
const pathWithEntity = segments.slice(0, i + 1).join("/");
|
|
862
|
+
parentPaths.push(pathWithEntity);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return parentPaths;
|
|
867
|
+
}
|
|
868
|
+
// =============================================================================
|
|
869
|
+
// Cross-Instance LISTEN/NOTIFY
|
|
870
|
+
// =============================================================================
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Enable cross-instance realtime broadcasting via Postgres LISTEN/NOTIFY.
|
|
874
|
+
* Creates a dedicated pg.Client (outside the Drizzle pool) that stays
|
|
875
|
+
* connected and listens for change notifications from other instances.
|
|
876
|
+
*
|
|
877
|
+
* This is an **optional** feature — if never called, the backend operates
|
|
878
|
+
* in single-instance mode (the default, perfectly fine for most setups).
|
|
879
|
+
*
|
|
880
|
+
* @param connectionString Raw Postgres connection string for the LISTEN client.
|
|
881
|
+
*/
|
|
882
|
+
async startListening(connectionString: string): Promise<void> {
|
|
883
|
+
if (this.broadcasting) {
|
|
884
|
+
console.warn("⚠️ [RealtimeService] startListening called but already listening. Ignoring.");
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
this.listenConnectionString = connectionString;
|
|
889
|
+
// Set broadcasting BEFORE connecting so that scheduleReconnect()
|
|
890
|
+
// works correctly if the initial connection attempt fails.
|
|
891
|
+
this.broadcasting = true;
|
|
892
|
+
await this.connectListenClient();
|
|
893
|
+
console.log(`📡 [RealtimeService] Cross-instance realtime enabled (instanceId: ${this.instanceId})`);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Stop listening and clean up the dedicated LISTEN connection.
|
|
898
|
+
*/
|
|
899
|
+
async stopListening(): Promise<void> {
|
|
900
|
+
this.broadcasting = false;
|
|
901
|
+
if (this.reconnectTimer) {
|
|
902
|
+
clearTimeout(this.reconnectTimer);
|
|
903
|
+
this.reconnectTimer = undefined;
|
|
904
|
+
}
|
|
905
|
+
if (this.listenClient) {
|
|
906
|
+
try {
|
|
907
|
+
await this.listenClient.end();
|
|
908
|
+
} catch { /* ignore close errors */ }
|
|
909
|
+
this.listenClient = undefined;
|
|
910
|
+
}
|
|
911
|
+
console.log("📡 [RealtimeService] Cross-instance realtime disabled.");
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Broadcast a change notification to other instances via pg_notify.
|
|
916
|
+
* Uses the main Drizzle connection (pooled) — NOT the LISTEN client.
|
|
917
|
+
*/
|
|
918
|
+
private async broadcastChange(path: string, entityId: string, databaseId?: string): Promise<void> {
|
|
919
|
+
const payload = JSON.stringify({
|
|
920
|
+
sid: this.instanceId,
|
|
921
|
+
p: path,
|
|
922
|
+
eid: entityId,
|
|
923
|
+
db: databaseId ?? null
|
|
924
|
+
});
|
|
925
|
+
await this.db.execute(drizzleSql`SELECT pg_notify(${PG_NOTIFY_CHANNEL}, ${payload})`);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Create and connect the dedicated LISTEN client with auto-reconnect.
|
|
930
|
+
*/
|
|
931
|
+
private async connectListenClient(): Promise<void> {
|
|
932
|
+
if (!this.listenConnectionString) return;
|
|
933
|
+
|
|
934
|
+
try {
|
|
935
|
+
const client = new PgClient({ connectionString: this.listenConnectionString });
|
|
936
|
+
|
|
937
|
+
client.on("error", (err) => {
|
|
938
|
+
console.error("❌ [RealtimeService] LISTEN client error:", err.message);
|
|
939
|
+
this.scheduleReconnect();
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
client.on("end", () => {
|
|
943
|
+
if (this.broadcasting) {
|
|
944
|
+
console.warn("⚠️ [RealtimeService] LISTEN client disconnected unexpectedly.");
|
|
945
|
+
this.scheduleReconnect();
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
client.on("notification", async (msg) => {
|
|
950
|
+
if (!msg.payload) return;
|
|
951
|
+
try {
|
|
952
|
+
const { sid, p, eid, db } = JSON.parse(msg.payload) as {
|
|
953
|
+
sid: string;
|
|
954
|
+
p: string;
|
|
955
|
+
eid: string;
|
|
956
|
+
db: string | null;
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
// Skip our own notifications — already processed locally
|
|
960
|
+
if (sid === this.instanceId) return;
|
|
961
|
+
|
|
962
|
+
this.debugLog(`📡 [RealtimeService] Received cross-instance notification: path=${p}, entityId=${eid}, from=${sid}`);
|
|
963
|
+
|
|
964
|
+
// Refetch the entity from the DB so entity subscriptions
|
|
965
|
+
// receive the actual data instead of null (which the client
|
|
966
|
+
// would interpret as "deleted").
|
|
967
|
+
let entity: Entity | null = null;
|
|
968
|
+
try {
|
|
969
|
+
if (this.driver) {
|
|
970
|
+
const collection = this.registry.getCollectionByPath(p);
|
|
971
|
+
const fetched = await this.driver.fetchEntity({
|
|
972
|
+
path: p,
|
|
973
|
+
entityId: eid,
|
|
974
|
+
collection: collection
|
|
975
|
+
});
|
|
976
|
+
entity = fetched ?? null;
|
|
977
|
+
} else {
|
|
978
|
+
const fetched = await this.entityService.fetchEntity(
|
|
979
|
+
p, eid, db ?? undefined
|
|
980
|
+
);
|
|
981
|
+
entity = fetched ?? null;
|
|
982
|
+
}
|
|
983
|
+
} catch (fetchErr) {
|
|
984
|
+
// If the fetch fails (e.g. entity was deleted), entity stays null
|
|
985
|
+
this.debugLog(`📡 [RealtimeService] Could not refetch entity ${eid} from ${p} — treating as deleted`, fetchErr);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Trigger local fan-out with broadcast=false to avoid re-broadcasting
|
|
989
|
+
await this.notifyEntityUpdate(p, eid, entity, db ?? undefined, false);
|
|
990
|
+
} catch (err) {
|
|
991
|
+
console.error("❌ [RealtimeService] Error processing cross-instance notification:", err);
|
|
992
|
+
}
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
await client.connect();
|
|
996
|
+
await client.query(`LISTEN ${PG_NOTIFY_CHANNEL}`);
|
|
997
|
+
this.listenClient = client;
|
|
998
|
+
|
|
999
|
+
this.debugLog(`📡 [RealtimeService] LISTEN client connected on channel "${PG_NOTIFY_CHANNEL}"`);
|
|
1000
|
+
} catch (err) {
|
|
1001
|
+
console.error("❌ [RealtimeService] Failed to connect LISTEN client:", err);
|
|
1002
|
+
this.scheduleReconnect();
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Schedule a reconnection attempt with a fixed 3s delay.
|
|
1008
|
+
*/
|
|
1009
|
+
private scheduleReconnect(): void {
|
|
1010
|
+
if (!this.broadcasting || this.reconnectTimer) return;
|
|
1011
|
+
|
|
1012
|
+
const delay = 3000; // Fixed 3s delay; simple and predictable
|
|
1013
|
+
this.debugLog(`📡 [RealtimeService] Scheduling LISTEN reconnect in ${delay}ms...`);
|
|
1014
|
+
|
|
1015
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
1016
|
+
this.reconnectTimer = undefined;
|
|
1017
|
+
if (!this.broadcasting) return;
|
|
1018
|
+
|
|
1019
|
+
// Clean up old client
|
|
1020
|
+
if (this.listenClient) {
|
|
1021
|
+
try { await this.listenClient.end(); } catch { /* ignore */ }
|
|
1022
|
+
this.listenClient = undefined;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
await this.connectListenClient();
|
|
1026
|
+
}, delay);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Alias for RealtimeService for consistent naming with other database implementations.
|
|
1032
|
+
* This allows code to use PostgresRealtimeProvider alongside future MongoRealtimeProvider, etc.
|
|
1033
|
+
*/
|
|
1034
|
+
export const PostgresRealtimeProvider = RealtimeService;
|