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.
- package/README.md +8 -1
- package/docs/REFERENCE.md +139 -0
- package/package.json +2 -2
- package/src/client/ws.js +87 -2
- package/src/compiler/codegen/subscribable-codegen.js +396 -0
- package/src/compiler/compiler.js +115 -0
- package/src/compiler/parser/subscribable-parser.js +242 -0
- package/src/database/migrations/009_subscriptions.sql +230 -0
- package/src/server/index.js +90 -1
- package/src/server/subscriptions.js +209 -0
- package/src/server/ws.js +78 -2
package/src/server/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|