dzql 0.6.13 → 0.6.14

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/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 subscriptionCallbacks = new Map<string, (event: any) => void>();
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
- try {
52
- const result = await this.call('login_user', credentials) as { token?: string };
53
- if (result && result.token) {
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
- try {
71
- const params = { ...credentials, options };
72
- const result = await this.call('register_user', params) as { token?: string };
73
- if (result && result.token) {
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
- if (wsUrl.includes('?')) wsUrl += '&token=' + encodeURIComponent(token);
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
- if (this.ws) this.ws.close();
120
- reject(new Error('WebSocket connection timed out after ' + timeout + 'ms'));
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
- const message = JSON.parse(event.data);
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
- const delay = 1000 * this.reconnectAttempts;
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 message
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 (messages with id)
165
+ // Handle RPC responses
177
166
  if (message.id && this.pendingRequests.has(message.id)) {
178
- const resolver = this.pendingRequests.get(message.id);
179
- if (resolver) {
180
- this.pendingRequests.delete(message.id);
181
- if (message.error) {
182
- const err: any = new Error(message.error.message || 'Unknown error');
183
- err.code = message.error.code;
184
- resolver.reject(err);
185
- } else {
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 to registered subscription callbacks
179
+ // Handle subscription events - dispatch by subscription_id
193
180
  if (message.method === "subscription:event") {
194
- const event = message.params?.event;
195
- if (event) {
196
- // Dispatch to all subscription handlers - they filter by table/scope
197
- for (const [subId, callback] of this.subscriptionCallbacks) {
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 other server-initiated messages (broadcasts) - route to registered handlers
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
- if (handlers) {
208
- handlers.forEach((cb) => cb(message.params));
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
- call(method: string, params: any = {}) {
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 to be called when connection is ready
249
- * @param callback - Called with user profile (or null if not authenticated)
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
- onReady(callback: (user: any) => void) {
253
- if (this.ready) {
254
- callback(this.user);
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.readyCallbacks.add(callback);
257
- return () => this.readyCallbacks.delete(callback);
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
- // Register callback for subscription events
275
- this.subscriptionCallbacks.set(result.subscription_id, callback);
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.subscriptionCallbacks.delete(result.subscription_id);
283
- // Notify server
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
  }
@@ -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,82 @@ 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
+ const subKey = `${subscribableName}:${paramValues.join(':')}`;
97
+
98
+ if (affected_keys.includes(subKey)) {
99
+ // Send entity broadcast (not subscription:event) so global dispatcher can route it
100
+ if (!notifiedConnections.has(sub.connection_id)) {
101
+ const sent = wsServer.toConnection(sub.connection_id, entityEventMessage);
102
+ if (sent) {
103
+ notifiedConnections.add(sub.connection_id);
104
+ notifyLogger.debug(`Sent entity broadcast to ${sub.connection_id.slice(0, 8)}... via ${subscribableName} (${table}:${op})`);
105
+ }
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ notifyLogger.debug(`Notified ${notifiedConnections.size} connection(s) with entity event`);
113
+ }
114
+
38
115
  // 4. Start Commit Listener (Realtime)
39
116
  async function startListener() {
40
117
  runtimeLogger.info("Setting up LISTEN on dzql_v2 channel...");
@@ -51,24 +128,18 @@ async function startListener() {
51
128
  ORDER BY id ASC
52
129
  `, [commit_id]);
53
130
 
131
+ notifyLogger.debug(`Fetched ${events.length} events for commit ${commit_id}`);
54
132
  for (const event of events) {
55
- // Broadcast
56
- const message = JSON.stringify({
57
- jsonrpc: "2.0",
58
- method: "subscription:event",
59
- params: {
60
- event: {
61
- table: event.table_name,
62
- op: event.op,
63
- pk: event.pk,
64
- data: event.data,
65
- // old_data is filtered out
66
- user_id: event.user_id
67
- }
68
- }
133
+ notifyLogger.debug(`Processing event: ${event.table_name}:${event.op}`);
134
+ processEventNotifications({
135
+ table: event.table_name,
136
+ op: event.op,
137
+ pk: event.pk,
138
+ data: event.data,
139
+ user_id: event.user_id,
140
+ affected_keys: event.affected_keys || [],
141
+ notify_users: event.notify_users || []
69
142
  });
70
- wsServer.broadcast(message);
71
- notifyLogger.trace(`Broadcast ${event.op} on ${event.table_name}`);
72
143
  }
73
144
  } catch (e: any) {
74
145
  notifyLogger.error("Listener Error:", e.message);
@@ -85,8 +156,6 @@ const server = serve({
85
156
  port: PORT,
86
157
  async fetch(req, server) {
87
158
  const url = new URL(req.url);
88
-
89
- // Extract token from query params for WebSocket connections
90
159
  const token = url.searchParams.get("token") ?? undefined;
91
160
 
92
161
  if (server.upgrade(req, { data: { token } })) {