dzql 0.5.33 → 0.6.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.
Files changed (150) 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 +293 -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 +641 -0
  20. package/docs/project-setup.md +432 -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 +164 -0
  39. package/src/client/index.ts +1 -0
  40. package/src/client/ws.ts +286 -0
  41. package/src/create/.env.example +8 -0
  42. package/src/create/README.md +101 -0
  43. package/src/create/compose.yml +14 -0
  44. package/src/create/domain.ts +153 -0
  45. package/src/create/package.json +24 -0
  46. package/src/create/server.ts +18 -0
  47. package/src/create/setup.sh +11 -0
  48. package/src/create/tsconfig.json +15 -0
  49. package/src/runtime/auth.ts +39 -0
  50. package/src/runtime/db.ts +33 -0
  51. package/src/runtime/errors.ts +51 -0
  52. package/src/runtime/index.ts +98 -0
  53. package/src/runtime/js_functions.ts +63 -0
  54. package/src/runtime/manifest_loader.ts +29 -0
  55. package/src/runtime/namespace.ts +483 -0
  56. package/src/runtime/server.ts +87 -0
  57. package/src/runtime/ws.ts +197 -0
  58. package/src/shared/ir.ts +197 -0
  59. package/tests/client.test.ts +38 -0
  60. package/tests/codegen.test.ts +71 -0
  61. package/tests/compiler.test.ts +45 -0
  62. package/tests/graph_rules.test.ts +173 -0
  63. package/tests/integration/db.test.ts +174 -0
  64. package/tests/integration/e2e.test.ts +65 -0
  65. package/tests/integration/features.test.ts +922 -0
  66. package/tests/integration/full_stack.test.ts +262 -0
  67. package/tests/integration/setup.ts +45 -0
  68. package/tests/ir.test.ts +32 -0
  69. package/tests/namespace.test.ts +395 -0
  70. package/tests/permissions.test.ts +55 -0
  71. package/tests/pinia.test.ts +48 -0
  72. package/tests/realtime.test.ts +22 -0
  73. package/tests/runtime.test.ts +80 -0
  74. package/tests/subscribable_gen.test.ts +72 -0
  75. package/tests/subscribable_reactivity.test.ts +258 -0
  76. package/tests/venues_gen.test.ts +25 -0
  77. package/tsconfig.json +20 -0
  78. package/tsconfig.tsbuildinfo +1 -0
  79. package/README.md +0 -90
  80. package/bin/cli.js +0 -727
  81. package/docs/compiler/ADVANCED_FILTERS.md +0 -183
  82. package/docs/compiler/CODING_STANDARDS.md +0 -415
  83. package/docs/compiler/COMPARISON.md +0 -673
  84. package/docs/compiler/QUICKSTART.md +0 -326
  85. package/docs/compiler/README.md +0 -134
  86. package/docs/examples/README.md +0 -38
  87. package/docs/examples/blog.sql +0 -160
  88. package/docs/examples/venue-detail-simple.sql +0 -8
  89. package/docs/examples/venue-detail-subscribable.sql +0 -45
  90. package/docs/for-ai/claude-guide.md +0 -1210
  91. package/docs/getting-started/quickstart.md +0 -125
  92. package/docs/getting-started/subscriptions-quick-start.md +0 -203
  93. package/docs/getting-started/tutorial.md +0 -1104
  94. package/docs/guides/atomic-updates.md +0 -299
  95. package/docs/guides/client-stores.md +0 -730
  96. package/docs/guides/composite-primary-keys.md +0 -158
  97. package/docs/guides/custom-functions.md +0 -362
  98. package/docs/guides/drop-semantics.md +0 -554
  99. package/docs/guides/field-defaults.md +0 -240
  100. package/docs/guides/interpreter-vs-compiler.md +0 -237
  101. package/docs/guides/many-to-many.md +0 -929
  102. package/docs/guides/subscriptions.md +0 -537
  103. package/docs/reference/api.md +0 -1373
  104. package/docs/reference/client.md +0 -224
  105. package/src/client/stores/index.js +0 -8
  106. package/src/client/stores/useAppStore.js +0 -285
  107. package/src/client/stores/useWsStore.js +0 -289
  108. package/src/client/ws.js +0 -762
  109. package/src/compiler/cli/compile-example.js +0 -33
  110. package/src/compiler/cli/compile-subscribable.js +0 -43
  111. package/src/compiler/cli/debug-compile.js +0 -44
  112. package/src/compiler/cli/debug-parse.js +0 -26
  113. package/src/compiler/cli/debug-path-parser.js +0 -18
  114. package/src/compiler/cli/debug-subscribable-parser.js +0 -21
  115. package/src/compiler/cli/index.js +0 -174
  116. package/src/compiler/codegen/auth-codegen.js +0 -153
  117. package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
  118. package/src/compiler/codegen/graph-rules-codegen.js +0 -450
  119. package/src/compiler/codegen/notification-codegen.js +0 -232
  120. package/src/compiler/codegen/operation-codegen.js +0 -1382
  121. package/src/compiler/codegen/permission-codegen.js +0 -318
  122. package/src/compiler/codegen/subscribable-codegen.js +0 -827
  123. package/src/compiler/compiler.js +0 -371
  124. package/src/compiler/index.js +0 -11
  125. package/src/compiler/parser/entity-parser.js +0 -440
  126. package/src/compiler/parser/path-parser.js +0 -290
  127. package/src/compiler/parser/subscribable-parser.js +0 -244
  128. package/src/database/dzql-core.sql +0 -161
  129. package/src/database/migrations/001_schema.sql +0 -60
  130. package/src/database/migrations/002_functions.sql +0 -890
  131. package/src/database/migrations/003_operations.sql +0 -1135
  132. package/src/database/migrations/004_search.sql +0 -581
  133. package/src/database/migrations/005_entities.sql +0 -730
  134. package/src/database/migrations/006_auth.sql +0 -94
  135. package/src/database/migrations/007_events.sql +0 -133
  136. package/src/database/migrations/008_hello.sql +0 -18
  137. package/src/database/migrations/008a_meta.sql +0 -172
  138. package/src/database/migrations/009_subscriptions.sql +0 -240
  139. package/src/database/migrations/010_atomic_updates.sql +0 -157
  140. package/src/database/migrations/010_fix_m2m_events.sql +0 -94
  141. package/src/index.js +0 -40
  142. package/src/server/api.js +0 -9
  143. package/src/server/db.js +0 -442
  144. package/src/server/index.js +0 -317
  145. package/src/server/logger.js +0 -259
  146. package/src/server/mcp.js +0 -594
  147. package/src/server/meta-route.js +0 -251
  148. package/src/server/namespace.js +0 -292
  149. package/src/server/subscriptions.js +0 -351
  150. 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 };