@kubun/plugin-p2p 0.11.0 → 0.12.1

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 (156) hide show
  1. package/lib/context/group.js +1073 -746
  2. package/lib/context/hub.js +2 -1
  3. package/lib/context/join.js +50 -15
  4. package/lib/context/peer.d.ts +92 -0
  5. package/lib/context/peer.js +1216 -0
  6. package/lib/context/require-admin.d.ts +57 -0
  7. package/lib/context/require-admin.js +57 -0
  8. package/lib/context/sync.d.ts +12 -0
  9. package/lib/context/sync.js +234 -1
  10. package/lib/context/types.d.ts +56 -20
  11. package/lib/context/types.js +23 -3
  12. package/lib/groups/access-default-token.d.ts +44 -0
  13. package/lib/groups/access-default-token.js +103 -0
  14. package/lib/groups/admin-roster.d.ts +42 -117
  15. package/lib/groups/admin-roster.js +53 -168
  16. package/lib/groups/anchor-store.d.ts +15 -0
  17. package/lib/groups/anchor-store.js +37 -0
  18. package/lib/groups/app-cursor-store.d.ts +17 -0
  19. package/lib/groups/app-cursor-store.js +34 -0
  20. package/lib/groups/broadcast.d.ts +199 -81
  21. package/lib/groups/broadcast.js +443 -368
  22. package/lib/groups/catalog-token.d.ts +50 -0
  23. package/lib/groups/catalog-token.js +96 -0
  24. package/lib/groups/circle-projection.d.ts +32 -64
  25. package/lib/groups/circle-projection.js +95 -181
  26. package/lib/groups/circle-reducers.d.ts +71 -110
  27. package/lib/groups/circle-reducers.js +57 -127
  28. package/lib/groups/commit-adoption.d.ts +77 -0
  29. package/lib/groups/commit-adoption.js +108 -0
  30. package/lib/groups/commit-journal.d.ts +14 -0
  31. package/lib/groups/commit-journal.js +65 -0
  32. package/lib/groups/control-request.d.ts +66 -0
  33. package/lib/groups/control-request.js +101 -0
  34. package/lib/groups/events.d.ts +14 -1
  35. package/lib/groups/group-anchor.d.ts +18 -57
  36. package/lib/groups/group-anchor.js +15 -100
  37. package/lib/groups/group-crypto.d.ts +36 -13
  38. package/lib/groups/group-crypto.js +124 -50
  39. package/lib/groups/group-handle-registry.d.ts +106 -36
  40. package/lib/groups/group-handle-registry.js +351 -61
  41. package/lib/groups/group-handlers.d.ts +5 -4
  42. package/lib/groups/group-handlers.js +155 -29
  43. package/lib/groups/group-health-monitor.d.ts +1 -1
  44. package/lib/groups/group-health-monitor.js +1 -1
  45. package/lib/groups/group-mls.d.ts +35 -4
  46. package/lib/groups/group-mls.js +594 -62
  47. package/lib/groups/group-peer-manager.d.ts +212 -19
  48. package/lib/groups/group-peer-manager.js +1061 -69
  49. package/lib/groups/group-protocols.d.ts +485 -28
  50. package/lib/groups/group-protocols.js +289 -15
  51. package/lib/groups/invite-payload.d.ts +48 -0
  52. package/lib/groups/join-request-identity.d.ts +21 -0
  53. package/lib/groups/join-request-identity.js +43 -0
  54. package/lib/groups/join-utils.d.ts +60 -0
  55. package/lib/groups/join-utils.js +262 -0
  56. package/lib/groups/ledger-affected-events.d.ts +11 -6
  57. package/lib/groups/ledger-affected-events.js +15 -26
  58. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  59. package/lib/groups/ledger-commit-fold.js +167 -0
  60. package/lib/groups/ledger-ingest.d.ts +124 -0
  61. package/lib/groups/ledger-ingest.js +212 -0
  62. package/lib/groups/ledger.d.ts +44 -0
  63. package/lib/groups/ledger.js +30 -0
  64. package/lib/groups/manager.d.ts +202 -19
  65. package/lib/groups/manager.js +623 -106
  66. package/lib/groups/mls-codec.d.ts +27 -6
  67. package/lib/groups/mls-codec.js +30 -15
  68. package/lib/groups/mls-encryptor.js +9 -6
  69. package/lib/groups/mls-group-handle.d.ts +37 -2
  70. package/lib/groups/mls-group-handle.js +12 -4
  71. package/lib/groups/mls-receive-errors.d.ts +0 -2
  72. package/lib/groups/mls-receive-errors.js +3 -6
  73. package/lib/groups/mls-state.d.ts +5 -3
  74. package/lib/groups/mls-state.js +27 -4
  75. package/lib/groups/peer-presence.d.ts +90 -0
  76. package/lib/groups/peer-presence.js +295 -0
  77. package/lib/groups/peer-selection.d.ts +37 -0
  78. package/lib/groups/peer-selection.js +72 -0
  79. package/lib/groups/roster-projection.d.ts +53 -0
  80. package/lib/groups/roster-projection.js +65 -0
  81. package/lib/groups/store-received-grant.d.ts +4 -3
  82. package/lib/groups/store-received-grant.js +80 -35
  83. package/lib/groups/store-received-revocation.d.ts +16 -13
  84. package/lib/groups/store-received-revocation.js +23 -22
  85. package/lib/hub/http-client.d.ts +10 -27
  86. package/lib/hub/http-client.js +2 -19
  87. package/lib/hub/hub-like.d.ts +57 -6
  88. package/lib/hub/hub-like.js +539 -52
  89. package/lib/hub/loopback-log-hub.d.ts +20 -0
  90. package/lib/hub/loopback-log-hub.js +178 -0
  91. package/lib/hub/manager.d.ts +25 -40
  92. package/lib/hub/manager.js +51 -94
  93. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  94. package/lib/hub/peer-scoped-hub-view.js +92 -0
  95. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  96. package/lib/hub/sender-scoped-hub-view.js +59 -0
  97. package/lib/hub/server-did.d.ts +67 -0
  98. package/lib/hub/server-did.js +131 -0
  99. package/lib/hub/wiring.d.ts +71 -14
  100. package/lib/hub/wiring.js +80 -23
  101. package/lib/index.d.ts +48 -13
  102. package/lib/index.js +257 -86
  103. package/lib/peer/connection-registry.d.ts +45 -0
  104. package/lib/peer/connection-registry.js +52 -0
  105. package/lib/peer/handlers.d.ts +69 -0
  106. package/lib/peer/handlers.js +110 -0
  107. package/lib/protocol.d.ts +213 -0
  108. package/lib/protocol.js +268 -0
  109. package/lib/schema.d.ts +2 -1
  110. package/lib/schema.js +805 -131
  111. package/lib/sync/access-default-sender.d.ts +23 -0
  112. package/lib/sync/access-default-sender.js +80 -0
  113. package/lib/sync/authorize.d.ts +57 -3
  114. package/lib/sync/authorize.js +45 -4
  115. package/lib/sync/broadcast-queue.d.ts +3 -4
  116. package/lib/sync/broadcast-queue.js +2 -3
  117. package/lib/sync/catalog-scope.d.ts +8 -7
  118. package/lib/sync/catalog-scope.js +55 -39
  119. package/lib/sync/errors.d.ts +9 -0
  120. package/lib/sync/errors.js +10 -0
  121. package/lib/sync/forwarder.d.ts +13 -19
  122. package/lib/sync/forwarder.js +13 -19
  123. package/lib/sync/handlers.js +54 -14
  124. package/lib/sync/hub-tunnel-sync-listener.d.ts +16 -6
  125. package/lib/sync/hub-tunnel-sync-listener.js +126 -37
  126. package/lib/sync/hub-tunnel-sync-provider.d.ts +31 -11
  127. package/lib/sync/hub-tunnel-sync-provider.js +68 -19
  128. package/lib/sync/merkle-channel.d.ts +9 -1
  129. package/lib/sync/merkle-channel.js +0 -0
  130. package/lib/sync/peer-registry.d.ts +14 -10
  131. package/lib/sync/peer-registry.js +0 -25
  132. package/lib/sync/sync-client.d.ts +11 -1
  133. package/lib/sync/sync-client.js +72 -10
  134. package/lib/sync/sync-manager.d.ts +22 -4
  135. package/lib/sync/sync-manager.js +39 -10
  136. package/lib/sync/tunnel-listeners.d.ts +52 -0
  137. package/lib/sync/tunnel-listeners.js +175 -0
  138. package/lib/sync/tunnel-observability.d.ts +25 -0
  139. package/lib/sync/tunnel-observability.js +63 -0
  140. package/lib/sync/tunnel-topics.d.ts +20 -0
  141. package/lib/sync/tunnel-topics.js +28 -0
  142. package/lib/types.d.ts +699 -28
  143. package/lib/types.js +0 -1
  144. package/lib/util/handler-error.d.ts +16 -0
  145. package/lib/util/handler-error.js +64 -0
  146. package/lib/util/mutex.d.ts +14 -1
  147. package/lib/util/mutex.js +37 -3
  148. package/package.json +59 -52
  149. package/lib/groups/ledger-entry.d.ts +0 -37
  150. package/lib/groups/ledger-entry.js +0 -56
  151. package/lib/groups/ledger-fold.d.ts +0 -56
  152. package/lib/groups/ledger-fold.js +0 -53
  153. package/lib/hub/did-observing-transport.d.ts +0 -75
  154. package/lib/hub/did-observing-transport.js +0 -126
  155. package/lib/hub/errors.d.ts +0 -24
  156. package/lib/hub/errors.js +0 -33
