dzql 0.5.33 → 0.6.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.
Files changed (142) hide show
  1. package/.env.sample +28 -0
  2. package/compose.yml +28 -0
  3. package/dist/client/index.ts +1 -0
  4. package/dist/client/stores/useMyProfileStore.ts +114 -0
  5. package/dist/client/stores/useOrgDashboardStore.ts +131 -0
  6. package/dist/client/stores/useVenueDetailStore.ts +117 -0
  7. package/dist/client/ws.ts +716 -0
  8. package/dist/db/migrations/000_core.sql +92 -0
  9. package/dist/db/migrations/20251229T212912022Z_schema.sql +3020 -0
  10. package/dist/db/migrations/20251229T212912022Z_subscribables.sql +371 -0
  11. package/dist/runtime/manifest.json +1562 -0
  12. package/docs/README.md +309 -36
  13. package/docs/feature-requests/applyPatch-bug-report.md +85 -0
  14. package/docs/feature-requests/connection-ready-profile.md +57 -0
  15. package/docs/feature-requests/hidden-bug-report.md +111 -0
  16. package/docs/feature-requests/hidden-fields-subscribables.md +34 -0
  17. package/docs/feature-requests/subscribable-param-key-bug.md +38 -0
  18. package/docs/feature-requests/todo.md +146 -0
  19. package/docs/for_ai.md +653 -0
  20. package/docs/project-setup.md +456 -0
  21. package/examples/blog.ts +50 -0
  22. package/examples/invalid.ts +18 -0
  23. package/examples/venues.js +485 -0
  24. package/package.json +23 -60
  25. package/src/cli/codegen/client.ts +99 -0
  26. package/src/cli/codegen/manifest.ts +95 -0
  27. package/src/cli/codegen/pinia.ts +174 -0
  28. package/src/cli/codegen/realtime.ts +58 -0
  29. package/src/cli/codegen/sql.ts +698 -0
  30. package/src/cli/codegen/subscribable_sql.ts +547 -0
  31. package/src/cli/codegen/subscribable_store.ts +184 -0
  32. package/src/cli/codegen/types.ts +142 -0
  33. package/src/cli/compiler/analyzer.ts +52 -0
  34. package/src/cli/compiler/graph_rules.ts +251 -0
  35. package/src/cli/compiler/ir.ts +233 -0
  36. package/src/cli/compiler/loader.ts +132 -0
  37. package/src/cli/compiler/permissions.ts +227 -0
  38. package/src/cli/index.ts +166 -0
  39. package/src/client/index.ts +1 -0
  40. package/src/client/ws.ts +286 -0
  41. package/src/runtime/auth.ts +39 -0
  42. package/src/runtime/db.ts +33 -0
  43. package/src/runtime/errors.ts +51 -0
  44. package/src/runtime/index.ts +98 -0
  45. package/src/runtime/js_functions.ts +63 -0
  46. package/src/runtime/manifest_loader.ts +29 -0
  47. package/src/runtime/namespace.ts +483 -0
  48. package/src/runtime/server.ts +87 -0
  49. package/src/runtime/ws.ts +197 -0
  50. package/src/shared/ir.ts +197 -0
  51. package/tests/client.test.ts +38 -0
  52. package/tests/codegen.test.ts +71 -0
  53. package/tests/compiler.test.ts +45 -0
  54. package/tests/graph_rules.test.ts +173 -0
  55. package/tests/integration/db.test.ts +174 -0
  56. package/tests/integration/e2e.test.ts +65 -0
  57. package/tests/integration/features.test.ts +922 -0
  58. package/tests/integration/full_stack.test.ts +262 -0
  59. package/tests/integration/setup.ts +45 -0
  60. package/tests/ir.test.ts +32 -0
  61. package/tests/namespace.test.ts +395 -0
  62. package/tests/permissions.test.ts +55 -0
  63. package/tests/pinia.test.ts +48 -0
  64. package/tests/realtime.test.ts +22 -0
  65. package/tests/runtime.test.ts +80 -0
  66. package/tests/subscribable_gen.test.ts +72 -0
  67. package/tests/subscribable_reactivity.test.ts +258 -0
  68. package/tests/venues_gen.test.ts +25 -0
  69. package/tsconfig.json +20 -0
  70. package/tsconfig.tsbuildinfo +1 -0
  71. package/README.md +0 -90
  72. package/bin/cli.js +0 -727
  73. package/docs/compiler/ADVANCED_FILTERS.md +0 -183
  74. package/docs/compiler/CODING_STANDARDS.md +0 -415
  75. package/docs/compiler/COMPARISON.md +0 -673
  76. package/docs/compiler/QUICKSTART.md +0 -326
  77. package/docs/compiler/README.md +0 -134
  78. package/docs/examples/README.md +0 -38
  79. package/docs/examples/blog.sql +0 -160
  80. package/docs/examples/venue-detail-simple.sql +0 -8
  81. package/docs/examples/venue-detail-subscribable.sql +0 -45
  82. package/docs/for-ai/claude-guide.md +0 -1210
  83. package/docs/getting-started/quickstart.md +0 -125
  84. package/docs/getting-started/subscriptions-quick-start.md +0 -203
  85. package/docs/getting-started/tutorial.md +0 -1104
  86. package/docs/guides/atomic-updates.md +0 -299
  87. package/docs/guides/client-stores.md +0 -730
  88. package/docs/guides/composite-primary-keys.md +0 -158
  89. package/docs/guides/custom-functions.md +0 -362
  90. package/docs/guides/drop-semantics.md +0 -554
  91. package/docs/guides/field-defaults.md +0 -240
  92. package/docs/guides/interpreter-vs-compiler.md +0 -237
  93. package/docs/guides/many-to-many.md +0 -929
  94. package/docs/guides/subscriptions.md +0 -537
  95. package/docs/reference/api.md +0 -1373
  96. package/docs/reference/client.md +0 -224
  97. package/src/client/stores/index.js +0 -8
  98. package/src/client/stores/useAppStore.js +0 -285
  99. package/src/client/stores/useWsStore.js +0 -289
  100. package/src/client/ws.js +0 -762
  101. package/src/compiler/cli/compile-example.js +0 -33
  102. package/src/compiler/cli/compile-subscribable.js +0 -43
  103. package/src/compiler/cli/debug-compile.js +0 -44
  104. package/src/compiler/cli/debug-parse.js +0 -26
  105. package/src/compiler/cli/debug-path-parser.js +0 -18
  106. package/src/compiler/cli/debug-subscribable-parser.js +0 -21
  107. package/src/compiler/cli/index.js +0 -174
  108. package/src/compiler/codegen/auth-codegen.js +0 -153
  109. package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
  110. package/src/compiler/codegen/graph-rules-codegen.js +0 -450
  111. package/src/compiler/codegen/notification-codegen.js +0 -232
  112. package/src/compiler/codegen/operation-codegen.js +0 -1382
  113. package/src/compiler/codegen/permission-codegen.js +0 -318
  114. package/src/compiler/codegen/subscribable-codegen.js +0 -827
  115. package/src/compiler/compiler.js +0 -371
  116. package/src/compiler/index.js +0 -11
  117. package/src/compiler/parser/entity-parser.js +0 -440
  118. package/src/compiler/parser/path-parser.js +0 -290
  119. package/src/compiler/parser/subscribable-parser.js +0 -244
  120. package/src/database/dzql-core.sql +0 -161
  121. package/src/database/migrations/001_schema.sql +0 -60
  122. package/src/database/migrations/002_functions.sql +0 -890
  123. package/src/database/migrations/003_operations.sql +0 -1135
  124. package/src/database/migrations/004_search.sql +0 -581
  125. package/src/database/migrations/005_entities.sql +0 -730
  126. package/src/database/migrations/006_auth.sql +0 -94
  127. package/src/database/migrations/007_events.sql +0 -133
  128. package/src/database/migrations/008_hello.sql +0 -18
  129. package/src/database/migrations/008a_meta.sql +0 -172
  130. package/src/database/migrations/009_subscriptions.sql +0 -240
  131. package/src/database/migrations/010_atomic_updates.sql +0 -157
  132. package/src/database/migrations/010_fix_m2m_events.sql +0 -94
  133. package/src/index.js +0 -40
  134. package/src/server/api.js +0 -9
  135. package/src/server/db.js +0 -442
  136. package/src/server/index.js +0 -317
  137. package/src/server/logger.js +0 -259
  138. package/src/server/mcp.js +0 -594
  139. package/src/server/meta-route.js +0 -251
  140. package/src/server/namespace.js +0 -292
  141. package/src/server/subscriptions.js +0 -351
  142. package/src/server/ws.js +0 -573
