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/bin/cli.js +7 -0
- package/docs/guides/atomic-updates.md +242 -0
- package/docs/guides/drop-semantics.md +554 -0
- package/docs/guides/subscriptions.md +3 -1
- package/package.json +1 -1
- package/src/client/ws.js +137 -7
- package/src/compiler/codegen/drop-semantics-codegen.js +553 -0
- package/src/compiler/codegen/subscribable-codegen.js +85 -0
- package/src/compiler/compiler.js +13 -3
- package/src/database/migrations/009_subscriptions.sql +10 -0
- package/src/database/migrations/010_atomic_updates.sql +150 -0
- package/src/server/index.js +25 -18
- package/src/server/subscriptions.js +125 -0
- package/src/server/ws.js +12 -2
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
|
}
|