@@ -1,10 +1,11 @@
1
1
  import type { VerifyTokenHook } from '@kokuin/capability';
2
2
  import type { SignedPayload } from '@kokuin/token';
3
3
  import type { KubunDB } from '@kubun/db';
4
- import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
4
+ import { type DefaultAccessLevel, type GraphInternals } from '@kubun/engine';
5
5
  import type { Logger } from '@kubun/logger';
6
6
  import type { SyncMerkleSyncParams, SyncMerkleSyncReceive, SyncMerkleSyncResult, SyncMerkleSyncSend } from '../protocol.js';
7
7
  import type { StoreUnreadableMode } from '../types.js';
8
+ import { type CircleReadGrantResolver } from './authorize.js';
8
9
  import { type PeerRegistry } from './peer-registry.js';
9
10
  /**
10
11
  * Minimal handler context shape for sync/merkle-sync channel.
@@ -47,6 +48,13 @@ export type CreateMerkleSyncChannelHandlerParams = {
47
48
  * state and cannot drift.
48
49
  */
49
50
  getRevocationChecker?: () => Promise<VerifyTokenHook>;
51
+ /**
52
+ * Resolves the circle-granted read resolver. Shared with the negotiate handler
53
+ * so both gates accept identical scopes. Enables a circle member to pull an
54
+ * owner's docs for a model the owner's tier-2 read default shares with that
55
+ * circle.
56
+ */
57
+ getCircleReadGrant?: () => Promise<CircleReadGrantResolver>;
50
58
  };
