@kubun/plugin-p2p 0.10.1 → 0.12.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 (201) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1646 -1
  3. package/lib/context/hub.js +151 -1
  4. package/lib/context/join.js +115 -1
  5. package/lib/context/peer.d.ts +92 -0
  6. package/lib/context/peer.js +1216 -0
  7. package/lib/context/require-admin.d.ts +57 -0
  8. package/lib/context/require-admin.js +57 -0
  9. package/lib/context/sync.d.ts +12 -0
  10. package/lib/context/sync.js +252 -1
  11. package/lib/context/types.d.ts +58 -22
  12. package/lib/context/types.js +48 -1
  13. package/lib/groups/access-default-token.d.ts +44 -0
  14. package/lib/groups/access-default-token.js +103 -0
  15. package/lib/groups/admin-roster.d.ts +55 -0
  16. package/lib/groups/admin-roster.js +69 -0
  17. package/lib/groups/anchor-store.d.ts +15 -0
  18. package/lib/groups/anchor-store.js +37 -0
  19. package/lib/groups/app-cursor-store.d.ts +17 -0
  20. package/lib/groups/app-cursor-store.js +34 -0
  21. package/lib/groups/broadcast-codec.js +6 -1
  22. package/lib/groups/broadcast.d.ts +238 -106
  23. package/lib/groups/broadcast.js +702 -1
  24. package/lib/groups/catalog-token.d.ts +50 -0
  25. package/lib/groups/catalog-token.js +96 -0
  26. package/lib/groups/circle-projection.d.ts +90 -0
  27. package/lib/groups/circle-projection.js +202 -0
  28. package/lib/groups/circle-reducers.d.ts +139 -0
  29. package/lib/groups/circle-reducers.js +128 -0
  30. package/lib/groups/commit-adoption.d.ts +77 -0
  31. package/lib/groups/commit-adoption.js +108 -0
  32. package/lib/groups/commit-journal.d.ts +14 -0
  33. package/lib/groups/commit-journal.js +65 -0
  34. package/lib/groups/control-request.d.ts +66 -0
  35. package/lib/groups/control-request.js +101 -0
  36. package/lib/groups/events.d.ts +19 -2
  37. package/lib/groups/events.js +10 -1
  38. package/lib/groups/group-anchor.d.ts +24 -0
  39. package/lib/groups/group-anchor.js +22 -0
  40. package/lib/groups/group-crypto.d.ts +42 -0
  41. package/lib/groups/group-crypto.js +159 -0
  42. package/lib/groups/group-handle-registry.d.ts +138 -9
  43. package/lib/groups/group-handle-registry.js +533 -1
  44. package/lib/groups/group-handlers.d.ts +30 -0
  45. package/lib/groups/group-handlers.js +218 -0
  46. package/lib/groups/group-health-monitor.d.ts +11 -0
  47. package/lib/groups/group-health-monitor.js +139 -1
  48. package/lib/groups/group-mls.d.ts +66 -0
  49. package/lib/groups/group-mls.js +696 -0
  50. package/lib/groups/group-peer-manager.d.ts +301 -0
  51. package/lib/groups/group-peer-manager.js +1336 -0
  52. package/lib/groups/group-protocols.d.ts +754 -0
  53. package/lib/groups/group-protocols.js +448 -0
  54. package/lib/groups/invite-payload.d.ts +49 -1
  55. package/lib/groups/invite-payload.js +26 -1
  56. package/lib/groups/join-request-identity.d.ts +21 -0
  57. package/lib/groups/join-request-identity.js +43 -0
  58. package/lib/groups/join-utils.d.ts +61 -0
  59. package/lib/groups/join-utils.js +366 -1
  60. package/lib/groups/ledger-affected-events.d.ts +20 -0
  61. package/lib/groups/ledger-affected-events.js +85 -0
  62. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  63. package/lib/groups/ledger-commit-fold.js +167 -0
  64. package/lib/groups/ledger-ingest.d.ts +124 -0
  65. package/lib/groups/ledger-ingest.js +212 -0
  66. package/lib/groups/ledger.d.ts +44 -0
  67. package/lib/groups/ledger.js +30 -0
  68. package/lib/groups/manager.d.ts +217 -85
  69. package/lib/groups/manager.js +798 -1
  70. package/lib/groups/mls-codec.d.ts +28 -7
  71. package/lib/groups/mls-codec.js +33 -1
  72. package/lib/groups/mls-encryptor.d.ts +2 -2
  73. package/lib/groups/mls-encryptor.js +33 -1
  74. package/lib/groups/mls-group-handle.d.ts +37 -2
  75. package/lib/groups/mls-group-handle.js +18 -1
  76. package/lib/groups/mls-json.js +17 -1
  77. package/lib/groups/mls-receive-errors.d.ts +27 -0
  78. package/lib/groups/mls-receive-errors.js +39 -0
  79. package/lib/groups/mls-state.d.ts +6 -4
  80. package/lib/groups/mls-state.js +79 -1
  81. package/lib/groups/peer-presence.d.ts +90 -0
  82. package/lib/groups/peer-presence.js +295 -0
  83. package/lib/groups/peer-selection.d.ts +37 -0
  84. package/lib/groups/peer-selection.js +72 -0
  85. package/lib/groups/roster-projection.d.ts +53 -0
  86. package/lib/groups/roster-projection.js +65 -0
  87. package/lib/groups/store-received-grant.d.ts +7 -3
  88. package/lib/groups/store-received-grant.js +161 -1
  89. package/lib/groups/store-received-revocation.d.ts +19 -16
  90. package/lib/groups/store-received-revocation.js +83 -1
  91. package/lib/hub/ack-backoff.d.ts +22 -0
  92. package/lib/hub/ack-backoff.js +88 -0
  93. package/lib/hub/http-client.d.ts +18 -10
  94. package/lib/hub/http-client.js +15 -1
  95. package/lib/hub/hub-like.d.ts +75 -0
  96. package/lib/hub/hub-like.js +1022 -0
  97. package/lib/hub/loopback-log-hub.d.ts +20 -0
  98. package/lib/hub/loopback-log-hub.js +178 -0
  99. package/lib/hub/manager.d.ts +25 -40
  100. package/lib/hub/manager.js +155 -1
  101. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  102. package/lib/hub/peer-scoped-hub-view.js +92 -0
  103. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  104. package/lib/hub/sender-scoped-hub-view.js +59 -0
  105. package/lib/hub/server-did.d.ts +67 -0
  106. package/lib/hub/server-did.js +131 -0
  107. package/lib/hub/wiring.d.ts +77 -51
  108. package/lib/hub/wiring.js +189 -1
  109. package/lib/index.d.ts +58 -22
  110. package/lib/index.js +563 -1
  111. package/lib/peer/connection-registry.d.ts +45 -0
  112. package/lib/peer/connection-registry.js +52 -0
  113. package/lib/peer/handlers.d.ts +69 -0
  114. package/lib/peer/handlers.js +110 -0
  115. package/lib/protocol.d.ts +214 -1
  116. package/lib/protocol.js +519 -1
  117. package/lib/schema.d.ts +3 -1
  118. package/lib/schema.js +1150 -39
  119. package/lib/sync/access-default-sender.d.ts +23 -0
  120. package/lib/sync/access-default-sender.js +80 -0
  121. package/lib/sync/authorize.d.ts +58 -4
  122. package/lib/sync/authorize.js +114 -1
  123. package/lib/sync/broadcast-queue.d.ts +3 -4
  124. package/lib/sync/broadcast-queue.js +182 -1
  125. package/lib/sync/broadcast-sender.js +179 -1
  126. package/lib/sync/catalog-scope.d.ts +8 -7
  127. package/lib/sync/catalog-scope.js +84 -1
  128. package/lib/sync/errors.d.ts +12 -2
  129. package/lib/sync/errors.js +29 -1
  130. package/lib/sync/forwarder.d.ts +13 -19
  131. package/lib/sync/forwarder.js +271 -1
  132. package/lib/sync/handlers.js +200 -1
  133. package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
  134. package/lib/sync/hub-tunnel-sync-listener.js +261 -1
  135. package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
  136. package/lib/sync/hub-tunnel-sync-provider.js +113 -1
  137. package/lib/sync/merkle-apply.d.ts +5 -0
  138. package/lib/sync/merkle-apply.js +194 -1
  139. package/lib/sync/merkle-channel.d.ts +11 -3
  140. package/lib/sync/merkle-channel.js +0 -0
  141. package/lib/sync/merkle-tree.js +120 -1
  142. package/lib/sync/peer-registry.d.ts +15 -11
  143. package/lib/sync/peer-registry.js +82 -1
  144. package/lib/sync/receive-access-gate.js +24 -1
  145. package/lib/sync/scope-resolver.js +41 -1
  146. package/lib/sync/sync-client.d.ts +14 -4
  147. package/lib/sync/sync-client.js +254 -1
  148. package/lib/sync/sync-manager.d.ts +24 -6
  149. package/lib/sync/sync-manager.js +301 -1
  150. package/lib/sync/tunnel-listeners.d.ts +52 -0
  151. package/lib/sync/tunnel-listeners.js +175 -0
  152. package/lib/sync/tunnel-observability.d.ts +25 -0
  153. package/lib/sync/tunnel-observability.js +63 -0
  154. package/lib/sync/tunnel-topics.d.ts +20 -0
  155. package/lib/sync/tunnel-topics.js +28 -0
  156. package/lib/types.d.ts +704 -22
  157. package/lib/types.js +1 -1
  158. package/lib/util/handler-error.d.ts +16 -0
  159. package/lib/util/handler-error.js +64 -0
  160. package/lib/util/mutex.d.ts +14 -1
  161. package/lib/util/mutex.js +44 -1
  162. package/lib/utils.js +21 -1
  163. package/package.json +58 -47
  164. package/lib/groups/broadcast-service.d.ts +0 -263
  165. package/lib/groups/broadcast-service.js +0 -1
  166. package/lib/groups/rejoin-codec.d.ts +0 -14
  167. package/lib/groups/rejoin-codec.js +0 -1
  168. package/lib/groups/wire-frame.d.ts +0 -34
  169. package/lib/groups/wire-frame.js +0 -1
  170. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  171. package/lib/hub/circle-catchup-requester.js +0 -1
  172. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  173. package/lib/hub/circle-catchup-responder.js +0 -1
  174. package/lib/hub/connection-pool.d.ts +0 -43
  175. package/lib/hub/connection-pool.js +0 -1
  176. package/lib/hub/did-observing-transport.d.ts +0 -64
  177. package/lib/hub/did-observing-transport.js +0 -1
  178. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  179. package/lib/hub/epoch-stale-detector.js +0 -1
  180. package/lib/hub/errors.d.ts +0 -30
  181. package/lib/hub/errors.js +0 -1
  182. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  183. package/lib/hub/forward-remote-broadcast.js +0 -1
  184. package/lib/hub/group-channel.d.ts +0 -89
  185. package/lib/hub/group-channel.js +0 -1
  186. package/lib/hub/hub-connection.d.ts +0 -96
  187. package/lib/hub/hub-connection.js +0 -1
  188. package/lib/hub/receive-handler.d.ts +0 -51
  189. package/lib/hub/receive-handler.js +0 -1
  190. package/lib/hub/rejoin-manager.d.ts +0 -78
  191. package/lib/hub/rejoin-manager.js +0 -1
  192. package/lib/hub/rejoin-responder.d.ts +0 -32
  193. package/lib/hub/rejoin-responder.js +0 -1
  194. package/lib/hub/relay-manager.d.ts +0 -142
  195. package/lib/hub/relay-manager.js +0 -1
  196. package/lib/hub/send-handler.d.ts +0 -40
  197. package/lib/hub/send-handler.js +0 -1
  198. package/lib/hub/tunnel-inbox.d.ts +0 -20
  199. package/lib/hub/tunnel-inbox.js +0 -1
  200. package/lib/hub/wait-for-gate.d.ts +0 -14
  201. package/lib/hub/wait-for-gate.js +0 -1
