dzql 0.6.13 → 0.6.15
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/docs/README.md +59 -16
- package/docs/for_ai.md +52 -2
- package/docs/project-setup.md +2 -0
- package/package.json +4 -2
- package/src/cli/codegen/notification.ts +219 -0
- package/src/cli/codegen/pinia.ts +28 -32
- package/src/cli/codegen/sql.ts +38 -6
- package/src/cli/codegen/subscribable_sql.ts +89 -12
- package/src/cli/codegen/subscribable_store.ts +101 -102
- package/src/cli/index.ts +4 -1
- package/src/client/ws.ts +177 -93
- package/src/runtime/index.ts +91 -18
- package/src/runtime/subscriptions.ts +189 -0
- package/src/runtime/ws.ts +74 -55
package/src/client/ws.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// Core WebSocket Manager for DZQL Client
|
|
2
2
|
// Handles connection, auth, reconnects, and message dispatching.
|
|
3
|
-
// This is a pure transport layer - it does not manage or cache data.
|
|
4
3
|
|
|
5
4
|
export interface WebSocketOptions {
|
|
6
5
|
url?: string;
|
|
@@ -8,38 +7,50 @@ export interface WebSocketOptions {
|
|
|
8
7
|
tokenName?: string;
|
|
9
8
|
}
|
|
10
9
|
|
|
11
|
-
// Get default token name from environment (build-time injection)
|
|
12
10
|
function getDefaultTokenName(): string {
|
|
13
|
-
// Vite: import.meta.env.VITE_DZQL_TOKEN_NAME
|
|
14
11
|
// @ts-ignore
|
|
15
12
|
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_DZQL_TOKEN_NAME) {
|
|
16
13
|
// @ts-ignore
|
|
17
14
|
return import.meta.env.VITE_DZQL_TOKEN_NAME;
|
|
18
15
|
}
|
|
19
|
-
// Node/bundlers: process.env.DZQL_TOKEN_NAME
|
|
20
16
|
if (typeof process !== 'undefined' && process.env?.DZQL_TOKEN_NAME) {
|
|
21
17
|
return process.env.DZQL_TOKEN_NAME;
|
|
22
18
|
}
|
|
23
19
|
return 'dzql_token';
|
|
24
20
|
}
|
|
25
21
|
|
|
22
|
+
/** Schema returned from subscription */
|
|
23
|
+
interface SubscriptionSchema {
|
|
24
|
+
root?: string;
|
|
25
|
+
paths?: Record<string, string>;
|
|
26
|
+
scopeTables?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Subscription entry stored on client */
|
|
30
|
+
interface SubscriptionEntry {
|
|
31
|
+
callback: (data: any) => void;
|
|
32
|
+
schema: SubscriptionSchema;
|
|
33
|
+
localData: any;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Handler signature for store table_changed methods */
|
|
37
|
+
export type TableChangedHandler = (table: string, op: string, pk: Record<string, unknown>, data: unknown) => void;
|
|
38
|
+
|
|
26
39
|
export class WebSocketManager {
|
|
27
40
|
protected ws: WebSocket | null = null;
|
|
28
41
|
protected messageId = 0;
|
|
29
42
|
protected pendingRequests = new Map<number, { resolve: (val: any) => void; reject: (err: any) => void }>();
|
|
30
43
|
protected methodHandlers = new Map<string, Set<(params: any) => void>>();
|
|
31
|
-
protected
|
|
44
|
+
protected subscriptions = new Map<string, SubscriptionEntry>();
|
|
32
45
|
protected readyCallbacks = new Set<(user: any) => void>();
|
|
46
|
+
protected storeHandlers = new Set<TableChangedHandler>();
|
|
33
47
|
protected reconnectAttempts = 0;
|
|
34
48
|
protected maxReconnectAttempts = 5;
|
|
35
49
|
protected tokenName = 'dzql_token';
|
|
36
50
|
protected isShuttingDown = false;
|
|
37
51
|
|
|
38
|
-
// Connection state
|
|
39
52
|
public user: any = null;
|
|
40
53
|
public ready: boolean = false;
|
|
41
|
-
|
|
42
|
-
// To be populated by generated code
|
|
43
54
|
public api: any = {};
|
|
44
55
|
|
|
45
56
|
constructor(options: WebSocketOptions = {}) {
|
|
@@ -48,18 +59,11 @@ export class WebSocketManager {
|
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
async login(credentials: any) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (typeof localStorage !== 'undefined') {
|
|
55
|
-
localStorage.setItem(this.tokenName, result.token);
|
|
56
|
-
}
|
|
57
|
-
await this.authenticate(result.token);
|
|
58
|
-
}
|
|
59
|
-
return result;
|
|
60
|
-
} catch (e) {
|
|
61
|
-
throw e;
|
|
62
|
+
const result = await this.call('login_user', credentials) as { token?: string };
|
|
63
|
+
if (result?.token && typeof localStorage !== 'undefined') {
|
|
64
|
+
localStorage.setItem(this.tokenName, result.token);
|
|
62
65
|
}
|
|
66
|
+
return result;
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
async authenticate(token: string) {
|
|
@@ -67,18 +71,12 @@ export class WebSocketManager {
|
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
async register(credentials: any, options: any = {}) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (typeof localStorage !== 'undefined') {
|
|
75
|
-
localStorage.setItem(this.tokenName, result.token);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return result;
|
|
79
|
-
} catch (e) {
|
|
80
|
-
throw e;
|
|
74
|
+
const params = { ...credentials, options };
|
|
75
|
+
const result = await this.call('register_user', params) as { token?: string };
|
|
76
|
+
if (result?.token && typeof localStorage !== 'undefined') {
|
|
77
|
+
localStorage.setItem(this.tokenName, result.token);
|
|
81
78
|
}
|
|
79
|
+
return result;
|
|
82
80
|
}
|
|
83
81
|
|
|
84
82
|
async logout() {
|
|
@@ -110,36 +108,32 @@ export class WebSocketManager {
|
|
|
110
108
|
if (typeof localStorage !== 'undefined') {
|
|
111
109
|
const token = localStorage.getItem(this.tokenName);
|
|
112
110
|
if (token) {
|
|
113
|
-
|
|
114
|
-
else wsUrl += '?token=' + encodeURIComponent(token);
|
|
111
|
+
wsUrl += (wsUrl.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(token);
|
|
115
112
|
}
|
|
116
113
|
}
|
|
117
114
|
|
|
118
115
|
const connectionTimeout = setTimeout(() => {
|
|
119
|
-
|
|
120
|
-
reject(new Error('WebSocket connection timed out
|
|
116
|
+
this.ws?.close();
|
|
117
|
+
reject(new Error('WebSocket connection timed out'));
|
|
121
118
|
}, timeout);
|
|
122
119
|
|
|
123
120
|
this.ws = new WebSocket(wsUrl);
|
|
124
121
|
|
|
125
122
|
this.ws.onopen = () => {
|
|
126
123
|
clearTimeout(connectionTimeout);
|
|
127
|
-
console.log('[DZQL] Connected to ' + wsUrl);
|
|
128
124
|
this.reconnectAttempts = 0;
|
|
129
125
|
resolve();
|
|
130
126
|
};
|
|
131
127
|
|
|
132
128
|
this.ws.onmessage = (event) => {
|
|
133
129
|
try {
|
|
134
|
-
|
|
135
|
-
this.handleMessage(message);
|
|
130
|
+
this.handleMessage(JSON.parse(event.data));
|
|
136
131
|
} catch (error) {
|
|
137
132
|
console.error("[DZQL] Failed to parse message:", error);
|
|
138
133
|
}
|
|
139
134
|
};
|
|
140
135
|
|
|
141
136
|
this.ws.onclose = () => {
|
|
142
|
-
console.log("[DZQL] Disconnected");
|
|
143
137
|
if (!this.isShuttingDown) {
|
|
144
138
|
this.attemptReconnect();
|
|
145
139
|
}
|
|
@@ -147,7 +141,6 @@ export class WebSocketManager {
|
|
|
147
141
|
|
|
148
142
|
this.ws.onerror = (error) => {
|
|
149
143
|
clearTimeout(connectionTimeout);
|
|
150
|
-
console.error("[DZQL] Connection error:", error);
|
|
151
144
|
reject(error);
|
|
152
145
|
};
|
|
153
146
|
});
|
|
@@ -156,16 +149,12 @@ export class WebSocketManager {
|
|
|
156
149
|
attemptReconnect() {
|
|
157
150
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
158
151
|
this.reconnectAttempts++;
|
|
159
|
-
|
|
160
|
-
setTimeout(() => {
|
|
161
|
-
console.log('[DZQL] Reconnecting (' + this.reconnectAttempts + ')...');
|
|
162
|
-
this.connect();
|
|
163
|
-
}, delay);
|
|
152
|
+
setTimeout(() => this.connect(), 1000 * this.reconnectAttempts);
|
|
164
153
|
}
|
|
165
154
|
}
|
|
166
155
|
|
|
167
156
|
handleMessage(message: any) {
|
|
168
|
-
// Handle connection:ready
|
|
157
|
+
// Handle connection:ready
|
|
169
158
|
if (message.method === "connection:ready") {
|
|
170
159
|
this.user = message.params?.user || null;
|
|
171
160
|
this.ready = true;
|
|
@@ -173,44 +162,111 @@ export class WebSocketManager {
|
|
|
173
162
|
return;
|
|
174
163
|
}
|
|
175
164
|
|
|
176
|
-
// Handle RPC responses
|
|
165
|
+
// Handle RPC responses
|
|
177
166
|
if (message.id && this.pendingRequests.has(message.id)) {
|
|
178
|
-
const resolver = this.pendingRequests.get(message.id)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
resolver.resolve(message.result);
|
|
187
|
-
}
|
|
167
|
+
const resolver = this.pendingRequests.get(message.id)!;
|
|
168
|
+
this.pendingRequests.delete(message.id);
|
|
169
|
+
if (message.error) {
|
|
170
|
+
const err: any = new Error(message.error.message || 'Unknown error');
|
|
171
|
+
err.code = message.error.code;
|
|
172
|
+
resolver.reject(err);
|
|
173
|
+
} else {
|
|
174
|
+
resolver.resolve(message.result);
|
|
188
175
|
}
|
|
189
176
|
return;
|
|
190
177
|
}
|
|
191
178
|
|
|
192
|
-
// Handle subscription events - dispatch
|
|
179
|
+
// Handle subscription events - dispatch by subscription_id
|
|
193
180
|
if (message.method === "subscription:event") {
|
|
194
|
-
const event = message.params
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
callback(event);
|
|
199
|
-
}
|
|
181
|
+
const { subscription_id, event } = message.params || {};
|
|
182
|
+
const sub = this.subscriptions.get(subscription_id);
|
|
183
|
+
if (sub && event) {
|
|
184
|
+
this.applyAtomicUpdate(sub, event);
|
|
200
185
|
}
|
|
201
186
|
return;
|
|
202
187
|
}
|
|
203
188
|
|
|
204
|
-
// Handle
|
|
189
|
+
// Handle entity broadcasts (e.g. "venues:update", "users:insert")
|
|
190
|
+
if (message.method && message.method.includes(':')) {
|
|
191
|
+
const [table, op] = message.method.split(':');
|
|
192
|
+
const { pk, data } = message.params || {};
|
|
193
|
+
|
|
194
|
+
// Dispatch to registered store handlers
|
|
195
|
+
this.storeHandlers.forEach(handler => handler(table, op, pk, data));
|
|
196
|
+
|
|
197
|
+
// Also dispatch to legacy broadcast handlers
|
|
198
|
+
this.dispatchBroadcast(message.method, message.params);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Handle other broadcasts via methodHandlers
|
|
205
202
|
if (message.method) {
|
|
206
203
|
const handlers = this.methodHandlers.get(message.method);
|
|
207
|
-
|
|
208
|
-
|
|
204
|
+
handlers?.forEach((cb) => cb(message.params));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Apply an atomic update to a subscription's local data
|
|
210
|
+
*/
|
|
211
|
+
private applyAtomicUpdate(sub: SubscriptionEntry, event: { table: string; op: string; pk: any; data: any }) {
|
|
212
|
+
const { table, op, pk, data } = event;
|
|
213
|
+
const { schema, localData, callback } = sub;
|
|
214
|
+
|
|
215
|
+
if (!schema || !localData) {
|
|
216
|
+
if (data) callback(data);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const path = schema.paths?.[table];
|
|
221
|
+
if (!path) {
|
|
222
|
+
console.warn(`Unknown table ${table} in subscription`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (path === '.' || table === schema.root) {
|
|
227
|
+
// Root entity changed
|
|
228
|
+
if (op === 'update' && data) {
|
|
229
|
+
Object.assign(localData[schema.root!] || localData, data);
|
|
209
230
|
}
|
|
231
|
+
} else {
|
|
232
|
+
// Relation changed - find and update in nested structure
|
|
233
|
+
this.applyRelationUpdate(localData, path, op, pk, data);
|
|
210
234
|
}
|
|
235
|
+
|
|
236
|
+
callback(localData);
|
|
211
237
|
}
|
|
212
238
|
|
|
213
|
-
|
|
239
|
+
/**
|
|
240
|
+
* Apply update to a nested relation
|
|
241
|
+
*/
|
|
242
|
+
private applyRelationUpdate(doc: any, path: string, op: string, pk: any, data: any) {
|
|
243
|
+
const parts = path.split('.');
|
|
244
|
+
let target = doc;
|
|
245
|
+
|
|
246
|
+
// Navigate to parent
|
|
247
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
248
|
+
target = target?.[parts[i]];
|
|
249
|
+
if (!target) return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const key = parts[parts.length - 1];
|
|
253
|
+
const arr = target[key];
|
|
254
|
+
|
|
255
|
+
if (!Array.isArray(arr)) return;
|
|
256
|
+
|
|
257
|
+
const pkValue = pk?.id;
|
|
258
|
+
const idx = arr.findIndex((item: any) => item?.id === pkValue);
|
|
259
|
+
|
|
260
|
+
if (op === 'insert' && idx === -1 && data) {
|
|
261
|
+
arr.push(data);
|
|
262
|
+
} else if (op === 'update' && idx !== -1 && data) {
|
|
263
|
+
Object.assign(arr[idx], data);
|
|
264
|
+
} else if (op === 'delete' && idx !== -1) {
|
|
265
|
+
arr.splice(idx, 1);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
call(method: string, params: any = {}): Promise<any> {
|
|
214
270
|
return new Promise((resolve, reject) => {
|
|
215
271
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
216
272
|
reject(new Error("WebSocket not connected"));
|
|
@@ -222,12 +278,6 @@ export class WebSocketManager {
|
|
|
222
278
|
});
|
|
223
279
|
}
|
|
224
280
|
|
|
225
|
-
/**
|
|
226
|
-
* Register a callback for a server-initiated method
|
|
227
|
-
* @param method - The method name to listen for
|
|
228
|
-
* @param callback - Called with params when server sends this method
|
|
229
|
-
* @returns Unsubscribe function
|
|
230
|
-
*/
|
|
231
281
|
on(method: string, callback: (params: any) => void) {
|
|
232
282
|
if (!this.methodHandlers.has(method)) {
|
|
233
283
|
this.methodHandlers.set(method, new Set());
|
|
@@ -237,51 +287,85 @@ export class WebSocketManager {
|
|
|
237
287
|
const handlers = this.methodHandlers.get(method);
|
|
238
288
|
if (handlers) {
|
|
239
289
|
handlers.delete(callback);
|
|
240
|
-
if (handlers.size === 0)
|
|
241
|
-
this.methodHandlers.delete(method);
|
|
242
|
-
}
|
|
290
|
+
if (handlers.size === 0) this.methodHandlers.delete(method);
|
|
243
291
|
}
|
|
244
292
|
};
|
|
245
293
|
}
|
|
246
294
|
|
|
295
|
+
onReady(callback: (user: any) => void) {
|
|
296
|
+
if (this.ready) callback(this.user);
|
|
297
|
+
this.readyCallbacks.add(callback);
|
|
298
|
+
return () => this.readyCallbacks.delete(callback);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Register a store's table_changed handler.
|
|
303
|
+
* Called automatically by generated stores.
|
|
304
|
+
* @returns Unregister function
|
|
305
|
+
*/
|
|
306
|
+
registerStore(handler: TableChangedHandler): () => void {
|
|
307
|
+
this.storeHandlers.add(handler);
|
|
308
|
+
return () => this.storeHandlers.delete(handler);
|
|
309
|
+
}
|
|
310
|
+
|
|
247
311
|
/**
|
|
248
|
-
* Register a callback
|
|
249
|
-
*
|
|
312
|
+
* Register a callback for entity broadcast events.
|
|
313
|
+
* Called when entities you have permission to view are created/updated/deleted.
|
|
314
|
+
* The method format is "table:op" (e.g. "venues:update", "users:insert").
|
|
315
|
+
*
|
|
316
|
+
* @param callback - Function called with (method, params) for each broadcast
|
|
250
317
|
* @returns Unsubscribe function
|
|
251
318
|
*/
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
callback
|
|
319
|
+
onBroadcast(callback: (method: string, params: { pk: any; data: any }) => void): () => void {
|
|
320
|
+
const handler = (params: any) => {
|
|
321
|
+
// The method name is stored on the callback context
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// Use a special marker to track broadcast handlers
|
|
325
|
+
const broadcastHandler = { callback, marker: 'broadcast' as const };
|
|
326
|
+
if (!this._broadcastHandlers) {
|
|
327
|
+
this._broadcastHandlers = new Set();
|
|
255
328
|
}
|
|
256
|
-
this.
|
|
257
|
-
|
|
329
|
+
this._broadcastHandlers.add(broadcastHandler);
|
|
330
|
+
|
|
331
|
+
return () => {
|
|
332
|
+
this._broadcastHandlers?.delete(broadcastHandler);
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
protected _broadcastHandlers?: Set<{ callback: (method: string, params: any) => void; marker: 'broadcast' }>;
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Dispatch a broadcast event to all broadcast handlers
|
|
340
|
+
*/
|
|
341
|
+
protected dispatchBroadcast(method: string, params: any) {
|
|
342
|
+
this._broadcastHandlers?.forEach(h => h.callback(method, params));
|
|
258
343
|
}
|
|
259
344
|
|
|
260
345
|
/**
|
|
261
346
|
* Subscribe to a subscribable document
|
|
262
|
-
* @param method - The subscribe method name (e.g., "subscribe_venue_detail")
|
|
263
|
-
* @param params - Subscription parameters
|
|
264
|
-
* @param callback - Called with initial data and on updates
|
|
265
|
-
* @returns Promise that resolves to an unsubscribe function
|
|
266
347
|
*/
|
|
267
348
|
async subscribe(method: string, params: any, callback: (data: any) => void): Promise<() => void> {
|
|
268
|
-
// Call server to get initial snapshot and subscription_id
|
|
269
349
|
const result = await this.call(method, params) as {
|
|
270
350
|
subscription_id: string;
|
|
271
351
|
data: any;
|
|
352
|
+
schema?: SubscriptionSchema;
|
|
272
353
|
};
|
|
273
354
|
|
|
274
|
-
//
|
|
275
|
-
this.
|
|
355
|
+
// Store subscription with local data for atomic updates
|
|
356
|
+
this.subscriptions.set(result.subscription_id, {
|
|
357
|
+
callback,
|
|
358
|
+
schema: result.schema || {},
|
|
359
|
+
localData: result.data
|
|
360
|
+
});
|
|
276
361
|
|
|
277
362
|
// Call callback with initial data
|
|
278
363
|
callback(result.data);
|
|
279
364
|
|
|
280
365
|
// Return unsubscribe function
|
|
281
366
|
return () => {
|
|
282
|
-
this.
|
|
283
|
-
|
|
284
|
-
this.call(`unsubscribe_${method.replace('subscribe_', '')}`, { subscription_id: result.subscription_id }).catch(() => {});
|
|
367
|
+
this.subscriptions.delete(result.subscription_id);
|
|
368
|
+
this.call(`unsubscribe_${method.replace('subscribe_', '')}`, params).catch(() => {});
|
|
285
369
|
};
|
|
286
370
|
}
|
|
287
371
|
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { loadManifest } from "./manifest_loader.js";
|
|
|
6
6
|
import { readFileSync } from "fs";
|
|
7
7
|
import { resolve } from "path";
|
|
8
8
|
import { runtimeLogger, notifyLogger, serverLogger } from "./logger.js";
|
|
9
|
+
import { getSubscriptionsBySubscribable } from "./subscriptions.js";
|
|
9
10
|
|
|
10
11
|
// Re-export JS function registration API for custom functions
|
|
11
12
|
export { registerJsFunction, type JsFunctionHandler, type JsFunctionContext } from "./js_functions.js";
|
|
@@ -35,6 +36,86 @@ try {
|
|
|
35
36
|
// 3. Initialize WebSocket Server
|
|
36
37
|
const wsServer = new WebSocketServer(db);
|
|
37
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Process event notifications.
|
|
41
|
+
* Sends events to connections based on:
|
|
42
|
+
* 1. Subscriptions matched by affected_keys
|
|
43
|
+
* 2. Users in notify_users (from entity notification paths)
|
|
44
|
+
*
|
|
45
|
+
* Each connection receives the event at most once, even if matched by both.
|
|
46
|
+
*/
|
|
47
|
+
function processEventNotifications(event: {
|
|
48
|
+
table: string;
|
|
49
|
+
op: string;
|
|
50
|
+
pk: Record<string, unknown>;
|
|
51
|
+
data: Record<string, unknown>;
|
|
52
|
+
user_id: number | null;
|
|
53
|
+
affected_keys: string[];
|
|
54
|
+
notify_users: number[];
|
|
55
|
+
}) {
|
|
56
|
+
const { table, op, pk, data, affected_keys, notify_users } = event;
|
|
57
|
+
|
|
58
|
+
notifyLogger.debug(`processEventNotifications called for ${table}:${op}`);
|
|
59
|
+
notifyLogger.debug(`affected_keys: ${JSON.stringify(affected_keys)}`);
|
|
60
|
+
notifyLogger.debug(`notify_users: ${JSON.stringify(notify_users)}`);
|
|
61
|
+
|
|
62
|
+
// Track which connections we've already notified to avoid duplicates
|
|
63
|
+
const notifiedConnections = new Set<string>();
|
|
64
|
+
|
|
65
|
+
// Build the event message for entity broadcasts
|
|
66
|
+
const entityEventMessage = JSON.stringify({
|
|
67
|
+
jsonrpc: "2.0",
|
|
68
|
+
method: `${table}:${op}`,
|
|
69
|
+
params: { table, op, pk, data }
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// 1. Send to users in notify_users list (entity notifications)
|
|
73
|
+
if (notify_users && notify_users.length > 0) {
|
|
74
|
+
for (const userId of notify_users) {
|
|
75
|
+
const connectionIds = wsServer.getConnectionsByUserId(userId);
|
|
76
|
+
for (const connectionId of connectionIds) {
|
|
77
|
+
if (!notifiedConnections.has(connectionId)) {
|
|
78
|
+
const sent = wsServer.toConnection(connectionId, entityEventMessage);
|
|
79
|
+
if (sent) {
|
|
80
|
+
notifiedConnections.add(connectionId);
|
|
81
|
+
notifyLogger.debug(`Sent entity event to user ${userId} connection ${connectionId.slice(0, 8)}...`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 2. Send entity broadcasts to connections with matching subscriptions
|
|
89
|
+
// Connections that have bound to a subscribable whose affected_key matches get the broadcast
|
|
90
|
+
if (affected_keys && affected_keys.length > 0) {
|
|
91
|
+
const subscriptionsByName = getSubscriptionsBySubscribable();
|
|
92
|
+
|
|
93
|
+
for (const [subscribableName, subs] of subscriptionsByName.entries()) {
|
|
94
|
+
for (const sub of subs) {
|
|
95
|
+
const paramValues = Object.values(sub.params);
|
|
96
|
+
// For subscribables with no params, the key is just the name
|
|
97
|
+
// For subscribables with params, the key is name:param1:param2:...
|
|
98
|
+
const subKey = paramValues.length > 0
|
|
99
|
+
? `${subscribableName}:${paramValues.join(':')}`
|
|
100
|
+
: subscribableName;
|
|
101
|
+
|
|
102
|
+
if (affected_keys.includes(subKey)) {
|
|
103
|
+
// Send entity broadcast (not subscription:event) so global dispatcher can route it
|
|
104
|
+
if (!notifiedConnections.has(sub.connection_id)) {
|
|
105
|
+
const sent = wsServer.toConnection(sub.connection_id, entityEventMessage);
|
|
106
|
+
if (sent) {
|
|
107
|
+
notifiedConnections.add(sub.connection_id);
|
|
108
|
+
notifyLogger.debug(`Sent entity broadcast to ${sub.connection_id.slice(0, 8)}... via ${subscribableName} (${table}:${op})`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
notifyLogger.debug(`Notified ${notifiedConnections.size} connection(s) with entity event`);
|
|
117
|
+
}
|
|
118
|
+
|
|
38
119
|
// 4. Start Commit Listener (Realtime)
|
|
39
120
|
async function startListener() {
|
|
40
121
|
runtimeLogger.info("Setting up LISTEN on dzql_v2 channel...");
|
|
@@ -51,24 +132,18 @@ async function startListener() {
|
|
|
51
132
|
ORDER BY id ASC
|
|
52
133
|
`, [commit_id]);
|
|
53
134
|
|
|
135
|
+
notifyLogger.debug(`Fetched ${events.length} events for commit ${commit_id}`);
|
|
54
136
|
for (const event of events) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
data: event.data,
|
|
65
|
-
// old_data is filtered out
|
|
66
|
-
user_id: event.user_id
|
|
67
|
-
}
|
|
68
|
-
}
|
|
137
|
+
notifyLogger.debug(`Processing event: ${event.table_name}:${event.op}`);
|
|
138
|
+
processEventNotifications({
|
|
139
|
+
table: event.table_name,
|
|
140
|
+
op: event.op,
|
|
141
|
+
pk: event.pk,
|
|
142
|
+
data: event.data,
|
|
143
|
+
user_id: event.user_id,
|
|
144
|
+
affected_keys: event.affected_keys || [],
|
|
145
|
+
notify_users: event.notify_users || []
|
|
69
146
|
});
|
|
70
|
-
wsServer.broadcast(message);
|
|
71
|
-
notifyLogger.trace(`Broadcast ${event.op} on ${event.table_name}`);
|
|
72
147
|
}
|
|
73
148
|
} catch (e: any) {
|
|
74
149
|
notifyLogger.error("Listener Error:", e.message);
|
|
@@ -85,8 +160,6 @@ const server = serve({
|
|
|
85
160
|
port: PORT,
|
|
86
161
|
async fetch(req, server) {
|
|
87
162
|
const url = new URL(req.url);
|
|
88
|
-
|
|
89
|
-
// Extract token from query params for WebSocket connections
|
|
90
163
|
const token = url.searchParams.get("token") ?? undefined;
|
|
91
164
|
|
|
92
165
|
if (server.upgrade(req, { data: { token } })) {
|