51
59
  export declare function createMerkleSyncChannelHandler(params: CreateMerkleSyncChannelHandlerParams): (ctx: MerkleSyncChannelContext) => Promise<SyncMerkleSyncResult>;
52
60
  export {};
Binary file
@@ -8,15 +8,23 @@ import type { SyncDirection } from '../protocol.js';
8
8
  * Otherwise the responder's policy wins.
9
9
  */
10
10
  export declare function negotiateDirection(requested: SyncDirection, allowed: SyncDirection): SyncDirection;
11
+ /**
12
+ * How to REACH a peer, plus the one policy a responder applies to it.
13
+ *
14
+ * Deliberately not a place to say what a peer may see. This row is written
15
+ * locally by whichever side dialled, nothing authenticates it, and it is absent
16
+ * entirely for a co-member reached through a group tunnel — so a gate keyed on
17
+ * it would be missing exactly when peering works as designed. What a peer may
18
+ * read is decided per document at the far end, against its authenticated DID.
19
+ */
11
20
  export type PeerConfig = {
12
21
  peerDID: string;
13
22
  endpoint: string;
14
- mode: 'persistent' | 'on-demand';
15
- allowedUsers: Array<string> | {
16
- all: boolean;
17
- };
18
- priority: string;
19
- trustLevel: 'trusted' | 'restricted';
23
+ /**
24
+ * What this device is willing to do with this peer — a resource decision
25
+ * about its own disk and apply path, not a claim about the peer's rights.
26
+ * The responder's policy wins; see {@link negotiateDirection}.
27
+ */
20
28
  allowedDirection?: SyncDirection;
21
29
  };