@@ -1 +1,301 @@
1
- import{EventEmitter as e}from"@enkaku/event";import{isSigningIdentity as t}from"@enkaku/token";import{getGraphStore as s}from"@kubun/store-graph";import{applySyncMutations as r}from"./merkle-apply.js";import{buildMerkleTree as i}from"./merkle-tree.js";import{PeerRegistry as n}from"./peer-registry.js";import{SyncClient as o}from"./sync-client.js";export class SyncManager{#e;#t;#s;#r;#i;#n=new e;#o=new Map;#a=new Map;#l;#c;#g;#p;constructor(e){this.#e=e.deployClusters,this.#t=e.graph,this.#s=e.runtime,this.#l=e.identity,this.#r=e.logger,this.#i=new n({runtime:e.runtime}),this.#c=e.serverResolver,this.#g=e.storeUnreadable??"persist",this.#p=e.defaultAccessLevel}get peerRegistry(){return this.#i}setIdentity(e){this.#l=e}setServerResolver(e){this.#c=e}async addPeer(e){await this.#i.addPeer(e),this.#r.info("Peer added",{peerDID:e.config.peerDID})}async removePeer(e){await this.#i.removePeer(e),this.#r.info("Peer removed",{peerDID:e.peerDID})}async updatePeerConfig(e){await this.#i.updatePeer(e),this.#r.info("Peer updated",{peerDID:e.peerDID})}async listPeers(e){return this.#i.listPeers(e)}async getPeer(e){return this.#i.getPeer(e)}async merkleSyncWithPeer(e){let n,{peerDID:a,scopes:l,delegationTokens:c=[],knownModelIDs:g,direction:p}=e;this.#r.info("Starting Merkle sync with peer",{peerDID:a,scopes:l});let m=await this.#i.getPeer({peerDID:a,stores:e.stores});if(!m)throw Error(`Peer ${a} not found`);let d=`merkle-sync-${a}-${Date.now()}`,h={peerID:a,startTime:Date.now(),documentsAttempted:0,documentsCompleted:0};this.#o.set(d,h),this.#m({type:"started",peerID:a,timestamp:Date.now()});try{let u=this.#l;if(!t(u))throw Error("Signing identity required for Merkle sync");let y=new o({runtime:this.#s,identity:u,logger:this.#r,serverResolver:this.#c});n=await y.connect(m.endpoint,a);let v=p??"pull";this.#r.info("Negotiating sync scopes",{scopes:l,direction:v});let{acceptedScopes:f,excludedDocumentIDs:D,missingClusters:w,direction:k}=await y.negotiate(n,l,c,g,v);if(0===f.length)return this.#r.info("No scopes accepted by peer"),this.#a.set(a,Date.now()),this.#m({type:"completed",peerID:a,timestamp:Date.now()}),this.#o.delete(d),{sessionID:d,divergentBuckets:0,messagesReceived:0,messagesSent:0,missingClusters:w};null!=w&&Object.keys(w).length>0&&(null!=this.#e?(this.#r.info("Deploying missing clusters from peer",{clusterCount:Object.keys(w).length}),await this.#e(w)):this.#r.warn("Missing clusters received but no deployClusters callback configured",{clusterCount:Object.keys(w).length})),this.#r.info("Building local Merkle tree",{acceptedScopes:f});let S=await s(e.stores),P=await S.getDocumentIDsForScope(f,D),R=await S.getMutationLogForDocuments(P),I=i(R),b=k??v;this.#r.info("Requesting Merkle sync from peer",{localTreeBuckets:Object.keys(I.buckets).length,direction:b});let C=await y.merkleSync(n,{scopes:f,excludedDocumentIDs:D,localTree:I,direction:b,localEntries:R,delegationTokens:c});return C.mutationJWTs.length>0&&(this.#r.info("Applying sync mutations",{mutations:C.mutationJWTs.length}),h.documentsCompleted=(await r({db:e.stores,graph:this.#t,mutationJWTs:C.mutationJWTs,selfDID:this.#l.id,storeUnreadable:this.#g,defaultAccessLevel:this.#p})).applied),this.#a.set(a,Date.now()),this.#m({type:"completed",peerID:a,timestamp:Date.now()}),this.#r.info("Merkle sync completed",{divergentBuckets:C.divergentBuckets,mutationsReceived:C.mutationJWTs.length}),{sessionID:d,divergentBuckets:C.divergentBuckets,messagesReceived:C.mutationJWTs.length,messagesSent:C.mutationsSent,missingClusters:w}}catch(e){throw this.#r.error("Merkle sync failed",{peerDID:a,error:e}),this.#m({type:"error",peerID:a,timestamp:Date.now(),error:e instanceof Error?e.message:String(e)}),e}finally{if(null!=n)try{n.abort("SyncComplete"),await n.dispose()}catch{}this.#o.delete(d)}}getStatus(e){let{peerDID:t}=e,s=Array.from(this.#o.values()).filter(e=>!t||e.peerID===t).map(e=>({peerID:e.peerID,startTime:e.startTime,documentsAttempted:e.documentsAttempted,documentsCompleted:e.documentsCompleted})),r={};for(let[e,s]of this.#a.entries())t&&e!==t||(r[e]=s);return{activeSessions:s,lastSyncByPeer:r}}onSyncEvent(e){return this.#n.on("sync",e)}#m(e){this.#n.emit("sync",e).catch(e=>{this.#r.error("Error in sync event listener",{error:e})})}async dispose(){this.#o.clear(),this.#a.clear()}}
1
+ import { isSigningIdentity } from '@kokuin/token';
2
+ import { getGraphStore } from '@kubun/store-graph';
3
+ import { EventEmitter } from '@sozai/event';
4
+ import { fromEmitter } from '@sozai/generator';
5
+ import { applySyncMutations } from './merkle-apply.js';
6
+ import { buildMerkleTree } from './merkle-tree.js';
7
+ import { PeerRegistry } from './peer-registry.js';
8
+ import { SyncClient } from './sync-client.js';
9
+ export class SyncManager {
10
+ #deployClusters;
11
+ #graph;
12
+ #runtime;
13
+ #logger;
14
+ #peerRegistry;
15
+ #events = new EventEmitter();
16
+ #activeSessions = new Map();
17
+ #lastSyncByPeer = new Map();
18
+ #identity;
19
+ #serverResolver;
20
+ #storeUnreadable;
21
+ #defaultAccessLevel;
22
+ constructor(params){
23
+ this.#deployClusters = params.deployClusters;
24
+ this.#graph = params.graph;
25
+ this.#runtime = params.runtime;
26
+ this.#identity = params.identity;
27
+ this.#logger = params.logger;
28
+ this.#peerRegistry = new PeerRegistry({
29
+ runtime: params.runtime
30
+ });
31
+ this.#serverResolver = params.serverResolver;
32
+ this.#storeUnreadable = params.storeUnreadable ?? 'persist';
33
+ this.#defaultAccessLevel = params.defaultAccessLevel;
34
+ }
35
+ get peerRegistry() {
36
+ return this.#peerRegistry;
37
+ }
38
+ /**
39
+ * Configure the signing identity used for sync operations.
40
+ */ setIdentity(identity) {
41
+ this.#identity = identity;
42
+ }
43
+ /**
44
+ * Configure the resolver for the transports built locally rather than dialled:
45
+ * `direct://` (in-process) and `tunnel://` (relayed by the group's hub, whose
46
+ * group id arrives as the resolver's `route`).
47
+ */ setServerResolver(resolver) {
48
+ this.#serverResolver = resolver;
49
+ }
50
+ // ---- Peer management (delegates to PeerRegistry) ----
51
+ async addPeer(params) {
52
+ await this.#peerRegistry.addPeer(params);
53
+ this.#logger.info('Peer added', {
54
+ peerDID: params.config.peerDID
55
+ });
56
+ }
57
+ async removePeer(params) {
58
+ await this.#peerRegistry.removePeer(params);
59
+ this.#logger.info('Peer removed', {
60
+ peerDID: params.peerDID
61
+ });
62
+ }
63
+ async updatePeerConfig(params) {
64
+ await this.#peerRegistry.updatePeer(params);
65
+ this.#logger.info('Peer updated', {
66
+ peerDID: params.peerDID
67
+ });
68
+ }
69
+ async listPeers(params) {
70
+ return this.#peerRegistry.listPeers(params);
71
+ }
72
+ async getPeer(params) {
73
+ return this.#peerRegistry.getPeer(params);
74
+ }
75
+ // ---- Sync orchestration ----
76
+ async merkleSyncWithPeer(params) {
77
+ const { peerDID, scopes, delegationTokens = [], knownModelIDs, direction: requestedDirection } = params;
78
+ this.#logger.info('Starting Merkle sync with peer', {
79
+ peerDID,
80
+ scopes
81
+ });
82
+ let endpoint = params.endpoint;
83
+ if (endpoint == null) {
84
+ const peer = await this.#peerRegistry.getPeer({
85
+ peerDID,
86
+ stores: params.stores
87
+ });
88
+ if (!peer) {
89
+ throw new Error(`Peer ${peerDID} not found`);
90
+ }
91
+ endpoint = peer.endpoint;
92
+ }
93
+ // Random, not a timestamp: two syncs to the same peer in the same
94
+ // millisecond collide, and the second overwrites the first's entry in
95
+ // `#activeSessions` — so one of them never clears.
96
+ const sessionID = `merkle-sync-${peerDID}-${this.#runtime.getRandomID()}`;
97
+ const sessionData = {
98
+ peerID: peerDID,
99
+ startTime: Date.now(),
100
+ documentsAttempted: 0,
101
+ documentsCompleted: 0
102
+ };
103
+ this.#activeSessions.set(sessionID, sessionData);
104
+ this.#emitEvent({
105
+ type: 'started',
106
+ peerID: peerDID,
107
+ timestamp: Date.now()
108
+ });
109
+ let client;
110
+ try {
111
+ const identity = this.#identity;
112
+ if (!isSigningIdentity(identity)) {
113
+ throw new Error('Signing identity required for Merkle sync');
114
+ }
115
+ const syncClient = new SyncClient({
116
+ runtime: this.#runtime,
117
+ identity,
118
+ logger: this.#logger,
119
+ serverResolver: this.#serverResolver
120
+ });
121
+ client = await syncClient.connect(endpoint, peerDID);
122
+ // Step 1: Negotiate scopes
123
+ const direction = requestedDirection ?? 'pull';
124
+ this.#logger.info('Negotiating sync scopes', {
125
+ scopes,
126
+ direction
127
+ });
128
+ const { acceptedScopes, excludedDocumentIDs, missingClusters, direction: agreedDirection } = await syncClient.negotiate(client, scopes, delegationTokens, knownModelIDs, direction);
129
+ if (acceptedScopes.length === 0) {
130
+ this.#logger.info('No scopes accepted by peer');
131
+ this.#lastSyncByPeer.set(peerDID, Date.now());
132
+ this.#emitEvent({
133
+ type: 'completed',
134
+ peerID: peerDID,
135
+ timestamp: Date.now()
136
+ });
137
+ this.#activeSessions.delete(sessionID);
138
+ return {
139
+ sessionID,
140
+ divergentBuckets: 0,
141
+ messagesReceived: 0,
142
+ messagesSent: 0,
143
+ missingClusters
144
+ };
145
+ }
146
+ // Step 1b: Deploy missing clusters before building merkle tree
147
+ if (missingClusters != null && Object.keys(missingClusters).length > 0) {
148
+ if (this.#deployClusters != null) {
149
+ this.#logger.info('Deploying missing clusters from peer', {
150
+ clusterCount: Object.keys(missingClusters).length
151
+ });
152
+ await this.#deployClusters(missingClusters);
153
+ } else {
154
+ this.#logger.warn('Missing clusters received but no deployClusters callback configured', {
155
+ clusterCount: Object.keys(missingClusters).length
156
+ });
157
+ }
158
+ }
159
+ // Step 2: Build local Merkle tree for accepted scopes
160
+ this.#logger.info('Building local Merkle tree', {
161
+ acceptedScopes
162
+ });
163
+ const graphStore = await getGraphStore(params.stores);
164
+ const documentIDs = await graphStore.getDocumentIDsForScope(acceptedScopes, excludedDocumentIDs);
165
+ const localEntries = await graphStore.getMutationLogForDocuments(documentIDs);
166
+ const localTree = buildMerkleTree(localEntries);
167
+ // Step 3: Sync with peer via bidirectional channel
168
+ const syncDirection = agreedDirection ?? direction;
169
+ this.#logger.info('Requesting Merkle sync from peer', {
170
+ localTreeBuckets: Object.keys(localTree.buckets).length,
171
+ direction: syncDirection
172
+ });
173
+ const syncResult = await syncClient.merkleSync(client, {
174
+ scopes: acceptedScopes,
175
+ excludedDocumentIDs,
176
+ localTree,
177
+ direction: syncDirection,
178
+ localEntries,
179
+ // Forward the same delegation tokens used during negotiate so the
180
+ // merkle-sync access gate authorizes the accepted non-owned scopes. A
181
+ // revocation landing between negotiate and sync fails the sync; the
182
+ // retry renegotiates with the updated revocation state.
183
+ delegationTokens
184
+ });
185
+ // Step 4: Apply received mutation JWTs locally
186
+ if (syncResult.mutationJWTs.length > 0) {
187
+ this.#logger.info('Applying sync mutations', {
188
+ mutations: syncResult.mutationJWTs.length
189
+ });
190
+ const applyResult = await applySyncMutations({
191
+ db: params.stores,
192
+ graph: this.#graph,
193
+ mutationJWTs: syncResult.mutationJWTs,
194
+ selfDID: this.#identity.id,
195
+ storeUnreadable: this.#storeUnreadable,
196
+ defaultAccessLevel: this.#defaultAccessLevel
197
+ });
198
+ // Attempted counts what arrived to be applied; completed counts what
199
+ // landed. Reported side by side, so leaving one at 0 makes every sync
200
+ // read as having applied nothing of nothing.
201
+ sessionData.documentsAttempted += syncResult.mutationJWTs.length;
202
+ sessionData.documentsCompleted = applyResult.applied;
203
+ if (applyResult.deferred > 0) {
204
+ this.#logger.info('Deferred sync mutations for retry next round', {
205
+ deferred: applyResult.deferred
206
+ });
207
+ }
208
+ }
209
+ this.#lastSyncByPeer.set(peerDID, Date.now());
210
+ this.#emitEvent({
211
+ type: 'completed',
212
+ peerID: peerDID,
213
+ timestamp: Date.now()
214
+ });
215
+ this.#logger.info('Merkle sync completed', {
216
+ divergentBuckets: syncResult.divergentBuckets,
217
+ mutationsReceived: syncResult.mutationJWTs.length
218
+ });
219
+ return {
220
+ sessionID,
221
+ divergentBuckets: syncResult.divergentBuckets,
222
+ messagesReceived: syncResult.mutationJWTs.length,
223
+ messagesSent: syncResult.mutationsSent,
224
+ missingClusters
225
+ };
226
+ } catch (error) {
227
+ // DEBUG, and re-thrown: this layer cannot know whether the failure is a
228
+ // problem. An unreachable peer is `catchUpWithBestPeer`'s ORDINARY outcome
229
+ // (it warns once and walks past), while `syncPeer` raises to a screen.
230
+ // Reporting `error` here made every napping phone a red console error on
231
+ // the other one. Kept for the session-scoped detail no caller holds.
232
+ this.#logger.debug('Merkle sync failed', {
233
+ peerDID,
234
+ error
235
+ });
236
+ this.#emitEvent({
237
+ type: 'error',
238
+ peerID: peerDID,
239
+ timestamp: Date.now(),
240
+ error: error instanceof Error ? error.message : String(error)
241
+ });
242
+ throw error;
243
+ } finally{
244
+ if (client != null) {
245
+ try {
246
+ client.abort('SyncComplete');
247
+ await client.dispose();
248
+ } catch {
249
+ // Transport may already be closed
250
+ }
251
+ }
252
+ this.#activeSessions.delete(sessionID);
253
+ }
254
+ }
255
+ // ---- Status ----
256
+ getStatus(params) {
257
+ const { peerDID } = params;
258
+ const sessions = Array.from(this.#activeSessions.values()).filter((s)=>!peerDID || s.peerID === peerDID).map((data)=>({
259
+ peerID: data.peerID,
260
+ startTime: data.startTime,
261
+ documentsAttempted: data.documentsAttempted,
262
+ documentsCompleted: data.documentsCompleted
263
+ }));
264
+ const lastSyncByPeer = {};
265
+ for (const [peer, time] of this.#lastSyncByPeer.entries()){
266
+ if (!peerDID || peer === peerDID) {
267
+ lastSyncByPeer[peer] = time;
268
+ }
269
+ }
270
+ return {
271
+ activeSessions: sessions,
272
+ lastSyncByPeer
273
+ };
274
+ }
275
+ // ---- Events ----
276
+ onSyncEvent(callback) {
277
+ return this.#events.on('sync', callback);
278
+ }
279
+ /**
280
+ * Async-iterable view of the sync event stream, for bridging into a GraphQL
281
+ * subscription. An optional `filter` narrows to a single peer. Closing the
282
+ * generator (its `return()`, e.g. a subscriber disconnecting) unsubscribes the
283
+ * underlying listener, so no listener leaks past the consumer.
284
+ */ subscribeEvents(filter) {
285
+ return fromEmitter(this.#events, 'sync', {
286
+ filter
287
+ });
288
+ }
289
+ #emitEvent(event) {
290
+ this.#events.emit('sync', event).catch((error)=>{
291
+ this.#logger.error('Error in sync event listener', {
292
+ error
293
+ });
294
+ });
295
+ }
296
+ // ---- Lifecycle ----
297
+ async dispose() {
298
+ this.#activeSessions.clear();
299
+ this.#lastSyncByPeer.clear();
300
+ }
301
+ }
@@ -0,0 +1,52 @@
1
+ import type { ProcedureHandlers } from '@enkaku/server';
2
+ import type { OwnIdentity } from '@kokuin/token';
3
+ import type { StoreProvider } from '@kubun/db';
4
+ import type { Logger } from '@kubun/logger';
5
+ import type { MailboxHub } from '@kumiai/hub-tunnel';
6
+ import type { Runtime } from '@sozai/runtime';
7
+ import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
8
+ import type { SyncProtocol } from '../protocol.js';
9
+ export type TunnelListenersParams = {
10
+ stores: StoreProvider;
11
+ registry: GroupHandleRegistry;
12
+ identity: OwnIdentity;
13
+ localDID: string;
14
+ runtime: Runtime;
15
+ syncHandlers: ProcedureHandlers<SyncProtocol>;
16
+ /** The group's device-wide drain, or `undefined` when no hub is bound. */
17
+ tunnelHub: (groupID: string) => MailboxHub | undefined;
18
+ logger?: Logger;
19
+ idleTimeoutMs?: number;
20
+ };
21
+ export type TunnelListeners = {
22
+ /**
23
+ * Bring one group's listener set in line with its roster. Safe to call on any
24
+ * signal and at any frequency — repeated calls for an unchanged group do
25
+ * nothing.
26
+ */
27
+ reconcile: (groupID: string) => Promise<void>;
28
+ /** Reconcile every group this device has joined. */
29
+ reconcileAll: (groupIDs: Array<string>) => Promise<void>;
30
+ /** Stop and forget one group's listeners. */
31
+ removeGroup: (groupID: string) => Promise<void>;
32
+ dispose: () => Promise<void>;
33
+ };
34
+ /**
35
+ * The answering half of the tunnel: one listener per co-member per group, so a
36
+ * device that dials `tunnel://<groupID>/<ourDID>` finds something draining the
37
+ * mailbox.
38
+ *
39
+ * Without this a route resolves, opens, publishes its first frame and idles out:
40
+ * "nobody is home" and "nobody is listening" are the same silence to the dialer.
41
+ *
42
+ * ONE PER PEER, not one per group: every listener on a device receives on the
43
+ * same inbox topic, and two sharing a raw drain destroy each other's frames.
44
+ * `HubTunnelSyncListener` fronts the hub with a per-peer view for exactly this,
45
+ * so peers are separated here rather than multiplexed inside a session.
46
+ *
47
+ * Gated on this device having declared a profile — the same invariant presence
48
+ * runs on. A device that never opted in is in nobody's projection, so nothing
49
+ * can dial it and a listener would hold a subscription open for a session that
50
+ * cannot arrive.
51
+ */
52
+ export declare function createTunnelListeners(params: TunnelListenersParams): TunnelListeners;
@@ -0,0 +1,175 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { getP2PStore } from '@kubun/store-p2p';
3
+ import { HubTunnelSyncListener } from './hub-tunnel-sync-listener.js';
4
+ /**
5
+ * The answering half of the tunnel: one listener per co-member per group, so a
6
+ * device that dials `tunnel://<groupID>/<ourDID>` finds something draining the
7
+ * mailbox.
8
+ *
9
+ * Without this a route resolves, opens, publishes its first frame and idles out:
10
+ * "nobody is home" and "nobody is listening" are the same silence to the dialer.
11
+ *
12
+ * ONE PER PEER, not one per group: every listener on a device receives on the
13
+ * same inbox topic, and two sharing a raw drain destroy each other's frames.
14
+ * `HubTunnelSyncListener` fronts the hub with a per-peer view for exactly this,
15
+ * so peers are separated here rather than multiplexed inside a session.
16
+ *
17
+ * Gated on this device having declared a profile — the same invariant presence
18
+ * runs on. A device that never opted in is in nobody's projection, so nothing
19
+ * can dial it and a listener would hold a subscription open for a session that
20
+ * cannot arrive.
21
+ */ export function createTunnelListeners(params) {
22
+ const selfDID = normalizeDID(params.localDID);
23
+ const { logger } = params;
24
+ // groupID → peerDID → its listener.
25
+ const listeners = new Map();
26
+ // Which drain each group's listeners were built on. A listener holds its hub
27
+ // for life, so a group that switches hubs needs new ones — and the peer set
28
+ // alone cannot show that, since the same members are still wanted.
29
+ const attachedHubs = new Map();
30
+ // And which epoch they derived their topics at. A live listener's subscription
31
+ // is pinned to the epoch its spawn ran at, and only a disposed transport
32
+ // re-derives — so a group whose epoch moved keeps draining a topic nobody
33
+ // publishes to any more, while every dialer idles out against the silence. The
34
+ // peer set cannot show this either: the same members are still wanted.
35
+ const attachedEpochs = new Map();
36
+ // Reconciles of one group run in sequence: an epoch burst fires several, and
37
+ // two interleaved passes over the same map would each start a listener the
38
+ // other has not recorded yet.
39
+ const queues = new Map();
40
+ let disposed = false;
41
+ const stopGroup = async (groupID)=>{
42
+ attachedHubs.delete(groupID);
43
+ attachedEpochs.delete(groupID);
44
+ const group = listeners.get(groupID);
45
+ if (group == null) {
46
+ return;
47
+ }
48
+ listeners.delete(groupID);
49
+ await Promise.allSettled([
50
+ ...group.values()
51
+ ].map((listener)=>listener.stop()));
52
+ };
53
+ const runReconcile = async (groupID)=>{
54
+ if (disposed) {
55
+ return;
56
+ }
57
+ const store = await getP2PStore(params.stores);
58
+ const profile = await store.getLocalPeerProfile(selfDID);
59
+ if (profile == null) {
60
+ await stopGroup(groupID);
61
+ return;
62
+ }
63
+ const hub = params.tunnelHub(groupID);
64
+ if (hub == null) {
65
+ // No hub bound: there is no mailbox to drain. A binding arriving later
66
+ // reconciles again.
67
+ await stopGroup(groupID);
68
+ return;
69
+ }
70
+ const epoch = params.registry.groupEpoch(groupID);
71
+ if (attachedHubs.get(groupID) !== hub || attachedEpochs.get(groupID) !== epoch) {
72
+ await stopGroup(groupID);
73
+ }
74
+ attachedHubs.set(groupID, hub);
75
+ attachedEpochs.set(groupID, epoch);
76
+ const members = await store.listGroupMembers(groupID);
77
+ const wanted = new Set(members.map((member)=>normalizeDID(member.member_did)).filter((did)=>did !== selfDID));
78
+ let group = listeners.get(groupID);
79
+ if (group == null) {
80
+ group = new Map();
81
+ listeners.set(groupID, group);
82
+ }
83
+ for (const [peerDID, listener] of group){
84
+ if (!wanted.has(peerDID)) {
85
+ group.delete(peerDID);
86
+ try {
87
+ await listener.stop();
88
+ } catch (error) {
89
+ logger?.warn('tunnel listener stop failed', {
90
+ groupID,
91
+ peerDID,
92
+ error
93
+ });
94
+ }
95
+ }
96
+ }
97
+ // Every await above is a point where the group could have been left or the
98
+ // whole thing disposed. A pass that continued past one would repopulate
99
+ // `listeners` with fresh, STARTED listeners for a group this device is no
100
+ // longer in, and they would drain that group's inbox topic indefinitely —
101
+ // nothing later removes what `removeGroup` already walked past.
102
+ if (disposed || listeners.get(groupID) !== group) {
103
+ return;
104
+ }
105
+ for (const peerDID of wanted){
106
+ if (group.has(peerDID)) {
107
+ continue;
108
+ }
109
+ const listener = new HubTunnelSyncListener({
110
+ hub,
111
+ registry: params.registry,
112
+ groupID,
113
+ localDID: selfDID,
114
+ peerDID,
115
+ identity: params.identity,
116
+ syncHandlers: params.syncHandlers,
117
+ runtime: params.runtime,
118
+ ...params.idleTimeoutMs != null ? {
119
+ idleTimeoutMs: params.idleTimeoutMs
120
+ } : {},
121
+ ...logger != null ? {
122
+ logger
123
+ } : {}
124
+ });
125
+ group.set(peerDID, listener);
126
+ listener.start();
127
+ }
128
+ };
129
+ const reconcile = (groupID)=>{
130
+ const queued = (queues.get(groupID) ?? Promise.resolve()).then(()=>runReconcile(groupID), ()=>runReconcile(groupID));
131
+ // Never let one group's failure reject a caller that only asked for a
132
+ // reconcile — the next signal retries, and a throw here would surface on
133
+ // whichever unrelated lane happened to trigger it.
134
+ const settled = queued.catch((error)=>{
135
+ logger?.warn('tunnel listener reconcile failed', {
136
+ groupID,
137
+ error
138
+ });
139
+ });
140
+ queues.set(groupID, settled);
141
+ return settled;
142
+ };
143
+ return {
144
+ reconcile,
145
+ reconcileAll: async (groupIDs)=>{
146
+ await Promise.all(groupIDs.map((groupID)=>reconcile(groupID)));
147
+ },
148
+ removeGroup: async (groupID)=>{
149
+ // Await the in-flight reconcile BEFORE tearing down. Dropping the queue
150
+ // entry does not stop the pass already running: it would finish after the
151
+ // stop and leave started listeners behind for a group the device has
152
+ // left. `runReconcile` also re-checks, so this is the pair.
153
+ const queued = queues.get(groupID);
154
+ queues.delete(groupID);
155
+ if (queued != null) {
156
+ await queued.catch(()=>{
157
+ // A failed reconcile is already logged; the teardown proceeds.
158
+ });
159
+ }
160
+ await stopGroup(groupID);
161
+ },
162
+ dispose: async ()=>{
163
+ disposed = true;
164
+ const inFlight = [
165
+ ...queues.values()
166
+ ];
167
+ queues.clear();
168
+ await Promise.allSettled(inFlight);
169
+ const groupIDs = [
170
+ ...listeners.keys()
171
+ ];
172
+ await Promise.allSettled(groupIDs.map((groupID)=>stopGroup(groupID)));
173
+ }
174
+ };
175
+ }
@@ -0,0 +1,25 @@
1
+ import type { Logger } from '@kubun/logger';
2
+ import type { ObservabilityEventListener } from '@kumiai/hub-tunnel';
3
+ /**
4
+ * Which side of a tunnel session a transport is. Carried on every line a
5
+ * session logs, because the two are indistinguishable otherwise: both receive
6
+ * on this device's inbox topic for the epoch, and a drop only means something
7
+ * once you know whether the reader was answering a peer or dialling one.
8
+ */
9
+ export type TunnelSessionRole = 'dialer' | 'listener';
10
+ /**
11
+ * Turn a tunnel transport's observability events into log lines, and pass them
12
+ * on to a listener the caller supplied.
13
+ *
14
+ * Until this existed the transports' `onEvent` port had no producer anywhere in
15
+ * the repo — declared, forwarded to the transport, and set by nobody. Every
16
+ * destroyed frame was therefore silent, in tests and on devices alike: a
17
+ * starved session showed up as an idle timeout half a minute later with nothing
18
+ * saying why. That is the entire reason a two-device sync failure had to be
19
+ * diagnosed by reading code rather than by reading a log.
20
+ *
21
+ * `logger` is expected to carry the session's context already (`groupID`,
22
+ * `peerDID`, `role`, `epoch`, topics), so a line needs no arguments beyond what
23
+ * the event itself adds.
24
+ */
25
+ export declare function createTunnelEventLogger(logger: Logger, delegate?: ObservabilityEventListener): ObservabilityEventListener;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Drops a healthy lane never produces, so each one is a frame that reached this
3
+ * device and was thrown away.
4
+ *
5
+ * - `decrypt` — the ciphertext did not open. On a lane whose key is right, that
6
+ * means something else opened it first: MLS consumes a ratchet generation per
7
+ * open, so a second reader of the same frame is handed a key that is gone.
8
+ * - `session-mismatch` — it opened, and belonged to a session this reader is not
9
+ * serving. The frame is destroyed for the session it WAS for, which is now
10
+ * waiting out its idle timeout with nothing raised.
11
+ * - `group-mismatch` — a frame for another group arrived on this topic, which
12
+ * the envelope catches before the cipher. Same-key misroute, never routine.
13
+ *
14
+ * Everything else is routine: `dedup` is the hub redelivering, `topic-mismatch`
15
+ * is the shared device drain doing its job, `envelope-decode` is a foreign
16
+ * publisher on the topic.
17
+ */ const UNEXPECTED_REASONS = new Set([
18
+ 'decrypt',
19
+ 'session-mismatch',
20
+ 'group-mismatch'
21
+ ]);
22
+ /**
23
+ * Turn a tunnel transport's observability events into log lines, and pass them
24
+ * on to a listener the caller supplied.
25
+ *
26
+ * Until this existed the transports' `onEvent` port had no producer anywhere in
27
+ * the repo — declared, forwarded to the transport, and set by nobody. Every
28
+ * destroyed frame was therefore silent, in tests and on devices alike: a
29
+ * starved session showed up as an idle timeout half a minute later with nothing
30
+ * saying why. That is the entire reason a two-device sync failure had to be
31
+ * diagnosed by reading code rather than by reading a log.
32
+ *
33
+ * `logger` is expected to carry the session's context already (`groupID`,
34
+ * `peerDID`, `role`, `epoch`, topics), so a line needs no arguments beyond what
35
+ * the event itself adds.
36
+ */ export function createTunnelEventLogger(logger, delegate) {
37
+ return (event)=>{
38
+ switch(event.type){
39
+ case 'decrypt-failed':
40
+ logger.warn('tunnel frame failed to decrypt', {
41
+ error: event.error
42
+ });
43
+ break;
44
+ case 'envelope-decode-failed':
45
+ logger.debug('tunnel envelope failed to decode', {
46
+ error: event.error
47
+ });
48
+ break;
49
+ case 'frame-dropped':
50
+ if (UNEXPECTED_REASONS.has(event.reason)) {
51
+ logger.warn('tunnel frame dropped', {
52
+ reason: event.reason
53
+ });
54
+ } else {
55
+ logger.debug('tunnel frame dropped', {
56
+ reason: event.reason
57
+ });
58
+ }
59
+ break;
60
+ }
61
+ delegate?.(event);
62
+ };
63
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Which side of a directed session a topic belongs to. A `dialer` topic carries
3
+ * the responses flowing back to whoever opened the session; a `responder` topic
4
+ * carries the requests arriving at whoever answers.
5
+ */
6
+ export type TunnelRole = 'dialer' | 'responder';
7
+ /**
8
+ * The topic on which `ownerDID` receives the frames addressed to it in `role`.
9
+ *
10
+ * Both sides derive both topics from the same (secret, epoch), so a dialer knows
11
+ * where to write and a responder knows where to answer without negotiating
12
+ * anything. Topics rotate with the MLS epoch exactly as the inbox ones did.
13
+ *
14
+ * Scoped by role and owner but NOT by session: a device holds one drain per
15
+ * (role, peer) and separates concurrent sessions to different peers by sender.
16
+ * Two sessions in the same role to the SAME peer at once would still contend —
17
+ * nothing in the sync client opens those, and a session id in the scope would
18
+ * cost a round trip to agree on before either side could subscribe.
19
+ */
20
+ export declare function tunnelTopic(secret: Uint8Array, epoch: number, role: TunnelRole, ownerDID: string): string;
@@ -0,0 +1,28 @@
1
+ import { protocolTopic } from '@kumiai/rpc';
2
+ /**
3
+ * The lane every directed sync tunnel rides, owned by this package.
4
+ *
5
+ * Deliberately not `inboxTopic`: that derives ONE topic per member, so a device
6
+ * dialing a peer and answering that same peer both read and both write the two
7
+ * members' inboxes. The two roles then share a topic, and sender scope cannot
8
+ * separate them — a peer's reply and that peer's own dial request are equally
9
+ * "from the peer". Whichever of the two opens a frame first consumes its MLS
10
+ * ratchet generation and DESTROYS it for the other, which waits out its idle
11
+ * timeout instead. `inboxTopic` is also `@kumiai/rpc`'s reserved lane, and a
12
+ * host protocol has no business publishing into it.
13
+ */ const SYNC_TUNNEL_PROTOCOL = 'kubun/sync-tunnel/v1';
14
+ /**
15
+ * The topic on which `ownerDID` receives the frames addressed to it in `role`.
16
+ *
17
+ * Both sides derive both topics from the same (secret, epoch), so a dialer knows
18
+ * where to write and a responder knows where to answer without negotiating
19
+ * anything. Topics rotate with the MLS epoch exactly as the inbox ones did.
20
+ *
21
+ * Scoped by role and owner but NOT by session: a device holds one drain per
22
+ * (role, peer) and separates concurrent sessions to different peers by sender.
23
+ * Two sessions in the same role to the SAME peer at once would still contend —
24
+ * nothing in the sync client opens those, and a session id in the scope would
25
+ * cost a round trip to agree on before either side could subscribe.
26
+ */ export function tunnelTopic(secret, epoch, role, ownerDID) {
27
+ return protocolTopic(secret, epoch, SYNC_TUNNEL_PROTOCOL, `${role}:${ownerDID}`);
28
+ }