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.
- package/README.md +8 -1
- package/docs/CLIENT-QUICK-START.md +183 -0
- package/docs/CLIENT-STORES.md +730 -0
- package/docs/REFERENCE.md +139 -0
- package/package.json +5 -2
- package/src/client/stores/README.md +95 -0
- package/src/client/stores/index.js +8 -0
- package/src/client/stores/useAppStore.js +285 -0
- package/src/client/stores/useWsStore.js +289 -0
- package/src/client/ws.js +87 -2
- package/src/compiler/codegen/operation-codegen.js +28 -3
- 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/index.js +35 -14
- package/src/server/index.js +90 -1
- package/src/server/subscriptions.js +209 -0
- package/src/server/ws.js +78 -2
- package/src/client/ui-configs/sample-2.js +0 -207
- package/src/client/ui-loader.js +0 -618
- package/src/client/ui.js +0 -990
- package/src/client.js +0 -9
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical DZQL WebSocket Pinia Store
|
|
3
|
+
*
|
|
4
|
+
* Manages WebSocket connection with three-phase lifecycle:
|
|
5
|
+
* 1. CONNECTING - Initial connection to server
|
|
6
|
+
* 2. AUTHENTICATING - After connection, waiting for profile (login if needed)
|
|
7
|
+
* 3. READY - Connected and authenticated, ready for use
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* // In your app
|
|
11
|
+
* import { useWsStore } from 'dzql/client/stores'
|
|
12
|
+
*
|
|
13
|
+
* const wsStore = useWsStore()
|
|
14
|
+
* await wsStore.connect() // or connect with custom URL
|
|
15
|
+
*
|
|
16
|
+
* // Access profile
|
|
17
|
+
* console.log(wsStore.profile) // null if not authenticated
|
|
18
|
+
*
|
|
19
|
+
* // Login
|
|
20
|
+
* await wsStore.login({ email: 'user@example.com', password: 'pass' })
|
|
21
|
+
*
|
|
22
|
+
* // Logout
|
|
23
|
+
* await wsStore.logout()
|
|
24
|
+
*/
|
|
25
|
+
import { defineStore } from 'pinia'
|
|
26
|
+
import { ref, computed } from 'vue'
|
|
27
|
+
import { WebSocketManager } from '../ws.js'
|
|
28
|
+
|
|
29
|
+
export const useWsStore = defineStore('dzql-ws', () => {
|
|
30
|
+
// ===== State =====
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Connection state: 'disconnected' | 'connecting' | 'connected' | 'error'
|
|
34
|
+
*/
|
|
35
|
+
const connectionState = ref('disconnected')
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* App state: 'connecting' | 'login' | 'ready'
|
|
39
|
+
* - connecting: Initial connection phase
|
|
40
|
+
* - login: Connected but needs authentication
|
|
41
|
+
* - ready: Connected and authenticated
|
|
42
|
+
*/
|
|
43
|
+
const appState = ref('connecting')
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* User profile (null if not authenticated)
|
|
47
|
+
*/
|
|
48
|
+
const profile = ref(null)
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Last error message
|
|
52
|
+
*/
|
|
53
|
+
const error = ref(null)
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* WebSocket manager instance
|
|
57
|
+
*/
|
|
58
|
+
const ws = new WebSocketManager()
|
|
59
|
+
|
|
60
|
+
// ===== Computed =====
|
|
61
|
+
|
|
62
|
+
const isConnected = computed(() => connectionState.value === 'connected')
|
|
63
|
+
const isAuthenticated = computed(() => profile.value !== null)
|
|
64
|
+
const isReady = computed(() => appState.value === 'ready')
|
|
65
|
+
const needsLogin = computed(() => appState.value === 'login')
|
|
66
|
+
const isConnecting = computed(() => appState.value === 'connecting')
|
|
67
|
+
|
|
68
|
+
// ===== Actions =====
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Connect to WebSocket server
|
|
72
|
+
*
|
|
73
|
+
* @param {string|null} url - WebSocket URL (auto-detected if null)
|
|
74
|
+
* @param {number} timeout - Connection timeout in ms
|
|
75
|
+
* @returns {Promise<void>}
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* // Auto-detect URL
|
|
79
|
+
* await wsStore.connect()
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* // Custom URL for development
|
|
83
|
+
* await wsStore.connect('ws://localhost:3000/ws')
|
|
84
|
+
*/
|
|
85
|
+
async function connect(url = null, timeout = 5000) {
|
|
86
|
+
try {
|
|
87
|
+
appState.value = 'connecting'
|
|
88
|
+
connectionState.value = 'connecting'
|
|
89
|
+
error.value = null
|
|
90
|
+
|
|
91
|
+
// Set up broadcast listener for connection events BEFORE connecting
|
|
92
|
+
ws.onBroadcast((method, params) => {
|
|
93
|
+
if (method === 'connected') {
|
|
94
|
+
connectionState.value = 'connected'
|
|
95
|
+
profile.value = params.profile || null
|
|
96
|
+
|
|
97
|
+
// Determine app state based on profile
|
|
98
|
+
if (params.profile) {
|
|
99
|
+
appState.value = 'ready'
|
|
100
|
+
} else {
|
|
101
|
+
appState.value = 'login'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log('[WsStore] Connected:', {
|
|
105
|
+
profile: params.profile,
|
|
106
|
+
appState: appState.value
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// Connect to WebSocket
|
|
112
|
+
await ws.connect(url, timeout)
|
|
113
|
+
|
|
114
|
+
// Note: appState will be updated when 'connected' broadcast arrives
|
|
115
|
+
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error('[WsStore] Connection failed:', err)
|
|
118
|
+
connectionState.value = 'error'
|
|
119
|
+
error.value = err.message
|
|
120
|
+
throw err
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Login with email and password
|
|
126
|
+
*
|
|
127
|
+
* @param {Object} credentials
|
|
128
|
+
* @param {string} credentials.email
|
|
129
|
+
* @param {string} credentials.password
|
|
130
|
+
* @returns {Promise<Object>} Login result with token and profile
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* const result = await wsStore.login({
|
|
134
|
+
* email: 'user@example.com',
|
|
135
|
+
* password: 'password123'
|
|
136
|
+
* })
|
|
137
|
+
*/
|
|
138
|
+
async function login({ email, password }) {
|
|
139
|
+
try {
|
|
140
|
+
error.value = null
|
|
141
|
+
|
|
142
|
+
const result = await ws.call('login_user', { email, password })
|
|
143
|
+
|
|
144
|
+
if (result.token) {
|
|
145
|
+
// Store token in localStorage
|
|
146
|
+
if (typeof localStorage !== 'undefined') {
|
|
147
|
+
localStorage.setItem('dzql_token', result.token)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Update profile
|
|
151
|
+
profile.value = result.profile
|
|
152
|
+
appState.value = 'ready'
|
|
153
|
+
|
|
154
|
+
console.log('[WsStore] Login successful:', result.profile)
|
|
155
|
+
|
|
156
|
+
return result
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
throw new Error('No token received')
|
|
160
|
+
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error('[WsStore] Login failed:', err)
|
|
163
|
+
error.value = err.message
|
|
164
|
+
throw err
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Register a new user
|
|
170
|
+
*
|
|
171
|
+
* @param {Object} credentials
|
|
172
|
+
* @param {string} credentials.email
|
|
173
|
+
* @param {string} credentials.password
|
|
174
|
+
* @returns {Promise<Object>} Registration result with token and profile
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* const result = await wsStore.register({
|
|
178
|
+
* email: 'newuser@example.com',
|
|
179
|
+
* password: 'securepass123'
|
|
180
|
+
* })
|
|
181
|
+
*/
|
|
182
|
+
async function register({ email, password }) {
|
|
183
|
+
try {
|
|
184
|
+
error.value = null
|
|
185
|
+
|
|
186
|
+
const result = await ws.call('register_user', { email, password })
|
|
187
|
+
|
|
188
|
+
if (result.token) {
|
|
189
|
+
// Store token in localStorage
|
|
190
|
+
if (typeof localStorage !== 'undefined') {
|
|
191
|
+
localStorage.setItem('dzql_token', result.token)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Update profile
|
|
195
|
+
profile.value = result.profile
|
|
196
|
+
appState.value = 'ready'
|
|
197
|
+
|
|
198
|
+
console.log('[WsStore] Registration successful:', result.profile)
|
|
199
|
+
|
|
200
|
+
return result
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
throw new Error('No token received')
|
|
204
|
+
|
|
205
|
+
} catch (err) {
|
|
206
|
+
console.error('[WsStore] Registration failed:', err)
|
|
207
|
+
error.value = err.message
|
|
208
|
+
throw err
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Logout and clear session
|
|
214
|
+
*
|
|
215
|
+
* @returns {Promise<void>}
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* await wsStore.logout()
|
|
219
|
+
*/
|
|
220
|
+
async function logout() {
|
|
221
|
+
try {
|
|
222
|
+
// Call server logout
|
|
223
|
+
await ws.call('logout')
|
|
224
|
+
} catch (err) {
|
|
225
|
+
console.warn('[WsStore] Server logout failed:', err)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Clear local state
|
|
229
|
+
if (typeof localStorage !== 'undefined') {
|
|
230
|
+
localStorage.removeItem('dzql_token')
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
profile.value = null
|
|
234
|
+
appState.value = 'login'
|
|
235
|
+
|
|
236
|
+
// Reconnect to get fresh state
|
|
237
|
+
await connect()
|
|
238
|
+
|
|
239
|
+
console.log('[WsStore] Logged out')
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Disconnect from WebSocket
|
|
244
|
+
*/
|
|
245
|
+
function disconnect() {
|
|
246
|
+
ws.disconnect()
|
|
247
|
+
connectionState.value = 'disconnected'
|
|
248
|
+
appState.value = 'connecting'
|
|
249
|
+
console.log('[WsStore] Disconnected')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Get the WebSocket manager instance for direct API calls
|
|
254
|
+
*
|
|
255
|
+
* @returns {WebSocketManager}
|
|
256
|
+
*
|
|
257
|
+
* @example
|
|
258
|
+
* const ws = wsStore.getWs()
|
|
259
|
+
* const venue = await ws.api.get.venues({ id: 1 })
|
|
260
|
+
*/
|
|
261
|
+
function getWs() {
|
|
262
|
+
return ws
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ===== Return Public API =====
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
// State
|
|
269
|
+
connectionState,
|
|
270
|
+
appState,
|
|
271
|
+
profile,
|
|
272
|
+
error,
|
|
273
|
+
|
|
274
|
+
// Computed
|
|
275
|
+
isConnected,
|
|
276
|
+
isAuthenticated,
|
|
277
|
+
isReady,
|
|
278
|
+
needsLogin,
|
|
279
|
+
isConnecting,
|
|
280
|
+
|
|
281
|
+
// Actions
|
|
282
|
+
connect,
|
|
283
|
+
login,
|
|
284
|
+
register,
|
|
285
|
+
logout,
|
|
286
|
+
disconnect,
|
|
287
|
+
getWs
|
|
288
|
+
}
|
|
289
|
+
})
|
package/src/client/ws.js
CHANGED
|
@@ -59,12 +59,11 @@ class WebSocketManager {
|
|
|
59
59
|
this.pendingRequests = new Map();
|
|
60
60
|
this.broadcastCallbacks = new Set();
|
|
61
61
|
this.sidRequestHandlers = new Set();
|
|
62
|
+
this.subscriptions = new Map(); // subscription_id -> { callback, unsubscribe }
|
|
62
63
|
this.reconnectAttempts = 0;
|
|
63
64
|
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
|
|
64
65
|
this.isShuttingDown = false;
|
|
65
66
|
|
|
66
|
-
// Ad
|
|
67
|
-
|
|
68
67
|
// DZQL nested proxy API - matches server-side db.api pattern
|
|
69
68
|
// Proxy handles both DZQL operations and custom functions
|
|
70
69
|
const dzqlOps = {
|
|
@@ -137,6 +136,18 @@ class WebSocketManager {
|
|
|
137
136
|
if (prop in target) {
|
|
138
137
|
return target[prop];
|
|
139
138
|
}
|
|
139
|
+
// Handle subscribe_* methods specially
|
|
140
|
+
if (prop.startsWith('subscribe_')) {
|
|
141
|
+
return (params = {}, callback) => {
|
|
142
|
+
return this.subscribe(prop, params, callback);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// Handle unsubscribe_* methods
|
|
146
|
+
if (prop.startsWith('unsubscribe_')) {
|
|
147
|
+
return (params = {}) => {
|
|
148
|
+
return this.unsubscribe(prop, params);
|
|
149
|
+
};
|
|
150
|
+
}
|
|
140
151
|
// All other properties are treated as custom function calls
|
|
141
152
|
return (params = {}) => {
|
|
142
153
|
return this.call(prop, params);
|
|
@@ -314,6 +325,16 @@ class WebSocketManager {
|
|
|
314
325
|
resolve(message.result);
|
|
315
326
|
}
|
|
316
327
|
} else {
|
|
328
|
+
// Handle subscription updates
|
|
329
|
+
if (message.method === "subscription:update") {
|
|
330
|
+
const { subscription_id, data } = message.params;
|
|
331
|
+
const sub = this.subscriptions.get(subscription_id);
|
|
332
|
+
if (sub && sub.callback) {
|
|
333
|
+
sub.callback(data);
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
317
338
|
// Handle broadcasts and SID requests
|
|
318
339
|
|
|
319
340
|
// Check if this is a SID request from server
|
|
@@ -376,6 +397,70 @@ class WebSocketManager {
|
|
|
376
397
|
});
|
|
377
398
|
}
|
|
378
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Subscribe to a live query
|
|
402
|
+
*
|
|
403
|
+
* @param {string} method - Method name (subscribe_<subscribable>)
|
|
404
|
+
* @param {object} params - Subscription parameters
|
|
405
|
+
* @param {function} callback - Callback function for updates
|
|
406
|
+
* @returns {Promise<{data, subscription_id, unsubscribe}>} Initial data and unsubscribe function
|
|
407
|
+
*
|
|
408
|
+
* @example
|
|
409
|
+
* const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
|
|
410
|
+
* { venue_id: 1 },
|
|
411
|
+
* (updated) => console.log('Updated:', updated)
|
|
412
|
+
* );
|
|
413
|
+
*
|
|
414
|
+
* // Use initial data
|
|
415
|
+
* console.log('Initial:', data);
|
|
416
|
+
*
|
|
417
|
+
* // Later: unsubscribe
|
|
418
|
+
* unsubscribe();
|
|
419
|
+
*/
|
|
420
|
+
async subscribe(method, params = {}, callback) {
|
|
421
|
+
if (!callback || typeof callback !== 'function') {
|
|
422
|
+
throw new Error('Subscribe requires a callback function');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Call server to register subscription
|
|
426
|
+
const result = await this.call(method, params);
|
|
427
|
+
const { subscription_id, data } = result;
|
|
428
|
+
|
|
429
|
+
// Create unsubscribe function
|
|
430
|
+
const unsubscribeFn = async () => {
|
|
431
|
+
const unsubMethod = method.replace('subscribe_', 'unsubscribe_');
|
|
432
|
+
await this.call(unsubMethod, params);
|
|
433
|
+
this.subscriptions.delete(subscription_id);
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
// Store callback for updates
|
|
437
|
+
this.subscriptions.set(subscription_id, {
|
|
438
|
+
callback,
|
|
439
|
+
unsubscribe: unsubscribeFn
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// Return initial data and unsubscribe function
|
|
443
|
+
return {
|
|
444
|
+
data,
|
|
445
|
+
subscription_id,
|
|
446
|
+
unsubscribe: unsubscribeFn
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Unsubscribe from a live query
|
|
452
|
+
*
|
|
453
|
+
* @param {string} method - Method name (unsubscribe_<subscribable>)
|
|
454
|
+
* @param {object} params - Subscription parameters
|
|
455
|
+
* @returns {Promise<{success: boolean}>}
|
|
456
|
+
*
|
|
457
|
+
* @example
|
|
458
|
+
* await ws.api.unsubscribe_venue_detail({ venue_id: 1 });
|
|
459
|
+
*/
|
|
460
|
+
async unsubscribe(method, params = {}) {
|
|
461
|
+
return await this.call(method, params);
|
|
462
|
+
}
|
|
463
|
+
|
|
379
464
|
/**
|
|
380
465
|
* Register callback for real-time broadcast events
|
|
381
466
|
*
|
|
@@ -385,6 +385,31 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
385
385
|
AND (${validTo} > COALESCE(p_on_date, NOW()) OR ${validTo} IS NULL)`;
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
+
/**
|
|
389
|
+
* Check if a trigger has any rules with actions
|
|
390
|
+
* @private
|
|
391
|
+
*/
|
|
392
|
+
_hasGraphRuleActions(trigger) {
|
|
393
|
+
const rules = this.entity.graphRules[trigger];
|
|
394
|
+
if (!rules || typeof rules !== 'object') {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Check if any rule has actions
|
|
399
|
+
for (const ruleConfig of Object.values(rules)) {
|
|
400
|
+
if (ruleConfig && ruleConfig.actions) {
|
|
401
|
+
const actions = Array.isArray(ruleConfig.actions)
|
|
402
|
+
? ruleConfig.actions
|
|
403
|
+
: [ruleConfig.actions];
|
|
404
|
+
if (actions.length > 0) {
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
|
|
388
413
|
/**
|
|
389
414
|
* Generate graph rules call
|
|
390
415
|
* @private
|
|
@@ -396,7 +421,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
396
421
|
|
|
397
422
|
// For DELETE operation
|
|
398
423
|
if (operation === 'delete') {
|
|
399
|
-
if (this.
|
|
424
|
+
if (this._hasGraphRuleActions('on_delete')) {
|
|
400
425
|
return `
|
|
401
426
|
-- Execute graph rules: on_delete
|
|
402
427
|
PERFORM _graph_${this.tableName}_on_delete(p_user_id, to_jsonb(v_result));`;
|
|
@@ -407,7 +432,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
407
432
|
// For SAVE operation (create/update)
|
|
408
433
|
const calls = [];
|
|
409
434
|
|
|
410
|
-
if (this.
|
|
435
|
+
if (this._hasGraphRuleActions('on_create')) {
|
|
411
436
|
calls.push(`
|
|
412
437
|
-- Execute graph rules: on_create (if insert)
|
|
413
438
|
IF v_is_insert THEN
|
|
@@ -415,7 +440,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
415
440
|
END IF;`);
|
|
416
441
|
}
|
|
417
442
|
|
|
418
|
-
if (this.
|
|
443
|
+
if (this._hasGraphRuleActions('on_update')) {
|
|
419
444
|
calls.push(`
|
|
420
445
|
-- Execute graph rules: on_update (if update)
|
|
421
446
|
IF NOT v_is_insert THEN
|