22
30
  export type PeerConfigWithID = PeerConfig & {
@@ -50,8 +58,4 @@ export declare class PeerRegistry {
50
58
  peerDID: string;
51
59
  stores: StoreProvider;
52
60
  }): Promise<void>;
53
- isPeerAllowed(params: {
54
- peerDID: string;
55
- stores: StoreProvider;
56
- }): Promise<boolean>;
57
61
  }
@@ -11,12 +11,6 @@ import { getP2PStore } from '@kubun/store-p2p';
11
11
  // Incompatible — responder policy wins
12
12
  return allowed;
13
13
  }
14
- function parseAllowedUsers(value) {
15
- if (typeof value === 'string') {
16
- return JSON.parse(value);
17
- }
18
- return value;
19
- }
20
14
  function parseConfig(value) {
21
15
  if (typeof value === 'string') {
22
16
  return JSON.parse(value);
@@ -29,10 +23,6 @@ function rowToPeerConfig(row) {
29
23
  id: row.id,
30
24
  peerDID: row.peer_did,
31
25
  endpoint: row.endpoint,
32
- mode: row.mode,
33
- allowedUsers: parseAllowedUsers(row.allowed_users),
34
- priority: row.priority,
35
- trustLevel: row.trust_level,
36
26
  allowedDirection: config.allowedDirection,
37
27
  createdAt: row.created_at,
38
28
  updatedAt: row.updated_at
@@ -49,10 +39,6 @@ export class PeerRegistry {
49
39
  id: this.#runtime.getRandomID(),
50
40
  peer_did: params.config.peerDID,
51
41
  endpoint: params.config.endpoint,
52
- mode: params.config.mode,
53
- allowed_users: params.config.allowedUsers,
54
- priority: params.config.priority,
55
- trust_level: params.config.trustLevel,
56
42
  config: {
57
43
  allowedDirection: params.config.allowedDirection
58
44
  },
@@ -84,10 +70,6 @@ export class PeerRegistry {
84
70
  const store = await getP2PStore(params.stores);
85
71
  await store.updateSyncPeer(params.peerDID, {
86
72
  endpoint: updated.endpoint,
87
- mode: updated.mode,
88
- allowed_users: updated.allowedUsers,
89
- priority: updated.priority,
90
- trust_level: updated.trustLevel,
91
73
  config: {
92
74
  allowedDirection: updated.allowedDirection
93
75
  }
@@ -97,11 +79,4 @@ export class PeerRegistry {
97
79
  const store = await getP2PStore(params.stores);
98
80
  await store.removeSyncPeer(params.peerDID);
99
81
  }
100
- async isPeerAllowed(params) {
101
- const peer = await this.getPeer({
102
- peerDID: params.peerDID,
103
- stores: params.stores
104
- });
105
- return peer !== undefined;
106
- }
107
82
  }
@@ -11,11 +11,20 @@ import type { SyncDirection, SyncProtocol } from '../protocol.js';
11
11
  export type SyncTransportProvider = {
12
12
  createSyncTransport: (signal?: AbortSignal) => ClientTransportOf<SyncProtocol> | Promise<ClientTransportOf<SyncProtocol>>;
13
13
  };
14
+ /**
15
+ * What a route carries beyond the peer DID. `tunnel://` names the group whose
16
+ * MLS epoch keys the session's inbox topics; `direct://` names nothing, so a
17
+ * resolver serving only in-process peers can ignore the second argument.
18
+ */
19
+ export type SyncRoute = {
20
+ groupID: string;
21
+ };
22
+ export type SyncServerResolver = (serverID: string, route?: SyncRoute) => SyncTransportProvider | undefined;
14
23
  export type SyncClientParams = {
15
24
  runtime: Runtime;
16
25
  identity: SigningIdentity;
17
26
  logger: Logger;
18
- serverResolver?: (serverID: string) => SyncTransportProvider | undefined;
27
+ serverResolver?: SyncServerResolver;
19
28
  };
20
29
  export type SyncScope = {
21
30
  modelID: string;
@@ -34,6 +43,7 @@ export declare class SyncClient {
34
43
  /**
35
44
  * Connect to a peer server using the specified endpoint.
36
45
  * - `direct://server-id` - Use in-process direct transport via sync server
46
+ * - `tunnel://group-id/peer-did` - Use a hub tunnel through the named group
37
47
  * - `http://...` or `https://...` - Use HTTP transport
38
48
  */
39
49
  connect(endpoint: string, serverID?: string): Promise<Client<SyncProtocol>>;
@@ -12,6 +12,19 @@ import { findDivergentBuckets, getTimeBuckets, SYNC_BATCH_SIZE } from './merkle-
12
12
  #identity;
13
13
  #logger;
14
14
  #serverResolver;
15
+ /**
16
+ * The context each session's lines carry, keyed by the client `connect` built
17
+ * for it.
18
+ *
19
+ * Held here rather than passed through `negotiate` / `merkleSync` because the
20
+ * peer a session is talking to is fixed when it is dialled and is not a
21
+ * parameter of either call. Without it these logs report bucket and mutation
22
+ * counts with nothing naming the peer, which is what makes two devices' logs
23
+ * impossible to join except by grepping DIDs out of unrelated lines.
24
+ *
25
+ * The transport's own `sessionID` is not here: it is minted inside the tunnel
26
+ * provider, which logs it against the same (group, peer, epoch).
27
+ */ #sessionLoggers = new WeakMap();
15
28
  constructor(params){
16
29
  this.#runtime = params.runtime;
17
30
  this.#identity = params.identity;
@@ -21,16 +34,27 @@ import { findDivergentBuckets, getTimeBuckets, SYNC_BATCH_SIZE } from './merkle-
21
34
  /**
22
35
  * Connect to a peer server using the specified endpoint.
23
36
  * - `direct://server-id` - Use in-process direct transport via sync server
37
+ * - `tunnel://group-id/peer-did` - Use a hub tunnel through the named group
24
38
  * - `http://...` or `https://...` - Use HTTP transport
25
39
  */ async connect(endpoint, serverID) {
26
40
  if (endpoint.startsWith('direct://')) {
27
41
  return this.#connectDirect(endpoint);
28
42
  }
43
+ if (endpoint.startsWith('tunnel://')) {
44
+ return this.#connectTunnel(endpoint);
45
+ }
29
46
  if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
30
47
  return this.#connectHTTP(endpoint, serverID);
31
48
  }
32
49
  throw new Error(`Unsupported endpoint scheme: ${endpoint}`);
33
50
  }
51
+ /** The session's own logger, or the client-wide one for a session built elsewhere. */ #loggerFor(client) {
52
+ return this.#sessionLoggers.get(client) ?? this.#logger;
53
+ }
54
+ #trackSession(client, context) {
55
+ this.#sessionLoggers.set(client, this.#logger.getChild('sync-client').with(context));
56
+ return client;
57
+ }
34
58
  #connectHTTP(endpoint, serverID) {
35
59
  if (serverID == null || serverID === '') {
36
60
  throw new Error('HTTP sync transport requires a serverID (peer DID) to authenticate; without it the server rejects all requests');
@@ -39,20 +63,42 @@ import { findDivergentBuckets, getTimeBuckets, SYNC_BATCH_SIZE } from './merkle-
39
63
  url: endpoint,
40
64
  fetch: this.#runtime.fetch
41
65
  });
42
- return new Client({
66
+ return this.#trackSession(new Client({
43
67
  runtime: this.#runtime,
44
68
  identity: this.#identity,
45
69
  logger: this.#logger.getChild('sync-client'),
46
70
  serverID,
47
71
  transport
72
+ }), {
73
+ peerDID: serverID,
74
+ scheme: 'http'
48
75
  });
49
76
  }
50
77
  async #connectDirect(endpoint) {
51
- const serverID = endpoint.replace('direct://', '');
78
+ return await this.#connectResolved(endpoint.replace('direct://', ''), 'direct');
79
+ }
80
+ /**
81
+ * `tunnel://<groupID>/<peerDID>` — a session relayed by the group's hub, with
82
+ * no server listening on either device. The audience comes from the route
83
+ * itself rather than from `connect`'s `serverID` argument: the route names the
84
+ * device whose inbox the frames are addressed to, so the two cannot disagree.
85
+ */ async #connectTunnel(endpoint) {
86
+ const route = endpoint.slice('tunnel://'.length);
87
+ const separator = route.indexOf('/');
88
+ const groupID = separator === -1 ? '' : route.slice(0, separator);
89
+ const peerDID = separator === -1 ? '' : route.slice(separator + 1);
90
+ if (groupID === '' || peerDID === '') {
91
+ throw new Error(`Invalid tunnel endpoint: ${endpoint} — expected tunnel://<groupID>/<peerDID>`);
92
+ }
93
+ return await this.#connectResolved(peerDID, 'tunnel', {
94
+ groupID
95
+ });
96
+ }
97
+ async #connectResolved(serverID, scheme, route) {
52
98
  if (!this.#serverResolver) {
53
- throw new Error('Server resolver not configured for direct transport');
99
+ throw new Error(`Server resolver not configured for ${scheme} transport`);
54
100
  }
