dzql 0.1.6 → 0.2.0

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.
@@ -2,12 +2,96 @@ import { createWebSocketHandlers, verify_jwt_token } from "./ws.js";
2
2
  import { closeConnections, setupListeners, sql, db } from "./db.js";
3
3
  import * as defaultApi from "./api.js";
4
4
  import { serverLogger, notifyLogger } from "./logger.js";
5
+ import { getSubscriptionsBySubscribable, paramsMatch } from "./subscriptions.js";
5
6
 
6
7
  // Re-export commonly used utilities
7
8
  export { sql, db } from "./db.js";
8
9
  export { metaRoute } from "./meta-route.js";
9
10
  export { createMCPRoute } from "./mcp.js";
10
11
 
12
+ /**
13
+ * Process subscription updates when a database event occurs
14
+ * Checks if any active subscriptions are affected and sends updates
15
+ * @param {Object} event - Database event {table, op, pk, before, after}
16
+ * @param {Function} broadcast - Broadcast function from WebSocket handlers
17
+ */
18
+ async function processSubscriptionUpdates(event, broadcast) {
19
+ const { table, op, before, after } = event;
20
+
21
+ // Get all active subscriptions grouped by subscribable
22
+ const subscriptionsByName = getSubscriptionsBySubscribable();
23
+
24
+ if (subscriptionsByName.size === 0) {
25
+ return; // No active subscriptions
26
+ }
27
+
28
+ notifyLogger.debug(`Checking ${subscriptionsByName.size} subscribable(s) for affected subscriptions`);
29
+
30
+ // For each unique subscribable, check if this event affects any subscriptions
31
+ for (const [subscribableName, subs] of subscriptionsByName.entries()) {
32
+ try {
33
+ // Ask PostgreSQL which subscription instances are affected
34
+ const result = await db.query(
35
+ `SELECT ${subscribableName}_affected_documents($1, $2, $3, $4) as affected`,
36
+ [table, op, before, after]
37
+ );
38
+
39
+ const affectedParamSets = result.rows[0]?.affected;
40
+
41
+ if (!affectedParamSets || affectedParamSets.length === 0) {
42
+ continue; // This subscribable not affected
43
+ }
44
+
45
+ notifyLogger.debug(`${subscribableName}: ${affectedParamSets.length} param set(s) affected`);
46
+
47
+ // Match affected params to active subscriptions
48
+ for (const affectedParams of affectedParamSets) {
49
+ for (const sub of subs) {
50
+ // Check if this subscription matches the affected params
51
+ if (paramsMatch(sub.params, affectedParams)) {
52
+ try {
53
+ // Re-execute query to get updated data
54
+ const updated = await db.query(
55
+ `SELECT get_${subscribableName}($1, $2) as data`,
56
+ [sub.params, sub.user_id]
57
+ );
58
+
59
+ const data = updated.rows[0]?.data;
60
+
61
+ // Send update to specific connection
62
+ const message = JSON.stringify({
63
+ jsonrpc: "2.0",
64
+ method: "subscription:update",
65
+ params: {
66
+ subscription_id: sub.subscriptionId,
67
+ subscribable: subscribableName,
68
+ data
69
+ }
70
+ });
71
+
72
+ const sent = broadcast.toConnection(sub.connection_id, message);
73
+ if (sent) {
74
+ notifyLogger.debug(`Sent update to subscription ${sub.subscriptionId.slice(0, 8)}...`);
75
+ } else {
76
+ notifyLogger.warn(`Failed to send update to connection ${sub.connection_id.slice(0, 8)}...`);
77
+ }
78
+ } catch (error) {
79
+ notifyLogger.error(`Failed to update subscription ${sub.subscriptionId}:`, error.message);
80
+ }
81
+ }
82
+ }
83
+ }
84
+ } catch (error) {
85
+ // If the subscribable function doesn't exist, just skip
86
+ if (error.message && error.message.includes('does not exist')) {
87
+ notifyLogger.debug(`Subscribable ${subscribableName} functions not found, skipping`);
88
+ } else {
89
+ notifyLogger.error(`Error processing subscriptions for ${subscribableName}:`, error.message);
90
+ }
91
+ }
92
+ }
93
+ }
94
+
11
95
  /**
12
96
  * Create a DZQL server with WebSocket support, real-time updates, and automatic CRUD operations
13
97
  *
@@ -97,10 +181,11 @@ export function createServer(options = {}) {
97
181
  });
98
182
 
99
183
  // Setup NOTIFY listeners for real-time events
100
- setupListeners((event) => {
184
+ setupListeners(async (event) => {
101
185
  // Handle single dzql event with filtering
102
186
  const { notify_users, ...eventData } = event;
103
187
 
188
+ // PATTERN 2: Need to Know notifications (existing)
104
189
  // Create JSON-RPC notification
105
190
  const message = JSON.stringify({
106
191
  jsonrpc: "2.0",
@@ -118,6 +203,10 @@ export function createServer(options = {}) {
118
203
  notifyLogger.debug(`Broadcasting ${event.table}:${event.op} to all users`);
119
204
  broadcast(message);
120
205
  }
206
+
207
+ // PATTERN 1: Live Query subscriptions (new)
208
+ // Check if any subscriptions are affected by this event
209
+ await processSubscriptionUpdates(event, broadcast);
121
210
  });
122
211
 
123
212
  routes['/health'] = () => new Response("OK", { status: 200 });
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Subscription Manager for Live Query Subscriptions
3
+ * Manages in-memory subscription registry and matching
4
+ */
5
+
6
+ import crypto from 'crypto';
7
+ import { wsLogger } from './logger.js';
8
+
9
+ /**
10
+ * In-memory subscription registry
11
+ * Structure: subscription_id -> { subscribable, user_id, connection_id, params }
12
+ */
13
+ const subscriptions = new Map();
14
+
15
+ /**
16
+ * Track subscriptions by connection for cleanup
17
+ * Structure: connection_id -> Set<subscription_id>
18
+ */
19
+ const connectionSubscriptions = new Map();
20
+
21
+ /**
22
+ * Register a new subscription
23
+ * @param {string} subscribableName - Name of the subscribable
24
+ * @param {number} userId - User ID
25
+ * @param {string} connectionId - WebSocket connection ID
26
+ * @param {object} params - Subscription parameters
27
+ * @returns {string} - Subscription ID
28
+ */
29
+ export function registerSubscription(subscribableName, userId, connectionId, params) {
30
+ const subscriptionId = crypto.randomUUID();
31
+
32
+ // Store subscription
33
+ subscriptions.set(subscriptionId, {
34
+ subscribable: subscribableName,
35
+ user_id: userId,
36
+ connection_id: connectionId,
37
+ params,
38
+ created_at: new Date()
39
+ });
40
+
41
+ // Track by connection
42
+ if (!connectionSubscriptions.has(connectionId)) {
43
+ connectionSubscriptions.set(connectionId, new Set());
44
+ }
45
+ connectionSubscriptions.get(connectionId).add(subscriptionId);
46
+
47
+ wsLogger.debug(`Subscription registered: ${subscriptionId.slice(0, 8)}... (${subscribableName})`, {
48
+ user_id: userId,
49
+ params
50
+ });
51
+
52
+ return subscriptionId;
53
+ }
54
+
55
+ /**
56
+ * Unregister a subscription
57
+ * @param {string} subscriptionId - Subscription ID to remove
58
+ * @returns {boolean} - True if subscription was found and removed
59
+ */
60
+ export function unregisterSubscription(subscriptionId) {
61
+ const sub = subscriptions.get(subscriptionId);
62
+
63
+ if (!sub) {
64
+ wsLogger.debug(`Subscription not found: ${subscriptionId}`);
65
+ return false;
66
+ }
67
+
68
+ // Remove from connection tracking
69
+ const connSubs = connectionSubscriptions.get(sub.connection_id);
70
+ if (connSubs) {
71
+ connSubs.delete(subscriptionId);
72
+ if (connSubs.size === 0) {
73
+ connectionSubscriptions.delete(sub.connection_id);
74
+ }
75
+ }
76
+
77
+ // Remove subscription
78
+ subscriptions.delete(subscriptionId);
79
+
80
+ wsLogger.debug(`Subscription removed: ${subscriptionId.slice(0, 8)}...`);
81
+ return true;
82
+ }
83
+
84
+ /**
85
+ * Unregister subscription by params
86
+ * Useful for unsubscribe_* methods that specify params instead of subscription ID
87
+ * @param {string} subscribableName - Name of the subscribable
88
+ * @param {string} connectionId - WebSocket connection ID
89
+ * @param {object} params - Subscription parameters to match
90
+ * @returns {boolean} - True if subscription was found and removed
91
+ */
92
+ export function unregisterSubscriptionByParams(subscribableName, connectionId, params) {
93
+ const paramsStr = JSON.stringify(params);
94
+
95
+ // Find matching subscription
96
+ for (const [subId, sub] of subscriptions.entries()) {
97
+ if (
98
+ sub.subscribable === subscribableName &&
99
+ sub.connection_id === connectionId &&
100
+ JSON.stringify(sub.params) === paramsStr
101
+ ) {
102
+ return unregisterSubscription(subId);
103
+ }
104
+ }
105
+
106
+ wsLogger.debug(`No matching subscription found for ${subscribableName} with params`, params);
107
+ return false;
108
+ }
109
+
110
+ /**
111
+ * Remove all subscriptions for a connection
112
+ * Called when WebSocket connection closes
113
+ * @param {string} connectionId - Connection ID
114
+ * @returns {number} - Number of subscriptions removed
115
+ */
116
+ export function removeConnectionSubscriptions(connectionId) {
117
+ const subIds = connectionSubscriptions.get(connectionId);
118
+
119
+ if (!subIds) {
120
+ return 0;
121
+ }
122
+
123
+ let count = 0;
124
+ for (const subId of subIds) {
125
+ if (subscriptions.delete(subId)) {
126
+ count++;
127
+ }
128
+ }
129
+
130
+ connectionSubscriptions.delete(connectionId);
131
+
132
+ if (count > 0) {
133
+ wsLogger.info(`Removed ${count} subscription(s) for connection ${connectionId.slice(0, 8)}...`);
134
+ }
135
+
136
+ return count;
137
+ }
138
+
139
+ /**
140
+ * Get all subscriptions for a specific subscribable
141
+ * @param {string} subscribableName - Name of the subscribable
142
+ * @returns {Array} - Array of {subscriptionId, subscription} objects
143
+ */
144
+ export function getSubscriptionsByName(subscribableName) {
145
+ const result = [];
146
+
147
+ for (const [subId, sub] of subscriptions.entries()) {
148
+ if (sub.subscribable === subscribableName) {
149
+ result.push({ subscriptionId: subId, ...sub });
150
+ }
151
+ }
152
+
153
+ return result;
154
+ }
155
+
156
+ /**
157
+ * Get all subscriptions grouped by subscribable name
158
+ * @returns {Map<string, Array>} - Map of subscribable name to array of subscriptions
159
+ */
160
+ export function getSubscriptionsBySubscribable() {
161
+ const grouped = new Map();
162
+
163
+ for (const [subId, sub] of subscriptions.entries()) {
164
+ if (!grouped.has(sub.subscribable)) {
165
+ grouped.set(sub.subscribable, []);
166
+ }
167
+ grouped.get(sub.subscribable).push({ subscriptionId: subId, ...sub });
168
+ }
169
+
170
+ return grouped;
171
+ }
172
+
173
+ /**
174
+ * Check if params match (deep equality)
175
+ * @param {object} a - First params object
176
+ * @param {object} b - Second params object
177
+ * @returns {boolean} - True if params match
178
+ */
179
+ export function paramsMatch(a, b) {
180
+ return JSON.stringify(a) === JSON.stringify(b);
181
+ }
182
+
183
+ /**
184
+ * Get subscription statistics
185
+ * @returns {object} - Stats object
186
+ */
187
+ export function getStats() {
188
+ return {
189
+ total_subscriptions: subscriptions.size,
190
+ active_connections: connectionSubscriptions.size,
191
+ subscriptions_by_subscribable: Array.from(getSubscriptionsBySubscribable().entries()).map(
192
+ ([name, subs]) => ({
193
+ name,
194
+ count: subs.length
195
+ })
196
+ )
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Get all active subscriptions (for debugging)
202
+ * @returns {Array} - Array of all subscriptions
203
+ */
204
+ export function getAllSubscriptions() {
205
+ return Array.from(subscriptions.entries()).map(([id, sub]) => ({
206
+ id,
207
+ ...sub
208
+ }));
209
+ }
package/src/server/ws.js CHANGED
@@ -6,6 +6,12 @@ import {
6
6
  db,
7
7
  } from "./db.js";
8
8
  import { wsLogger, authLogger } from "./logger.js";
9
+ import {
10
+ registerSubscription,
11
+ unregisterSubscription,
12
+ unregisterSubscriptionByParams,
13
+ removeConnectionSubscriptions
14
+ } from "./subscriptions.js";
9
15
 
10
16
  // Environment configuration
11
17
  const JWT_SECRET_STRING = process.env.JWT_SECRET;
@@ -304,6 +310,57 @@ export function createRPCHandler(customHandlers = {}) {
304
310
  return create_rpc_response(id, result);
305
311
  }
306
312
 
313
+ // SUBSCRIPTION HANDLERS - Pattern match on method name
314
+ if (method.startsWith("subscribe_")) {
315
+ const subscribableName = method.replace("subscribe_", "");
316
+ wsLogger.debug(`Subscribe request: ${subscribableName}`, params);
317
+
318
+ try {
319
+ // Execute initial query (this also checks permissions)
320
+ const queryResult = await db.query(
321
+ `SELECT get_${subscribableName}($1, $2) as data`,
322
+ [params, ws.data.user_id]
323
+ );
324
+
325
+ const data = queryResult.rows[0]?.data;
326
+
327
+ // Register subscription in memory
328
+ const subscriptionId = registerSubscription(
329
+ subscribableName,
330
+ ws.data.user_id,
331
+ ws.data.connection_id,
332
+ params
333
+ );
334
+
335
+ const result = {
336
+ subscription_id: subscriptionId,
337
+ data
338
+ };
339
+
340
+ wsLogger.response(method, result, Date.now() - startTime);
341
+ return create_rpc_response(id, result);
342
+ } catch (error) {
343
+ wsLogger.error(`Subscribe failed for ${subscribableName}:`, error.message);
344
+ return create_rpc_error(id, -32603, error.message);
345
+ }
346
+ }
347
+
348
+ if (method.startsWith("unsubscribe_")) {
349
+ const subscribableName = method.replace("unsubscribe_", "");
350
+ wsLogger.debug(`Unsubscribe request: ${subscribableName}`, params);
351
+
352
+ // Remove subscription by params
353
+ const removed = unregisterSubscriptionByParams(
354
+ subscribableName,
355
+ ws.data.connection_id,
356
+ params
357
+ );
358
+
359
+ const result = { success: removed };
360
+ wsLogger.response(method, result, Date.now() - startTime);
361
+ return create_rpc_response(id, result);
362
+ }
363
+
307
364
  // Check for custom handlers
308
365
  if (customHandlers[method]) {
309
366
  wsLogger.debug(`Calling custom handler: ${method}`);
@@ -417,7 +474,14 @@ export function createWebSocketHandlers(options = {}) {
417
474
  close(ws) {
418
475
  const id = ws.data.connection_id;
419
476
  connections.delete(id);
420
- wsLogger.info(`Connection closed: ${id?.slice(0, 8)}...`);
477
+
478
+ // Clean up all subscriptions for this connection
479
+ const removedCount = removeConnectionSubscriptions(id);
480
+ if (removedCount > 0) {
481
+ wsLogger.info(`Connection closed: ${id?.slice(0, 8)}... (${removedCount} subscriptions removed)`);
482
+ } else {
483
+ wsLogger.info(`Connection closed: ${id?.slice(0, 8)}...`);
484
+ }
421
485
 
422
486
  // Call custom disconnection handler
423
487
  if (onDisconnection) {
@@ -434,7 +498,7 @@ export function createWebSocketHandlers(options = {}) {
434
498
 
435
499
  // Broadcast message to all authenticated connections or specific client_ids
436
500
  export function createBroadcaster(connections) {
437
- return function broadcastToConnections(message, client_ids = null) {
501
+ const broadcastToConnections = function(message, client_ids = null) {
438
502
  if (client_ids && Array.isArray(client_ids)) {
439
503
  // Send to specific user_ids
440
504
  for (const [id, ws] of connections) {
@@ -451,6 +515,18 @@ export function createBroadcaster(connections) {
451
515
  }
452
516
  }
453
517
  };
518
+
519
+ // Add helper function to send to a specific connection
520
+ broadcastToConnections.toConnection = function(connectionId, message) {
521
+ const ws = connections.get(connectionId);
522
+ if (ws && ws.readyState === 1) { // 1 = OPEN
523
+ ws.send(message);
524
+ return true;
525
+ }
526
+ return false;
527
+ };
528
+
529
+ return broadcastToConnections;
454
530
  }
455
531
 
456
532
  // Legacy export for backward compatibility