@enbox/agent 0.5.8 → 0.5.10
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/dist/browser.mjs +7 -7
- package/dist/browser.mjs.map +3 -3
- package/dist/esm/sync-engine-level.js +74 -213
- package/dist/esm/sync-engine-level.js.map +1 -1
- package/dist/types/sync-engine-level.d.ts +3 -15
- package/dist/types/sync-engine-level.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/sync-engine-level.ts +82 -236
package/src/sync-engine-level.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { GenericMessage, MessageEvent, MessagesSubscribeReply, MessagesSync
|
|
|
6
6
|
import ms from 'ms';
|
|
7
7
|
|
|
8
8
|
import { Level } from 'level';
|
|
9
|
-
import { hashToHex, initDefaultHashes, Message } from '@enbox/dwn-sdk-js';
|
|
9
|
+
import { Encoder, hashToHex, initDefaultHashes, Message } from '@enbox/dwn-sdk-js';
|
|
10
10
|
|
|
11
11
|
import type { PermissionsApi } from './types/permissions.js';
|
|
12
12
|
import type { EnboxAgent, EnboxPlatformAgent } from './types/agent.js';
|
|
@@ -16,7 +16,7 @@ import { AgentPermissionsApi } from './permissions-api.js';
|
|
|
16
16
|
import { DwnInterface } from './types/dwn.js';
|
|
17
17
|
import { isRecordsWrite } from './utils.js';
|
|
18
18
|
import { topologicalSort } from './sync-topological-sort.js';
|
|
19
|
-
import { pullMessages, pushMessages } from './sync-messages.js';
|
|
19
|
+
import { fetchRemoteMessages, pullMessages, pushMessages } from './sync-messages.js';
|
|
20
20
|
|
|
21
21
|
export type SyncEngineLevelParams = {
|
|
22
22
|
agent?: EnboxPlatformAgent;
|
|
@@ -40,56 +40,6 @@ const MAX_DIFF_DEPTH = 16;
|
|
|
40
40
|
const BATCHED_DIFF_DEPTH = 8;
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* Maximum number of concurrent remote HTTP requests during a tree diff.
|
|
44
|
-
* The binary tree walk fans out in parallel — without a limit, depth N
|
|
45
|
-
* produces 2^N concurrent requests, which can exhaust server rate limits.
|
|
46
|
-
*/
|
|
47
|
-
const REMOTE_CONCURRENCY = 4;
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Counting semaphore for bounding concurrent async operations.
|
|
51
|
-
* Used by the tree walk to limit in-flight remote HTTP requests.
|
|
52
|
-
*/
|
|
53
|
-
class Semaphore {
|
|
54
|
-
private _permits: number;
|
|
55
|
-
private readonly _waiting: (() => void)[] = [];
|
|
56
|
-
|
|
57
|
-
constructor(permits: number) {
|
|
58
|
-
this._permits = permits;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Wait until a permit is available, then consume one. */
|
|
62
|
-
async acquire(): Promise<void> {
|
|
63
|
-
if (this._permits > 0) {
|
|
64
|
-
this._permits--;
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
return new Promise<void>((resolve) => {
|
|
68
|
-
this._waiting.push(resolve);
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Release a permit, waking the next waiter if any. */
|
|
73
|
-
release(): void {
|
|
74
|
-
const next = this._waiting.shift();
|
|
75
|
-
if (next) {
|
|
76
|
-
next();
|
|
77
|
-
} else {
|
|
78
|
-
this._permits++;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Acquire a permit, run the task, then release regardless of outcome. */
|
|
83
|
-
async run<T>(fn: () => Promise<T>): Promise<T> {
|
|
84
|
-
await this.acquire();
|
|
85
|
-
try {
|
|
86
|
-
return await fn();
|
|
87
|
-
} finally {
|
|
88
|
-
this.release();
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
43
|
/**
|
|
94
44
|
* Key for the subscription cursor sublevel. Cursors are keyed by
|
|
95
45
|
* `{did}^{dwnUrl}[^{protocol}]` and store an opaque EventLog cursor string.
|
|
@@ -572,28 +522,19 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
572
522
|
const filters = protocol ? [{ protocol }] : [];
|
|
573
523
|
|
|
574
524
|
// Look up permission grant for MessagesSubscribe if using a delegate.
|
|
525
|
+
// The unified scope matching in AgentPermissionsApi accepts a
|
|
526
|
+
// Messages.Read grant for MessagesSubscribe requests, so a single
|
|
527
|
+
// lookup is sufficient.
|
|
575
528
|
let permissionGrantId: string | undefined;
|
|
576
529
|
if (delegateDid) {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
permissionGrantId = grant.grant.id;
|
|
586
|
-
} catch {
|
|
587
|
-
// Fall back to trying MessagesRead which is a unified scope.
|
|
588
|
-
const grant = await this._permissionsApi.getPermissionForRequest({
|
|
589
|
-
connectedDid : did,
|
|
590
|
-
messageType : DwnInterface.MessagesRead,
|
|
591
|
-
delegateDid,
|
|
592
|
-
protocol,
|
|
593
|
-
cached : true
|
|
594
|
-
});
|
|
595
|
-
permissionGrantId = grant.grant.id;
|
|
596
|
-
}
|
|
530
|
+
const grant = await this._permissionsApi.getPermissionForRequest({
|
|
531
|
+
connectedDid : did,
|
|
532
|
+
messageType : DwnInterface.MessagesSubscribe,
|
|
533
|
+
delegateDid,
|
|
534
|
+
protocol,
|
|
535
|
+
cached : true
|
|
536
|
+
});
|
|
537
|
+
permissionGrantId = grant.grant.id;
|
|
597
538
|
}
|
|
598
539
|
|
|
599
540
|
// Define the subscription handler that processes incoming events.
|
|
@@ -608,15 +549,33 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
608
549
|
if (subMessage.type === 'event') {
|
|
609
550
|
const event: MessageEvent = subMessage.event;
|
|
610
551
|
try {
|
|
611
|
-
//
|
|
612
|
-
|
|
552
|
+
// Extract inline data from the event (available for records <= 30 KB).
|
|
553
|
+
let dataStream = this.extractDataStream(event);
|
|
554
|
+
|
|
555
|
+
// For large RecordsWrite messages (no inline data), fetch the data
|
|
556
|
+
// from the remote DWN via MessagesRead before storing locally.
|
|
557
|
+
if (!dataStream && isRecordsWrite(event) && (event.message.descriptor as any).dataCid) {
|
|
558
|
+
const messageCid = await Message.getCid(event.message);
|
|
559
|
+
const fetched = await fetchRemoteMessages({
|
|
560
|
+
did, dwnUrl, delegateDid, protocol,
|
|
561
|
+
messageCids : [messageCid],
|
|
562
|
+
agent : this.agent,
|
|
563
|
+
permissionsApi : this._permissionsApi,
|
|
564
|
+
});
|
|
565
|
+
if (fetched.length > 0 && fetched[0].dataStream) {
|
|
566
|
+
dataStream = fetched[0].dataStream;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
613
570
|
await this.agent.dwn.processRawMessage(did, event.message, { dataStream });
|
|
571
|
+
|
|
572
|
+
// Only advance the cursor after successful processing.
|
|
573
|
+
// If processing fails, the event will be re-delivered on
|
|
574
|
+
// reconnection (cursor-based resume from the last good point).
|
|
575
|
+
await this.setCursor(cursorKey, subMessage.cursor);
|
|
614
576
|
} catch (error: any) {
|
|
615
577
|
console.error(`SyncEngineLevel: Error processing live-pull event for ${did}`, error);
|
|
616
578
|
}
|
|
617
|
-
|
|
618
|
-
// Persist cursor for resume on reconnect.
|
|
619
|
-
await this.setCursor(cursorKey, subMessage.cursor);
|
|
620
579
|
}
|
|
621
580
|
};
|
|
622
581
|
|
|
@@ -703,7 +662,7 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
703
662
|
if (delegateDid) {
|
|
704
663
|
const grant = await this._permissionsApi.getPermissionForRequest({
|
|
705
664
|
connectedDid : did,
|
|
706
|
-
messageType : DwnInterface.
|
|
665
|
+
messageType : DwnInterface.MessagesSubscribe,
|
|
707
666
|
delegateDid,
|
|
708
667
|
protocol,
|
|
709
668
|
cached : true,
|
|
@@ -774,10 +733,11 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
774
733
|
const entries = [...this._pendingPushCids.entries()];
|
|
775
734
|
this._pendingPushCids.clear();
|
|
776
735
|
|
|
777
|
-
|
|
736
|
+
// Push to all endpoints in parallel — each target is independent.
|
|
737
|
+
await Promise.all(entries.map(async ([, pending]) => {
|
|
778
738
|
const { did, dwnUrl, delegateDid, protocol, cids } = pending;
|
|
779
739
|
if (cids.length === 0) {
|
|
780
|
-
|
|
740
|
+
return;
|
|
781
741
|
}
|
|
782
742
|
|
|
783
743
|
try {
|
|
@@ -789,8 +749,25 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
789
749
|
});
|
|
790
750
|
} catch (error: any) {
|
|
791
751
|
console.error(`SyncEngineLevel: Push-on-write failed for ${did} -> ${dwnUrl}`, error);
|
|
752
|
+
|
|
753
|
+
// Re-queue the failed CIDs so they are retried on the next
|
|
754
|
+
// debounce cycle (or picked up by the SMT integrity check).
|
|
755
|
+
const targetKey = this.buildCursorKey(did, dwnUrl, protocol);
|
|
756
|
+
let requeued = this._pendingPushCids.get(targetKey);
|
|
757
|
+
if (!requeued) {
|
|
758
|
+
requeued = { did, dwnUrl, delegateDid, protocol, cids: [] };
|
|
759
|
+
this._pendingPushCids.set(targetKey, requeued);
|
|
760
|
+
}
|
|
761
|
+
requeued.cids.push(...cids);
|
|
762
|
+
|
|
763
|
+
// Schedule a retry after a short delay.
|
|
764
|
+
if (!this._pushDebounceTimer) {
|
|
765
|
+
this._pushDebounceTimer = setTimeout((): void => {
|
|
766
|
+
void this.flushPendingPushes();
|
|
767
|
+
}, PUSH_DEBOUNCE_MS * 4); // Back off: 1 second instead of 250ms.
|
|
768
|
+
}
|
|
792
769
|
}
|
|
793
|
-
}
|
|
770
|
+
}));
|
|
794
771
|
}
|
|
795
772
|
|
|
796
773
|
// ---------------------------------------------------------------------------
|
|
@@ -825,24 +802,35 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
825
802
|
// ---------------------------------------------------------------------------
|
|
826
803
|
|
|
827
804
|
/**
|
|
828
|
-
* Extracts a ReadableStream from a MessageEvent if it contains a
|
|
805
|
+
* Extracts a ReadableStream from a MessageEvent if it contains a
|
|
806
|
+
* RecordsWrite with data — either as an inline `encodedData` field
|
|
807
|
+
* (for records <= 30 KB) or as a pre-existing data stream.
|
|
829
808
|
*/
|
|
830
809
|
private extractDataStream(event: MessageEvent): ReadableStream<Uint8Array> | undefined {
|
|
831
|
-
if (isRecordsWrite(event)
|
|
810
|
+
if (!isRecordsWrite(event)) {
|
|
811
|
+
return undefined;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Check for inline base64url-encoded data (small records from EventLog).
|
|
815
|
+
const encodedData = (event.message as any).encodedData as string | undefined;
|
|
816
|
+
if (encodedData) {
|
|
817
|
+
const bytes = Encoder.base64UrlToBytes(encodedData);
|
|
818
|
+
return new ReadableStream<Uint8Array>({
|
|
819
|
+
start(controller): void {
|
|
820
|
+
controller.enqueue(bytes);
|
|
821
|
+
controller.close();
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Check for a pre-existing data stream (e.g. from a direct message read).
|
|
827
|
+
if ((event as any).data) {
|
|
832
828
|
return (event as any).data;
|
|
833
829
|
}
|
|
830
|
+
|
|
834
831
|
return undefined;
|
|
835
832
|
}
|
|
836
833
|
|
|
837
|
-
/**
|
|
838
|
-
* Synchronously attempts to get a message CID. Returns undefined on failure.
|
|
839
|
-
* This is used in the synchronous EventLog callback; the actual CID computation
|
|
840
|
-
* is fast for already-constructed messages.
|
|
841
|
-
*/
|
|
842
|
-
// tryGetCidSync was removed — it tried to return a CID synchronously
|
|
843
|
-
// from an async SHA-256 computation, which always returned undefined.
|
|
844
|
-
// The local push handler now awaits Message.getCid() directly.
|
|
845
|
-
|
|
846
834
|
// ---------------------------------------------------------------------------
|
|
847
835
|
// Default Hash Cache
|
|
848
836
|
// ---------------------------------------------------------------------------
|
|
@@ -958,97 +946,6 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
958
946
|
}
|
|
959
947
|
|
|
960
948
|
// ---------------------------------------------------------------------------
|
|
961
|
-
// Tree Diff — walk the SMT to find divergent leaf sets
|
|
962
|
-
// ---------------------------------------------------------------------------
|
|
963
|
-
|
|
964
|
-
/**
|
|
965
|
-
* Walks the local and remote SMTs in parallel, recursing into subtrees whose
|
|
966
|
-
* hashes differ, until reaching `MAX_DIFF_DEPTH` where leaves are enumerated.
|
|
967
|
-
*
|
|
968
|
-
* Returns the sets of messageCids that exist only locally or only remotely.
|
|
969
|
-
*/
|
|
970
|
-
private async walkTreeDiff({ did, dwnUrl, delegateDid, protocol }: {
|
|
971
|
-
did: string;
|
|
972
|
-
dwnUrl: string;
|
|
973
|
-
delegateDid?: string;
|
|
974
|
-
protocol?: string;
|
|
975
|
-
}): Promise<{ onlyLocal: string[]; onlyRemote: string[] }> {
|
|
976
|
-
const onlyLocal: string[] = [];
|
|
977
|
-
const onlyRemote: string[] = [];
|
|
978
|
-
|
|
979
|
-
// Hoist permission grant lookup — resolved once and reused for all subtree/leaf requests.
|
|
980
|
-
const permissionGrantId = await this.getSyncPermissionGrantId(did, delegateDid, protocol);
|
|
981
|
-
|
|
982
|
-
// Gate remote HTTP requests through a semaphore so the binary tree walk
|
|
983
|
-
// doesn't produce an exponential burst of concurrent requests. Local
|
|
984
|
-
// DWN requests (in-process) are not gated.
|
|
985
|
-
const remoteSemaphore = new Semaphore(REMOTE_CONCURRENCY);
|
|
986
|
-
|
|
987
|
-
const walk = async (prefix: string): Promise<void> => {
|
|
988
|
-
// Get subtree hashes for this prefix from local and remote.
|
|
989
|
-
// Only the remote request is gated by the semaphore.
|
|
990
|
-
const [localHash, remoteHash] = await Promise.all([
|
|
991
|
-
this.getLocalSubtreeHash(did, prefix, delegateDid, protocol, permissionGrantId),
|
|
992
|
-
remoteSemaphore.run(() => this.getRemoteSubtreeHash(did, dwnUrl, prefix, delegateDid, protocol, permissionGrantId)),
|
|
993
|
-
]);
|
|
994
|
-
|
|
995
|
-
// If hashes match, this subtree is identical — skip.
|
|
996
|
-
if (localHash === remoteHash) {
|
|
997
|
-
return;
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
// Short-circuit: if one side is the default (empty-subtree) hash, all entries
|
|
1001
|
-
// on the other side are unique. Enumerate leaves directly instead of recursing
|
|
1002
|
-
// further into the tree — this avoids an exponential walk when one DWN has
|
|
1003
|
-
// entries that the other lacks entirely in this subtree.
|
|
1004
|
-
const emptyHash = await this.getDefaultHashHex(prefix.length);
|
|
1005
|
-
if (remoteHash === emptyHash && localHash !== emptyHash) {
|
|
1006
|
-
const localLeaves = await this.getLocalLeaves(did, prefix, delegateDid, protocol, permissionGrantId);
|
|
1007
|
-
onlyLocal.push(...localLeaves);
|
|
1008
|
-
return;
|
|
1009
|
-
}
|
|
1010
|
-
if (localHash === emptyHash && remoteHash !== emptyHash) {
|
|
1011
|
-
const remoteLeaves = await remoteSemaphore.run(
|
|
1012
|
-
() => this.getRemoteLeaves(did, dwnUrl, prefix, delegateDid, protocol, permissionGrantId),
|
|
1013
|
-
);
|
|
1014
|
-
onlyRemote.push(...remoteLeaves);
|
|
1015
|
-
return;
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
// If we've reached the maximum diff depth, enumerate leaves.
|
|
1019
|
-
if (prefix.length >= MAX_DIFF_DEPTH) {
|
|
1020
|
-
const [localLeaves, remoteLeaves] = await Promise.all([
|
|
1021
|
-
this.getLocalLeaves(did, prefix, delegateDid, protocol, permissionGrantId),
|
|
1022
|
-
remoteSemaphore.run(() => this.getRemoteLeaves(did, dwnUrl, prefix, delegateDid, protocol, permissionGrantId)),
|
|
1023
|
-
]);
|
|
1024
|
-
|
|
1025
|
-
const localSet = new Set(localLeaves);
|
|
1026
|
-
const remoteSet = new Set(remoteLeaves);
|
|
1027
|
-
|
|
1028
|
-
for (const cid of localLeaves) {
|
|
1029
|
-
if (!remoteSet.has(cid)) {
|
|
1030
|
-
onlyLocal.push(cid);
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
for (const cid of remoteLeaves) {
|
|
1034
|
-
if (!localSet.has(cid)) {
|
|
1035
|
-
onlyRemote.push(cid);
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
return;
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
// Recurse into left (0) and right (1) children in parallel.
|
|
1042
|
-
await Promise.all([
|
|
1043
|
-
walk(prefix + '0'),
|
|
1044
|
-
walk(prefix + '1'),
|
|
1045
|
-
]);
|
|
1046
|
-
};
|
|
1047
|
-
|
|
1048
|
-
await walk('');
|
|
1049
|
-
return { onlyLocal, onlyRemote };
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
949
|
// ---------------------------------------------------------------------------
|
|
1053
950
|
// Batched Diff — single round-trip set reconciliation
|
|
1054
951
|
// ---------------------------------------------------------------------------
|
|
@@ -1104,7 +1001,8 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1104
1001
|
}
|
|
1105
1002
|
|
|
1106
1003
|
// Step 3: Enumerate local leaves for prefixes the remote reported as onlyLocal.
|
|
1107
|
-
|
|
1004
|
+
// Reuse the same grant ID from step 2 (avoids redundant lookup).
|
|
1005
|
+
const permissionGrantIdForLeaves = permissionGrantId;
|
|
1108
1006
|
const onlyLocalCids: string[] = [];
|
|
1109
1007
|
for (const prefix of reply.onlyLocal ?? []) {
|
|
1110
1008
|
const leaves = await this.getLocalLeaves(did, prefix, delegateDid, protocol, permissionGrantIdForLeaves);
|
|
@@ -1205,32 +1103,6 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1205
1103
|
return reply.hash ?? '';
|
|
1206
1104
|
}
|
|
1207
1105
|
|
|
1208
|
-
private async getRemoteSubtreeHash(
|
|
1209
|
-
did: string, dwnUrl: string, prefix: string, delegateDid?: string, protocol?: string, permissionGrantId?: string
|
|
1210
|
-
): Promise<string> {
|
|
1211
|
-
const syncMessage = await this.agent.dwn.processRequest({
|
|
1212
|
-
store : false,
|
|
1213
|
-
author : did,
|
|
1214
|
-
target : did,
|
|
1215
|
-
messageType : DwnInterface.MessagesSync,
|
|
1216
|
-
granteeDid : delegateDid,
|
|
1217
|
-
messageParams : {
|
|
1218
|
-
action: 'subtree',
|
|
1219
|
-
prefix,
|
|
1220
|
-
protocol,
|
|
1221
|
-
permissionGrantId
|
|
1222
|
-
}
|
|
1223
|
-
});
|
|
1224
|
-
|
|
1225
|
-
const reply = await this.agent.rpc.sendDwnRequest({
|
|
1226
|
-
dwnUrl,
|
|
1227
|
-
targetDid : did,
|
|
1228
|
-
message : syncMessage.message,
|
|
1229
|
-
}) as MessagesSyncReply;
|
|
1230
|
-
|
|
1231
|
-
return reply.hash ?? '';
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
1106
|
/**
|
|
1235
1107
|
* Get all leaf messageCids under a given prefix from the local DWN.
|
|
1236
1108
|
*
|
|
@@ -1265,32 +1137,6 @@ export class SyncEngineLevel implements SyncEngine {
|
|
|
1265
1137
|
return reply.entries ?? [];
|
|
1266
1138
|
}
|
|
1267
1139
|
|
|
1268
|
-
private async getRemoteLeaves(
|
|
1269
|
-
did: string, dwnUrl: string, prefix: string, delegateDid?: string, protocol?: string, permissionGrantId?: string
|
|
1270
|
-
): Promise<string[]> {
|
|
1271
|
-
const syncMessage = await this.agent.dwn.processRequest({
|
|
1272
|
-
store : false,
|
|
1273
|
-
author : did,
|
|
1274
|
-
target : did,
|
|
1275
|
-
messageType : DwnInterface.MessagesSync,
|
|
1276
|
-
granteeDid : delegateDid,
|
|
1277
|
-
messageParams : {
|
|
1278
|
-
action: 'leaves',
|
|
1279
|
-
prefix,
|
|
1280
|
-
protocol,
|
|
1281
|
-
permissionGrantId
|
|
1282
|
-
}
|
|
1283
|
-
});
|
|
1284
|
-
|
|
1285
|
-
const reply = await this.agent.rpc.sendDwnRequest({
|
|
1286
|
-
dwnUrl,
|
|
1287
|
-
targetDid : did,
|
|
1288
|
-
message : syncMessage.message,
|
|
1289
|
-
}) as MessagesSyncReply;
|
|
1290
|
-
|
|
1291
|
-
return reply.entries ?? [];
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
1140
|
// ---------------------------------------------------------------------------
|
|
1295
1141
|
// Pull / Push — delegates to standalone functions in sync-messages.ts
|
|
1296
1142
|
// ---------------------------------------------------------------------------
|