55
- const provider = this.#serverResolver(serverID);
101
+ const provider = this.#serverResolver(serverID, route);
56
102
  if (!provider) {
57
103
  throw new Error(`Server not found: ${serverID}`);
58
104
  }
@@ -61,18 +107,25 @@ import { findDivergentBuckets, getTimeBuckets, SYNC_BATCH_SIZE } from './merkle-
61
107
  // its epoch-bound inbox topics from the live MLS handle), so await it; an
62
108
  // in-process provider returning a transport synchronously is unaffected.
63
109
  const transport = await provider.createSyncTransport();
64
- return new Client({
110
+ return this.#trackSession(new Client({
65
111
  runtime: this.#runtime,
66
112
  identity: this.#identity,
67
113
  logger: this.#logger.getChild('sync-client'),
68
114
  serverID,
69
115
  transport
116
+ }), {
117
+ peerDID: serverID,
118
+ scheme,
119
+ ...route == null ? {} : {
120
+ groupID: route.groupID
121
+ }
70
122
  });
71
123
  }
72
124
  /**
73
125
  * Negotiate sync scopes with peer.
74
126
  */ async negotiate(client, scopes, delegationTokens = [], knownModelIDs, direction) {
75
- this.#logger.debug('Negotiate: sending {scopeCount} scopes', {
127
+ const logger = this.#loggerFor(client);
128
+ logger.debug('Negotiate: sending {scopeCount} scopes', {
76
129
  scopeCount: scopes.length
77
130
  });
78
131
  const result = await client.request('sync/negotiate', {
@@ -83,7 +136,7 @@ import { findDivergentBuckets, getTimeBuckets, SYNC_BATCH_SIZE } from './merkle-
83
136
  direction
84
137
  }
85
138
  });
