dzql 0.1.5 → 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.
@@ -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
@@ -1,207 +0,0 @@
1
- // Rights Management UI Configuration
2
- export const uiConfig = {
3
- type: 'container',
4
- class: 'app',
5
- children: [
6
- // Header
7
- {
8
- type: 'div',
9
- class: 'header',
10
- children: [
11
- { type: 'h1', text: '🏛️ Rights Management' },
12
- { type: 'p', text: 'Manage venues, packages, and contractor rights' },
13
- {
14
- type: 'span',
15
- class: '${state.wsStatus === "connected" ? "status connected" : "status disconnected"}',
16
- text: '${state.wsStatus || "disconnected"}'
17
- }
18
- ]
19
- },
20
-
21
- // Navigation tabs
22
- {
23
- type: 'div',
24
- class: 'nav-bar',
25
- children: [{
26
- type: 'div',
27
- class: 'nav-tabs',
28
- children: [
29
- 'dashboard', 'organisations', 'venues', 'packages', 'allocations', 'rights'
30
- ].map(tab => ({
31
- type: 'button',
32
- class: '${state.activeTab === "' + tab + '" ? "tab active" : "tab"}',
33
- text: tab.charAt(0).toUpperCase() + tab.slice(1),
34
- onClick: {
35
- actions: [
36
- { type: 'setState', path: 'activeTab', value: tab },
37
- tab !== 'dashboard' ? {
38
- type: 'call',
39
- operation: 'search',
40
- entity: tab === 'rights' ? 'contractor_rights' : tab,
41
- params: { limit: 25 },
42
- resultPath: tab + '.list'
43
- } : null
44
- ].filter(Boolean)
45
- }
46
- }))
47
- }]
48
- },
49
-
50
- // Content area
51
- {
52
- type: 'div',
53
- class: 'content',
54
- children: [
55
- // Dashboard
56
- {
57
- type: 'if',
58
- condition: "${state.activeTab === 'dashboard'}",
59
- then: {
60
- type: 'section',
61
- children: [
62
- { type: 'h2', text: 'Dashboard' },
63
- {
64
- type: 'div',
65
- class: 'grid',
66
- children: ['venues', 'packages', 'allocations', 'organisations'].map(entity => ({
67
- type: 'div',
68
- class: 'card',
69
- children: [
70
- { type: 'h3', text: entity.charAt(0).toUpperCase() + entity.slice(1) },
71
- { type: 'div', class: 'stat-value', text: '${state.stats?.' + entity + ' || "..."}' },
72
- { type: 'div', class: 'stat-label', text: 'Total ' + entity }
73
- ]
74
- }))
75
- }
76
- ]
77
- }
78
- },
79
-
80
- // Entity views (organisations, venues, packages, allocations)
81
- ...['organisations', 'venues', 'packages', 'allocations'].map(entity => ({
82
- type: 'if',
83
- condition: "${state.activeTab === '" + entity + "'}",
84
- then: {
85
- type: 'section',
86
- children: [
87
- { type: 'h2', text: entity.charAt(0).toUpperCase() + entity.slice(1) },
88
- {
89
- type: 'div',
90
- class: 'search-bar',
91
- children: [
92
- {
93
- type: 'input',
94
- bind: '${state.' + entity + '.search}',
95
- attributes: { placeholder: 'Search ' + entity + '...' }
96
- },
97
- {
98
- type: 'button',
99
- text: 'Search',
100
- onClick: {
101
- actions: [{
102
- type: 'call',
103
- operation: 'search',
104
- entity: entity,
105
- params: {
106
- filters: { _search: '${state.' + entity + '.search}' },
107
- limit: 25
108
- },
109
- resultPath: entity + '.list'
110
- }]
111
- }
112
- }
113
- ]
114
- },
115
- {
116
- type: 'if',
117
- condition: '${state.' + entity + '.list}',
118
- then: {
119
- type: 'table',
120
- data: '${state.' + entity + '.list.data}',
121
- columns: getColumnsForEntity(entity)
122
- }
123
- }
124
- ]
125
- }
126
- })),
127
-
128
- // Rights view
129
- {
130
- type: 'if',
131
- condition: "${state.activeTab === 'rights'}",
132
- then: {
133
- type: 'section',
134
- children: [
135
- { type: 'h2', text: 'Rights Management' },
136
- {
137
- type: 'div',
138
- class: 'pill-nav',
139
- children: ['contractor', 'promotion'].map(type => ({
140
- type: 'button',
141
- class: '${state.rightsType === "' + type + '" ? "pill active" : "pill"}',
142
- text: type.charAt(0).toUpperCase() + type.slice(1) + ' Rights',
143
- onClick: {
144
- actions: [
145
- { type: 'setState', path: 'rightsType', value: type },
146
- {
147
- type: 'call',
148
- operation: 'search',
149
- entity: type + '_rights',
150
- params: { limit: 25 },
151
- resultPath: 'rights.' + type
152
- }
153
- ]
154
- }
155
- }))
156
- },
157
- {
158
- type: 'if',
159
- condition: '${state.rights.contractor}',
160
- then: {
161
- type: 'table',
162
- data: '${state.rights.contractor.data}',
163
- columns: [
164
- { field: 'contractor_org.name', label: 'Contractor' },
165
- { field: 'venue.name', label: 'Venue' },
166
- { field: 'valid_from', label: 'From' },
167
- { field: 'valid_to', label: 'To' }
168
- ]
169
- }
170
- }
171
- ]
172
- }
173
- }
174
- ]
175
- }
176
- ]
177
- };
178
-
179
- // Helper function for table columns
180
- function getColumnsForEntity(entity) {
181
- const columnSets = {
182
- organisations: [
183
- { field: 'id', label: 'ID' },
184
- { field: 'name', label: 'Name' },
185
- { field: 'description', label: 'Description' }
186
- ],
187
- venues: [
188
- { field: 'id', label: 'ID' },
189
- { field: 'name', label: 'Name' },
190
- { field: 'address', label: 'Address' },
191
- { field: 'org.name', label: 'Organisation' }
192
- ],
193
- packages: [
194
- { field: 'id', label: 'ID' },
195
- { field: 'name', label: 'Name' },
196
- { field: 'owner.name', label: 'Owner' },
197
- { field: 'sponsor.name', label: 'Sponsor' }
198
- ],
199
- allocations: [
200
- { field: 'id', label: 'ID' },
201
- { field: 'package.name', label: 'Package' },
202
- { field: 'site.name', label: 'Site' },
203
- { field: 'from_datetime', label: 'From' }
204
- ]
205
- };
206
- return columnSets[entity] || [];
207
- }