package/src/client/ws.js DELETED
@@ -1,762 +0,0 @@
1
- /**
2
- * WebSocket manager for DZQL client-side real-time communication
3
- *
4
- * Provides:
5
- * - WebSocket connection management with auto-reconnect
6
- * - JSON-RPC 2.0 protocol for API calls
7
- * - Proxy-based API matching server-side db.api pattern
8
- * - Real-time broadcast event handling
9
- * - Automatic JWT authentication
10
- *
11
- * @class WebSocketManager
12
- *
13
- * @example
14
- * // Basic usage
15
- * import { WebSocketManager } from 'dzql/client';
16
- *
17
- * const ws = new WebSocketManager();
18
- * await ws.connect('ws://localhost:3000/ws');
19
- *
20
- * // Login
21
- * const session = await ws.api.login_user({
22
- * email: 'user@example.com',
23
- * password: 'password123'
24
- * });
25
- *
26
- * // Register with options (e.g., organisation name)
27
- * const session = await ws.api.register_user({
28
- * email: 'user@example.com',
29
- * password: 'password123',
30
- * options: { org_name: 'Acme Corp' }
31
- * });
32
- *
33
- * // CRUD operations
34
- * const venue = await ws.api.get.venues({ id: 1 });
35
- * const created = await ws.api.save.venues({ name: 'New Venue' });
36
- *
37
- * // Listen to real-time updates
38
- * ws.onBroadcast((method, params) => {
39
- * console.log(`Event: ${method}`, params);
40
- * });
41
- *
42
- * @example
43
- * // Advanced search
44
- * const results = await ws.api.search.venues({
45
- * filters: {
46
- * city: 'New York',
47
- * capacity: { gte: 1000 },
48
- * _search: 'garden'
49
- * },
50
- * sort: { field: 'name', order: 'asc' },
51
- * page: 1,
52
- * limit: 25
53
- * });
54
- */
55
- // Pure WebSocket manager class (no React dependencies)
56
- class WebSocketManager {
57
- /**
58
- * Create a WebSocketManager instance
59
- *
60
- * @param {Object} [options={}] - Configuration options
61
- * @param {number} [options.maxReconnectAttempts=5] - Maximum reconnection attempts before giving up
62
- * @param {string} [options.tokenName='dzql_token'] - Name of the localStorage key for JWT token
63
- */
64
- constructor(options = {}) {
65
- this.ws = null;
66
- this.messageId = 0;
67
- this.pendingRequests = new Map();
68
- this.broadcastCallbacks = new Set();
69
- this.sidRequestHandlers = new Set();
70
- this.subscriptions = new Map(); // subscription_id -> { callback, unsubscribe }
71
- this.reconnectAttempts = 0;
72
- this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
73
- this.tokenName = options.tokenName ?? 'dzql_token';
74
- this.isShuttingDown = false;
75
-
76
- // DZQL nested proxy API - matches server-side db.api pattern
77
- // Proxy handles both DZQL operations and custom functions
78
- const dzqlOps = {
79
- get: this.createEntityProxy("get"),
80
- save: this.createEntityProxy("save"),
81
- delete: this.createEntityProxy("delete"),
82
- lookup: this.createEntityProxy("lookup"),
83
- search: this.createEntityProxy("search"),
84
- };
85
-
86
- /**
87
- * API proxy for calling DZQL operations and custom functions
88
- *
89
- * @member {Object} api
90
- * @memberof WebSocketManager
91
- *
92
- * @property {Object} get - Get single record by primary key
93
- * @property {Object} save - Create or update record (upsert)
94
- * @property {Object} delete - Delete record by primary key
95
- * @property {Object} lookup - Autocomplete lookup by label field
96
- * @property {Object} search - Advanced search with filters
97
- *
98
- * @example
99
- * // Get operation
100
- * const venue = await ws.api.get.venues({ id: 1 });
101
- *
102
- * @example
103
- * // Save operation (create)
104
- * const created = await ws.api.save.venues({
105
- * name: 'New Venue',
106
- * org_id: 3
107
- * });
108
- *
109
- * @example
110
- * // Save operation (update)
111
- * const updated = await ws.api.save.venues({
112
- * id: 1,
113
- * name: 'Updated Name'
114
- * });
115
- *
116
- * @example
117
- * // Delete operation
118
- * await ws.api.delete.venues({ id: 1 });
119
- *
120
- * @example
121
- * // Lookup for autocomplete
122
- * const results = await ws.api.lookup.venues({ p_filter: 'garden' });
123
- *
124
- * @example
125
- * // Search with filters
126
- * const results = await ws.api.search.venues({
127
- * filters: {
128
- * city: 'New York',
129
- * capacity: { gte: 1000, lt: 5000 },
130
- * name: { ilike: '%garden%' },
131
- * _search: 'madison'
132
- * },
133
- * sort: { field: 'name', order: 'asc' },
134
- * page: 1,
135
- * limit: 25
136
- * });
137
- *
138
- * @example
139
- * // Call custom function
140
- * const result = await ws.api.myCustomFunction({ param: 'value' });
141
- */
142
- this.api = new Proxy(dzqlOps, {
143
- get: (target, prop) => {
144
- // Return cached DZQL operation if it exists
145
- if (prop in target) {
146
- return target[prop];
147
- }
148
- // Handle subscribe_* methods specially
149
- if (prop.startsWith('subscribe_')) {
150
- return (params = {}, callback) => {
151
- return this.subscribe(prop, params, callback);
152
- };
153
- }
154
- // Handle unsubscribe_* methods
155
- if (prop.startsWith('unsubscribe_')) {
156
- return (params = {}) => {
157
- return this.unsubscribe(prop, params);
158
- };
159
- }
160
- // All other properties are treated as custom function calls
161
- return (params = {}) => {
162
- return this.call(prop, params);
163
- };
164
- },
165
- });
166
- }
167
-
168
- /**
169
- * Create entity proxy for DZQL operations
170
- *
171
- * @param {string} operation - The operation type (get, save, delete, lookup, search)
172
- * @returns {Proxy} A proxy that creates entity-specific methods
173
- *
174
- * @example
175
- * // For search operations with advanced filtering:
176
- * const venues = await ws.api.search.venues({
177
- * filters: {
178
- * city: "New York", // Exact match
179
- * capacity: { gte: 1000, lt: 5000 }, // Range operators
180
- * name: { ilike: "%garden%" }, // Pattern matching
181
- * categories: ["sports", "concert"], // IN array
182
- * description: { not_null: true }, // Not null check
183
- * _search: "madison" // Text search across searchable fields
184
- * },
185
- * sort: { field: "name", order: "asc" },
186
- * page: 1,
187
- * limit: 25,
188
- * on_date: "2024-01-15" // Optional temporal filter
189
- * });
190
- *
191
- * Filter operators supported:
192
- * - Exact match: {field: "value"}
193
- * - Greater than: {field: {gt: 100}}
194
- * - Greater or equal: {field: {gte: 100}}
195
- * - Less than: {field: {lt: 100}}
196
- * - Less or equal: {field: {lte: 100}}
197
- * - Not equal: {field: {neq: "value"}}
198
- * - Between: {field: {between: [10, 100]}}
199
- * - Pattern match: {field: {like: "%pattern%"}}
200
- * - Case-insensitive: {field: {ilike: "%pattern%"}}
201
- * - IN array: {field: ["value1", "value2"]}
202
- * - NOT IN: {field: {not_in: ["value1", "value2"]}}
203
- * - IS NULL: {field: null}
204
- * - IS NOT NULL: {field: {not_null: true}}
205
- * - Text search: {_search: "search terms"}
206
- *
207
- * Response format:
208
- * {
209
- * data: [...], // Array of results
210
- * total: 100, // Total count before pagination
211
- * page: 1, // Current page number
212
- * limit: 50 // Results per page
213
- * }
214
- *
215
- * Error handling:
216
- * Invalid column names will throw an error with message:
217
- * "Column {column_name} does not exist in table {table_name}"
218
- *
219
- * Invalid operators are silently ignored (no error).
220
- */
221
- createEntityProxy(operation) {
222
- return new Proxy(
223
- {},
224
- {
225
- get: (target, entityName) => {
226
- return (params = {}) => {
227
- return this.call(`dzql.${operation}.${entityName}`, params);
228
- };
229
- },
230
- },
231
- );
232
- }
233
-
234
- /**
235
- * Connect to DZQL WebSocket server
236
- *
237
- * Automatically detects environment (browser vs Node.js) and constructs WebSocket URL.
238
- * If JWT token exists in localStorage, automatically includes it in connection.
239
- *
240
- * @param {string|null} [url=null] - WebSocket URL (auto-detected if not provided)
241
- * Browser: ws://current-host/ws or wss://current-host/ws
242
- * Node.js: ws://localhost:3000/ws
243
- * @param {number} [timeout=5000] - Connection timeout in milliseconds
244
- *
245
- * @returns {Promise<void>} Resolves when connected, rejects on timeout or error
246
- *
247
- * @example
248
- * // Auto-detect URL (browser)
249
- * await ws.connect();
250
- *
251
- * @example
252
- * // Explicit URL
253
- * await ws.connect('ws://localhost:3000/ws');
254
- *
255
- * @example
256
- * // Custom timeout
257
- * await ws.connect(null, 10000); // 10 second timeout
258
- */
259
- connect(url = null, timeout = 5000) {
260
- return new Promise((resolve, reject) => {
261
- let wsUrl;
262
-
263
- if (url) {
264
- // Direct URL provided (for testing)
265
- wsUrl = url;
266
- } else if (typeof window !== "undefined") {
267
- // Browser environment
268
- const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
269
- wsUrl = `${protocol}//${window.location.host}/ws`;
270
- } else {
271
- // Node.js environment (default for testing)
272
- wsUrl = "ws://localhost:3000/ws";
273
- }
274
-
275
- // Add JWT token as query parameter if available
276
- if (typeof localStorage !== 'undefined'){
277
- const storedToken = localStorage.getItem(this.tokenName);
278
- if (storedToken) {
279
- wsUrl += `?token=${encodeURIComponent(storedToken)}`;
280
- }
281
- }
282
-
283
- const connectionTimeout = setTimeout(() => {
284
- if (this.ws) {
285
- this.ws.close();
286
- }
287
- reject(new Error(`WebSocket connection timed out after ${timeout}ms`));
288
- }, timeout);
289
-
290
- // When bundled by Bun, this will always use the browser's WebSocket
291
- this.ws = new WebSocket(wsUrl);
292
-
293
- this.ws.onopen = () => {
294
- clearTimeout(connectionTimeout);
295
- console.log(`WebSocket connected to ${wsUrl}`);
296
- this.reconnectAttempts = 0;
297
- resolve();
298
- };
299
-
300
- this.ws.onmessage = (event) => {
301
- try {
302
- const message = JSON.parse(event.data);
303
-
304
- this.handleMessage(message);
305
- } catch (error) {
306
- console.error("Failed to parse WebSocket message:", error);
307
- }
308
- };
309
-
310
- this.ws.onclose = () => {
311
- console.log(`WebSocket disconnected from ${wsUrl}`);
312
- if (!this.isShuttingDown) {
313
- this.attemptReconnect();
314
- }
315
- };
316
-
317
- this.ws.onerror = (error) => {
318
- clearTimeout(connectionTimeout);
319
- console.error(`WebSocket connection error to ${wsUrl}:`, error);
320
- reject(error);
321
- };
322
- });
323
- }
324
-
325
- handleMessage(message) {
326
- // Handle JSON-RPC responses
327
- if (message.id && this.pendingRequests.has(message.id)) {
328
- const { resolve, reject } = this.pendingRequests.get(message.id);
329
- this.pendingRequests.delete(message.id);
330
-
331
- if (message.error) {
332
- reject(new Error(message.error.message || message.error.code || 'Unknown error'));
333
- } else {
334
- resolve(message.result);
335
- }
336
- } else {
337
- // Handle subscription updates (legacy full document replacement)
338
- if (message.method === "subscription:update") {
339
- const { subscription_id, data } = message.params;
340
- const sub = this.subscriptions.get(subscription_id);
341
- if (sub && sub.callback) {
342
- // Update local data and call callback
343
- sub.localData = data;
344
- sub.callback(data);
345
- }
346
- return;
347
- }
348
-
349
- // Handle atomic subscription events (new efficient patching)
350
- if (message.method === "subscription:event") {
351
- const { subscription_id, event } = message.params;
352
- const sub = this.subscriptions.get(subscription_id);
353
- if (sub) {
354
- this.applyAtomicUpdate(sub, event);
355
- }
356
- return;
357
- }
358
-
359
- // Handle broadcasts and SID requests
360
-
361
- // Check if this is a SID request from server
362
- if (message.params && message.params.sid) {
363
- // Call all registered SID handlers
364
- this.sidRequestHandlers.forEach((handler) => {
365
- handler(message.method, message.params);
366
- });
367
- }
368
-
369
- // Call regular broadcast callbacks
370
- this.broadcastCallbacks.forEach((callback) => {
371
- callback(message.method, message.params);
372
- });
373
- }
374
- }
375
-
376
- /**
377
- * Apply an atomic update to a subscription's local data
378
- * @private
379
- * @param {Object} sub - Subscription object with localData, schema, callback
380
- * @param {Object} event - Event with table, op, pk, data, before
381
- */
382
- applyAtomicUpdate(sub, event) {
383
- const { table, op, pk, data, before } = event;
384
- const { schema, localData, callback } = sub;
385
-
386
- // Fallback: if no schema or localData, we can't apply atomic updates
387
- if (!schema || !localData) {
388
- console.warn('Cannot apply atomic update: missing schema or localData');
389
- // If we have data, just call callback with it as a fallback
390
- if (data) {
391
- callback(data);
392
- }
393
- return;
394
- }
395
-
396
- const path = schema.paths?.[table];
397
- if (!path) {
398
- console.warn(`Unknown table ${table} for subscribable, cannot apply patch`);
399
- return;
400
- }
401
-
402
- // Apply the update based on where the table lives in the document
403
- if (path === '.' || table === schema.root) {
404
- // Root entity changed
405
- this.applyRootUpdate(localData, schema.root, op, data, before);
406
- } else {
407
- // Relation changed - find and update in nested structure
408
- this.applyRelationUpdate(localData, path, op, pk, data);
409
- }
410
-
411
- // Trigger callback with updated document
412
- callback(localData);
413
- }
414
-
415
- /**
416
- * Apply update to root entity
417
- * @private
418
- */
419
- applyRootUpdate(localData, rootKey, op, data, before) {
420
- if (op === 'update' && data) {
421
- // Merge update into root entity
422
- if (localData[rootKey]) {
423
- Object.assign(localData[rootKey], data);
424
- }
425
- } else if (op === 'delete') {
426
- // Mark root as deleted (or set to null)
427
- localData[rootKey] = null;
428
- }
429
- // insert at root level would be a new document, handled by initial subscribe
430
- }
431
-
432
- /**
433
- * Apply update to a relation (nested array)
434
- * @private
435
- */
436
- applyRelationUpdate(localData, path, op, pk, data) {
437
- const arr = this.getArrayAtPath(localData, path);
438
- if (!arr || !Array.isArray(arr)) {
439
- console.warn(`Could not find array at path ${path}`);
440
- return;
441
- }
442
-
443
- if (op === 'insert' && data) {
444
- arr.push(data);
445
- } else if (op === 'update' && data && pk) {
446
- const idx = arr.findIndex(item => this.pkMatch(item, pk));
447
- if (idx !== -1) {
448
- Object.assign(arr[idx], data);
449
- } else {
450
- // Item not found, might be a new item that passes the filter - add it
451
- arr.push(data);
452
- }
453
- } else if (op === 'delete' && pk) {
454
- const idx = arr.findIndex(item => this.pkMatch(item, pk));
455
- if (idx !== -1) {
456
- arr.splice(idx, 1);
457
- }
458
- }
459
- }
460
-
461
- /**
462
- * Get array at a dot-separated path in an object
463
- * @private
464
- */
465
- getArrayAtPath(obj, path) {
466
- const parts = path.split('.');
467
- let current = obj;
468
- for (const part of parts) {
469
- if (!current || typeof current !== 'object') return null;
470
- current = current[part];
471
- }
472
- return current;
473
- }
474
-
475
- /**
476
- * Check if an item matches a primary key
477
- * @private
478
- */
479
- pkMatch(item, pk) {
480
- if (!item || !pk) return false;
481
- for (const [key, value] of Object.entries(pk)) {
482
- if (item[key] !== value) return false;
483
- }
484
- return true;
485
- }
486
-
487
- attemptReconnect() {
488
- if (this.reconnectAttempts < this.maxReconnectAttempts) {
489
- this.reconnectAttempts++;
490
- const delay = 1000 * this.reconnectAttempts;
491
- setTimeout(() => {
492
- console.log(
493
- `Attempting WebSocket reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts}) to ws://localhost:3000/ws in ${delay}ms`,
494
- );
495
- this.connect();
496
- }, delay);
497
- } else {
498
- console.error(
499
- `WebSocket failed to connect after ${this.maxReconnectAttempts} attempts. Giving up.`,
500
- );
501
- }
502
- }
503
-
504
- /**
505
- * Call a method via JSON-RPC over WebSocket
506
- *
507
- * @private
508
- * @param {string} method - Method name (e.g., 'login_user' or 'dzql.get.venues')
509
- * @param {Object} [params={}] - Method parameters
510
- * @returns {Promise<*>} Resolves with method result, rejects on error
511
- */
512
- call(method, params = {}) {
513
- return new Promise((resolve, reject) => {
514
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
515
- reject(new Error("WebSocket not connected"));
516
- return;
517
- }
518
-
519
- const id = ++this.messageId;
520
- const message = {
521
- jsonrpc: "2.0",
522
- method,
523
- params,
524
- id,
525
- };
526
-
527
- this.pendingRequests.set(id, { resolve, reject });
528
- this.ws.send(JSON.stringify(message));
529
- });
530
- }
531
-
532
- /**
533
- * Subscribe to a live query
534
- *
535
- * Subscribes to real-time updates for a document. The server returns the initial
536
- * data along with a schema that enables efficient atomic updates (patching).
537
- *
538
- * @param {string} method - Method name (subscribe_<subscribable>)
539
- * @param {object} params - Subscription parameters
540
- * @param {function} callback - Callback function for updates
541
- * @returns {Promise<{data, subscription_id, schema, unsubscribe}>} Initial data, schema, and unsubscribe function
542
- *
543
- * @example
544
- * const { data, schema, unsubscribe } = await ws.api.subscribe_venue_detail(
545
- * { venue_id: 1 },
546
- * (updated) => console.log('Updated:', updated)
547
- * );
548
- *
549
- * // Use initial data
550
- * console.log('Initial:', data);
551
- * console.log('Schema:', schema); // { root: 'venues', paths: { venues: '.', sites: 'sites', ... } }
552
- *
553
- * // Later: unsubscribe
554
- * unsubscribe();
555
- */
556
- async subscribe(method, params = {}, callback) {
557
- if (!callback || typeof callback !== 'function') {
558
- throw new Error('Subscribe requires a callback function');
559
- }
560
-
561
- // Call server to register subscription
562
- const result = await this.call(method, params);
563
- const { subscription_id, data, schema } = result;
564
-
565
- // Create unsubscribe function
566
- const unsubscribeFn = async () => {
567
- const unsubMethod = method.replace('subscribe_', 'unsubscribe_');
568
- await this.call(unsubMethod, params);
569
- this.subscriptions.delete(subscription_id);
570
- };
571
-
572
- // Store callback, schema, and local data for atomic updates
573
- this.subscriptions.set(subscription_id, {
574
- callback,
575
- unsubscribe: unsubscribeFn,
576
- schema, // Schema for path mapping (enables atomic updates)
577
- localData: data // Local copy for patching
578
- });
579
-
580
- // Return initial data, schema, and unsubscribe function
581
- return {
582
- data,
583
- subscription_id,
584
- schema,
585
- unsubscribe: unsubscribeFn
586
- };
587
- }
588
-
589
- /**
590
- * Unsubscribe from a live query
591
- *
592
- * @param {string} method - Method name (unsubscribe_<subscribable>)
593
- * @param {object} params - Subscription parameters
594
- * @returns {Promise<{success: boolean}>}
595
- *
596
- * @example
597
- * await ws.api.unsubscribe_venue_detail({ venue_id: 1 });
598
- */
599
- async unsubscribe(method, params = {}) {
600
- return await this.call(method, params);
601
- }
602
-
603
- /**
604
- * Register callback for real-time broadcast events
605
- *
606
- * Broadcasts are sent when data changes (insert/update/delete operations).
607
- * Method format: "{table}:{operation}" (e.g., "venues:update")
608
- *
609
- * @param {Function} callback - Callback function (method, params) => void
610
- * @returns {Function} Cleanup function to remove the callback
611
- *
612
- * @example
613
- * // Listen to all broadcasts
614
- * ws.onBroadcast((method, params) => {
615
- * console.log(`Event: ${method}`, params);
616
- * });
617
- *
618
- * @example
619
- * // Listen to specific table events
620
- * ws.onBroadcast((method, params) => {
621
- * if (method === 'venues:update') {
622
- * console.log('Venue updated:', params.after);
623
- * }
624
- * });
625
- *
626
- * @example
627
- * // With cleanup
628
- * const cleanup = ws.onBroadcast((method, params) => {
629
- * console.log(method, params);
630
- * });
631
- *
632
- * // Later: stop listening
633
- * cleanup();
634
- *
635
- * @example
636
- * // Event structure
637
- * {
638
- * table: 'venues',
639
- * op: 'insert', // 'insert', 'update', or 'delete'
640
- * pk: { id: 1 },
641
- * before: null, // Old values (null for insert)
642
- * after: { ... }, // New values (null for delete)
643
- * user_id: 123,
644
- * at: '2025-01-15T10:30:00Z'
645
- * }
646
- */
647
- onBroadcast(callback) {
648
- this.broadcastCallbacks.add(callback);
649
- return () => this.broadcastCallbacks.delete(callback);
650
- }
651
-
652
- offBroadcast(callback) {
653
- this.broadcastCallbacks.delete(callback);
654
- }
655
-
656
- /**
657
- * Register a handler for SID requests from server
658
- * Handler receives (method, params) where params includes { sid, ...otherData }
659
- * Handler should call respondToSID(sid, result) or respondToSID(sid, null, error)
660
- *
661
- * @param {Function} callback - Handler function
662
- * @returns {Function} - Cleanup function to remove the handler
663
- */
664
- onSIDRequest(callback) {
665
- this.sidRequestHandlers.add(callback);
666
- return () => this.sidRequestHandlers.delete(callback);
667
- }
668
-
669
- offSIDRequest(callback) {
670
- this.sidRequestHandlers.delete(callback);
671
- }
672
-
673
- /**
674
- * Respond to a SID request from server
675
- *
676
- * @param {string} sid - The session ID from the server request
677
- * @param {any} result - The result to send back (use null if error)
678
- * @param {string|Error} error - Optional error if the request failed
679
- */
680
- async respondToSID(sid, result = null, error = null) {
681
- const errorMessage = error ? (typeof error === 'string' ? error : error.message) : null;
682
-
683
- return this.call('_sid_response', {
684
- sid,
685
- result,
686
- error: errorMessage
687
- });
688
- }
689
-
690
- isConnected() {
691
- return this.ws?.readyState === WebSocket.OPEN;
692
- }
693
-
694
- disconnect() {
695
- this.isShuttingDown = true;
696
- if (this.ws) {
697
- this.ws.close();
698
- this.ws = null;
699
- }
700
- this.pendingRequests.clear();
701
- this.reconnectAttempts = 0;
702
- }
703
-
704
- /**
705
- * Clean disconnect without reconnection attempts
706
- * Perfect for test cleanup
707
- */
708
- cleanDisconnect() {
709
- this.isShuttingDown = true;
710
- this.reconnectAttempts = this.maxReconnectAttempts; // Prevent any reconnection
711
-
712
- if (this.ws) {
713
- this.ws.onclose = null; // Remove close handler to prevent reconnection
714
- this.ws.close();
715
- this.ws = null;
716
- }
717
-
718
- this.pendingRequests.clear();
719
- this.broadcastCallbacks.clear();
720
- }
721
-
722
- /**
723
- * Reset the WebSocket manager to initial state
724
- * Useful for test isolation
725
- */
726
- reset() {
727
- this.cleanDisconnect();
728
- this.messageId = 0;
729
- this.reconnectAttempts = 0;
730
- this.isShuttingDown = false;
731
- this.pendingRequests.clear();
732
- this.broadcastCallbacks.clear();
733
- }
734
-
735
- /**
736
- * Check connection status
737
- */
738
- getStatus() {
739
- if (!this.ws) return "disconnected";
740
-
741
- switch (this.ws.readyState) {
742
- case WebSocket.CONNECTING:
743
- return "connecting";
744
- case WebSocket.OPEN:
745
- return "connected";
746
- case WebSocket.CLOSING:
747
- return "closing";
748
- case WebSocket.CLOSED:
749
- return "disconnected";
750
- default:
751
- return "unknown";
752
- }
753
- }
754
- }
755
-
756
- const ws = new WebSocketManager();
757
-
758
- export const useWs = () => {
759
- return ws;
760
- };
761
-
762
- export { WebSocketManager };