86
- this.#logger.debug('Negotiate: accepted {acceptedCount} scopes, excluded {excludedCount} docs', {
139
+ logger.debug('Negotiate: accepted {acceptedCount} scopes, excluded {excludedCount} docs', {
87
140
  acceptedCount: result.acceptedScopes.length,
88
141
  excludedCount: result.excludedDocumentIDs.length
89
142
  });
@@ -94,7 +147,8 @@ import { findDivergentBuckets, getTimeBuckets, SYNC_BATCH_SIZE } from './merkle-
94
147
  * Sends local tree and divergent mutations, receives remote mutations.
95
148
  */ async merkleSync(client, params) {
96
149
  const { scopes, excludedDocumentIDs, localTree, direction, localEntries, delegationTokens } = params;
97
- this.#logger.debug('MerkleSync: creating channel with {buckets} local buckets', {
150
+ const logger = this.#loggerFor(client);
151
+ logger.debug('MerkleSync: creating channel with {buckets} local buckets', {
98
152
  buckets: Object.keys(localTree.buckets).length
99
153
  });
100
154
  const channelCall = client.createChannel('sync/merkle-sync', {
@@ -108,6 +162,14 @@ import { findDivergentBuckets, getTimeBuckets, SYNC_BATCH_SIZE } from './merkle-
108
162
  }
109
163
  }
110
164
  });
