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,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.entity.graphRules.on_delete) {
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.entity.graphRules.on_create) {
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.entity.graphRules.on_update) {
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