dzql 0.5.5 → 0.5.7

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/src/client/ws.js CHANGED
@@ -334,16 +334,28 @@ class WebSocketManager {
334
334
  resolve(message.result);
335
335
  }
336
336
  } else {
337
- // Handle subscription updates
337
+ // Handle subscription updates (legacy full document replacement)
338
338
  if (message.method === "subscription:update") {
339
339
  const { subscription_id, data } = message.params;
340
340
  const sub = this.subscriptions.get(subscription_id);
341
341
  if (sub && sub.callback) {
342
+ // Update local data and call callback
343
+ sub.localData = data;
342
344
  sub.callback(data);
343
345
  }
344
346
  return;
345
347
  }
346
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
+
347
359
  // Handle broadcasts and SID requests
348
360
 
349
361
  // Check if this is a SID request from server
@@ -361,6 +373,117 @@ class WebSocketManager {
361
373
  }
362
374
  }
363
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
+
364
487
  attemptReconnect() {
365
488
  if (this.reconnectAttempts < this.maxReconnectAttempts) {
366
489
  this.reconnectAttempts++;
@@ -409,19 +532,23 @@ class WebSocketManager {
409
532
  /**
410
533
  * Subscribe to a live query
411
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
+ *
412
538
  * @param {string} method - Method name (subscribe_<subscribable>)
413
539
  * @param {object} params - Subscription parameters
414
540
  * @param {function} callback - Callback function for updates
415
- * @returns {Promise<{data, subscription_id, unsubscribe}>} Initial data and unsubscribe function
541
+ * @returns {Promise<{data, subscription_id, schema, unsubscribe}>} Initial data, schema, and unsubscribe function
416
542
  *
417
543
  * @example
418
- * const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
544
+ * const { data, schema, unsubscribe } = await ws.api.subscribe_venue_detail(
419
545
  * { venue_id: 1 },
420
546
  * (updated) => console.log('Updated:', updated)
421
547
  * );
422
548
  *
423
549
  * // Use initial data
424
550
  * console.log('Initial:', data);
551
+ * console.log('Schema:', schema); // { root: 'venues', paths: { venues: '.', sites: 'sites', ... } }
425
552
  *
426
553
  * // Later: unsubscribe
427
554
  * unsubscribe();
@@ -433,7 +560,7 @@ class WebSocketManager {
433
560
 
434
561
  // Call server to register subscription
435
562
  const result = await this.call(method, params);
436
- const { subscription_id, data } = result;
563
+ const { subscription_id, data, schema } = result;
437
564
 
438
565
  // Create unsubscribe function
439
566
  const unsubscribeFn = async () => {
@@ -442,16 +569,19 @@ class WebSocketManager {
442
569
  this.subscriptions.delete(subscription_id);
443
570
  };
444
571
 
445
- // Store callback for updates
572
+ // Store callback, schema, and local data for atomic updates
446
573
  this.subscriptions.set(subscription_id, {
447
574
  callback,
448
- unsubscribe: unsubscribeFn
575
+ unsubscribe: unsubscribeFn,
576
+ schema, // Schema for path mapping (enables atomic updates)
577
+ localData: data // Local copy for patching
449
578
  });
450
579
 
451
- // Return initial data and unsubscribe function
580
+ // Return initial data, schema, and unsubscribe function
452
581
  return {
453
582
  data,
454
583
  subscription_id,
584
+ schema,
455
585
  unsubscribe: unsubscribeFn
456
586
  };
457
587
  }