165
+ // Marked handled the moment it exists, because the call is awaited LAST —
166
+ // after the send/receive pair below. A transport that aborts rejects all
167
+ // three, `Promise.all` throws first, and this one's rejection then reaches
168
+ // nobody: on React Native that is an error overlay with no message, which
169
+ // is exactly how a starved tunnel session presented during device QA. The
170
+ // await at the end still sees the rejection; this only stops it counting as
171
+ // unhandled in the window before that.
172
+ void channelCall.catch(()=>{});
111
173
  // Step 1: Read the responder's tree from the first message
112
174
  const reader = channelCall.readable.getReader();
113
175
  const firstRead = await reader.read();
@@ -125,7 +187,7 @@ import { findDivergentBuckets, getTimeBuckets, SYNC_BATCH_SIZE } from './merkle-
125
187
  };
126
188
  const divergentBuckets = findDivergentBuckets(localTree, remoteTree);
127
189
  const divergentBucketCount = divergentBuckets.length;
128
- this.#logger.debug('MerkleSync: found {count} divergent buckets', {
190
+ logger.debug('MerkleSync: found {count} divergent buckets', {
129
191
  count: divergentBucketCount
130
192
  });
131
193
  // Step 3: Concurrent send and receive
@@ -179,7 +241,7 @@ import { findDivergentBuckets, getTimeBuckets, SYNC_BATCH_SIZE } from './merkle-
179
241
  if (handlerResult != null && !handlerResult.success) {
180
242
  throw new Error('MerkleSync: responder reported failure');
181
243
  }
182
- this.#logger.debug('MerkleSync: completed with {received} received, {sent} sent', {
244
+ logger.debug('MerkleSync: completed with {received} received, {sent} sent', {
183
245
  received: mutationJWTs.length,
184
246
  sent: mutationsSent
185
247
  });
@@ -6,7 +6,7 @@ import type { Runtime } from '@sozai/runtime';
6
6
  import type { SyncDirection } from '../protocol.js';
7
7
  import type { StoreUnreadableMode } from '../types.js';
8
8
  import { type PeerConfig, type PeerConfigWithID, PeerRegistry } from './peer-registry.js';
