dzql 0.1.6 → 0.2.1
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 +23 -1
- package/docs/LIVE_QUERY_SUBSCRIPTIONS.md +535 -0
- package/docs/LIVE_QUERY_SUBSCRIPTIONS_STRATEGY.md +488 -0
- package/docs/REFERENCE.md +139 -0
- package/docs/SUBSCRIPTIONS_QUICK_START.md +203 -0
- package/package.json +2 -3
- package/src/client/ws.js +87 -2
- package/src/compiler/cli/compile-example.js +33 -0
- package/src/compiler/cli/compile-subscribable.js +43 -0
- package/src/compiler/cli/debug-compile.js +44 -0
- package/src/compiler/cli/debug-parse.js +26 -0
- package/src/compiler/cli/debug-path-parser.js +18 -0
- package/src/compiler/cli/debug-subscribable-parser.js +21 -0
- package/src/compiler/codegen/subscribable-codegen.js +446 -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/client/stores/README.md +0 -95
|
@@ -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
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
# DZQL Canonical Pinia Stores
|
|
2
|
-
|
|
3
|
-
**The official, AI-friendly Pinia stores for DZQL Vue.js applications.**
|
|
4
|
-
|
|
5
|
-
## Why These Stores Exist
|
|
6
|
-
|
|
7
|
-
When building DZQL apps, developers (and AI assistants) often struggle with:
|
|
8
|
-
|
|
9
|
-
1. **Three-phase lifecycle** - connecting → login → ready
|
|
10
|
-
2. **WebSocket connection management** - reconnection, error handling
|
|
11
|
-
3. **Authentication flow** - token storage, profile management
|
|
12
|
-
4. **Router integration** - navigation, state synchronization
|
|
13
|
-
5. **Inconsistent patterns** - every project does it differently
|
|
14
|
-
|
|
15
|
-
These canonical stores solve all of these problems with a **simple, consistent pattern** that AI can easily understand and replicate.
|
|
16
|
-
|
|
17
|
-
## The Stores
|
|
18
|
-
|
|
19
|
-
### `useWsStore` - WebSocket & Auth
|
|
20
|
-
|
|
21
|
-
Manages:
|
|
22
|
-
- WebSocket connection (with auto-reconnect)
|
|
23
|
-
- User authentication (login/register/logout)
|
|
24
|
-
- Connection state tracking
|
|
25
|
-
- Three-phase app lifecycle
|
|
26
|
-
|
|
27
|
-
### `useAppStore` - Application State
|
|
28
|
-
|
|
29
|
-
Manages:
|
|
30
|
-
- App initialization
|
|
31
|
-
- Router integration
|
|
32
|
-
- Entity metadata caching
|
|
33
|
-
- Navigation helpers
|
|
34
|
-
- UI state (sidebars, panels)
|
|
35
|
-
|
|
36
|
-
## Quick Example
|
|
37
|
-
|
|
38
|
-
```vue
|
|
39
|
-
<script setup>
|
|
40
|
-
import { computed } from 'vue'
|
|
41
|
-
import { useWsStore, useAppStore } from 'dzql/client/stores'
|
|
42
|
-
|
|
43
|
-
const wsStore = useWsStore()
|
|
44
|
-
const appStore = useAppStore()
|
|
45
|
-
|
|
46
|
-
const state = computed(() => wsStore.appState)
|
|
47
|
-
const ws = wsStore.getWs()
|
|
48
|
-
|
|
49
|
-
// Use DZQL API
|
|
50
|
-
const venues = await ws.api.search.venues({ limit: 50 })
|
|
51
|
-
</script>
|
|
52
|
-
|
|
53
|
-
<template>
|
|
54
|
-
<!-- Three-phase lifecycle -->
|
|
55
|
-
<div v-if="state === 'connecting'">Connecting...</div>
|
|
56
|
-
<LoginView v-else-if="state === 'login'" />
|
|
57
|
-
<MainApp v-else-if="state === 'ready'" />
|
|
58
|
-
</template>
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
## Documentation
|
|
62
|
-
|
|
63
|
-
- **[Quick Start](../../../docs/CLIENT-QUICK-START.md)** - Get running in 5 minutes
|
|
64
|
-
- **[Complete Guide](../../../docs/CLIENT-STORES.md)** - Full API reference and patterns
|
|
65
|
-
|
|
66
|
-
## Files
|
|
67
|
-
|
|
68
|
-
- `useWsStore.js` - WebSocket connection and authentication
|
|
69
|
-
- `useAppStore.js` - Application state and navigation
|
|
70
|
-
- `index.js` - Exports both stores
|
|
71
|
-
|
|
72
|
-
## For AI Assistants
|
|
73
|
-
|
|
74
|
-
When helping users with DZQL apps:
|
|
75
|
-
|
|
76
|
-
1. **Always use these stores** - Don't create custom connection logic
|
|
77
|
-
2. **Follow the three-phase lifecycle** - connecting → login → ready
|
|
78
|
-
3. **Use computed for reactive state** - `const profile = computed(() => wsStore.profile)`
|
|
79
|
-
4. **Get WS instance for API calls** - `const ws = wsStore.getWs()`
|
|
80
|
-
|
|
81
|
-
**Example prompt for AI:**
|
|
82
|
-
|
|
83
|
-
> "I'm using the canonical DZQL stores from `dzql/client/stores`. The pattern is:
|
|
84
|
-
> 1. useWsStore for WebSocket connection (three phases: connecting, login, ready)
|
|
85
|
-
> 2. useAppStore for app state and navigation
|
|
86
|
-
> 3. Access DZQL API via `wsStore.getWs().api.get.venues({ id: 1 })`
|
|
87
|
-
> Please follow this pattern."
|
|
88
|
-
|
|
89
|
-
## Version
|
|
90
|
-
|
|
91
|
-
These stores are available in DZQL v0.1.6+
|
|
92
|
-
|
|
93
|
-
## License
|
|
94
|
-
|
|
95
|
-
MIT
|