9
- import { type SyncScope, type SyncTransportProvider } from './sync-client.js';
9
+ import { type SyncScope, type SyncServerResolver } from './sync-client.js';
10
10
  export type SyncSessionInfo = {
11
11
  peerID: string;
12
12
  startTime: number;
@@ -31,7 +31,7 @@ export type SyncManagerParams = {
31
31
  runtime: Runtime;
32
32
  identity: Identity;
33
33
  logger: Logger;
34
- serverResolver?: (serverID: string) => SyncTransportProvider | undefined;
34
+ serverResolver?: SyncServerResolver;
35
35
  /**
36
36
  * Receive-side storage mode forwarded to `applySyncMutations`.
37
37
  * Defaults to `'persist'` when omitted.
@@ -47,6 +47,15 @@ export type MerkleSyncParams = {
47
47
  delegationTokens?: Array<string>;
48
48
  knownModelIDs?: Array<string>;
49
49
  direction?: SyncDirection;
50
+ /**
51
+ * Where to reach the peer, when the caller already resolved it. Omitted, the
52
+ * persisted registry answers and a peer with no row is an error.
53
+ *
54
+ * A route, and only a route: what a session serves is decided per document
55
+ * downstream, so supplying one reaches a peer the registry never heard of and
56
+ * grants it nothing.
57
+ */
58
+ endpoint?: string;
50
59
  };
51
60
  export declare class SyncManager {
52
61
  #private;
@@ -57,9 +66,11 @@ export declare class SyncManager {
57
66
  */
58
67
  setIdentity(identity: SigningIdentity): void;
59
68
  /**
60
- * Configure the server resolver for direct transport connections.
69
+ * Configure the resolver for the transports built locally rather than dialled:
70
+ * `direct://` (in-process) and `tunnel://` (relayed by the group's hub, whose
71
+ * group id arrives as the resolver's `route`).
61
72
  */
62
- setServerResolver(resolver: (serverID: string) => SyncTransportProvider | undefined): void;
73
+ setServerResolver(resolver: SyncServerResolver): void;
63
74
  addPeer(params: {
64
75
  config: PeerConfig;
65
76
  stores: StoreProvider;
@@ -92,5 +103,12 @@ export declare class SyncManager {
92
103
  stores: StoreProvider;
93
104
  }): SyncStatus;
94
105
  onSyncEvent(callback: (event: SyncEvent) => void): () => void;
106
+ /**
107
+ * Async-iterable view of the sync event stream, for bridging into a GraphQL
108
+ * subscription. An optional `filter` narrows to a single peer. Closing the
109
+ * generator (its `return()`, e.g. a subscriber disconnecting) unsubscribes the
110
+ * underlying listener, so no listener leaks past the consumer.
111
+ */
112
+ subscribeEvents(filter?: (event: SyncEvent) => boolean): AsyncGenerator<SyncEvent, void, void>;
95
113
  dispose(): Promise<void>;
96
114
  }
@@ -1,6 +1,7 @@
1
1
  import { isSigningIdentity } from '@kokuin/token';
2
2
  import { getGraphStore } from '@kubun/store-graph';
3
3
  import { EventEmitter } from '@sozai/event';
4
+ import { fromEmitter } from '@sozai/generator';
4
5
  import { applySyncMutations } from './merkle-apply.js';
5
6
  import { buildMerkleTree } from './merkle-tree.js';
6
7
  import { PeerRegistry } from './peer-registry.js';
@@ -40,7 +41,9 @@ export class SyncManager {
40
41
  this.#identity = identity;
41
42
  }
42
43
  /**
43
- * Configure the server resolver for direct transport connections.
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`).
44
47
  */ setServerResolver(resolver) {
45
48
  this.#serverResolver = resolver;
46
49
  }
@@ -76,14 +79,21 @@ export class SyncManager {
76
79
  peerDID,
77
80
  scopes
78
81
  });
79
- const peer = await this.#peerRegistry.getPeer({
80
- peerDID,
81
- stores: params.stores
82
- });
83
- if (!peer) {
84
- throw new Error(`Peer ${peerDID} not found`);
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;
85
92
  }
86
- const sessionID = `merkle-sync-${peerDID}-${Date.now()}`;
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()}`;
87
97
  const sessionData = {
88
98
  peerID: peerDID,
89
99
  startTime: Date.now(),
@@ -108,7 +118,7 @@ export class SyncManager {
108
118
  logger: this.#logger,
109
119
  serverResolver: this.#serverResolver
110
120
  });
111
- client = await syncClient.connect(peer.endpoint, peerDID);
121
+ client = await syncClient.connect(endpoint, peerDID);
112
122
  // Step 1: Negotiate scopes
113
123
  const direction = requestedDirection ?? 'pull';
114
124
  this.#logger.info('Negotiating sync scopes', {
@@ -185,6 +195,10 @@ export class SyncManager {
185
195
  storeUnreadable: this.#storeUnreadable,
186
196
  defaultAccessLevel: this.#defaultAccessLevel
187
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;
188
202
  sessionData.documentsCompleted = applyResult.applied;
189
203
  if (applyResult.deferred > 0) {
190
204
  this.#logger.info('Deferred sync mutations for retry next round', {
@@ -210,7 +224,12 @@ export class SyncManager {
210
224
  missingClusters
211
225
  };
212
226
  } catch (error) {
213
- this.#logger.error('Merkle sync failed', {
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', {
214
233
  peerDID,
215
234
  error
216
235
  });
@@ -257,6 +276,16 @@ export class SyncManager {
257
276
  onSyncEvent(callback) {
258
277
  return this.#events.on('sync', callback);
259
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
+ }
260
289
  #emitEvent(event) {
261
290
  this.#events.emit('sync', event).catch((error)=>{
262
291
  this.#logger.error('Error in sync event listener', {
@@ -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;