@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.
- package/lib/context/group.js +1073 -746
- package/lib/context/hub.js +2 -1
- package/lib/context/join.js +50 -15
- package/lib/context/peer.d.ts +92 -0
- package/lib/context/peer.js +1216 -0
- package/lib/context/require-admin.d.ts +57 -0
- package/lib/context/require-admin.js +57 -0
- package/lib/context/sync.d.ts +12 -0
- package/lib/context/sync.js +234 -1
- package/lib/context/types.d.ts +56 -20
- package/lib/context/types.js +23 -3
- package/lib/groups/access-default-token.d.ts +44 -0
- package/lib/groups/access-default-token.js +103 -0
- package/lib/groups/admin-roster.d.ts +42 -117
- package/lib/groups/admin-roster.js +53 -168
- package/lib/groups/anchor-store.d.ts +15 -0
- package/lib/groups/anchor-store.js +37 -0
- package/lib/groups/app-cursor-store.d.ts +17 -0
- package/lib/groups/app-cursor-store.js +34 -0
- package/lib/groups/broadcast.d.ts +199 -81
- package/lib/groups/broadcast.js +443 -368
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +32 -64
- package/lib/groups/circle-projection.js +95 -181
- package/lib/groups/circle-reducers.d.ts +71 -110
- package/lib/groups/circle-reducers.js +57 -127
- package/lib/groups/commit-adoption.d.ts +77 -0
- package/lib/groups/commit-adoption.js +108 -0
- package/lib/groups/commit-journal.d.ts +14 -0
- package/lib/groups/commit-journal.js +65 -0
- package/lib/groups/control-request.d.ts +66 -0
- package/lib/groups/control-request.js +101 -0
- package/lib/groups/events.d.ts +14 -1
- package/lib/groups/group-anchor.d.ts +18 -57
- package/lib/groups/group-anchor.js +15 -100
- package/lib/groups/group-crypto.d.ts +36 -13
- package/lib/groups/group-crypto.js +124 -50
- package/lib/groups/group-handle-registry.d.ts +106 -36
- package/lib/groups/group-handle-registry.js +351 -61
- package/lib/groups/group-handlers.d.ts +5 -4
- package/lib/groups/group-handlers.js +155 -29
- package/lib/groups/group-health-monitor.d.ts +1 -1
- package/lib/groups/group-health-monitor.js +1 -1
- package/lib/groups/group-mls.d.ts +35 -4
- package/lib/groups/group-mls.js +594 -62
- package/lib/groups/group-peer-manager.d.ts +212 -19
- package/lib/groups/group-peer-manager.js +1061 -69
- package/lib/groups/group-protocols.d.ts +485 -28
- package/lib/groups/group-protocols.js +289 -15
- package/lib/groups/invite-payload.d.ts +48 -0
- package/lib/groups/join-request-identity.d.ts +21 -0
- package/lib/groups/join-request-identity.js +43 -0
- package/lib/groups/join-utils.d.ts +60 -0
- package/lib/groups/join-utils.js +262 -0
- package/lib/groups/ledger-affected-events.d.ts +11 -6
- package/lib/groups/ledger-affected-events.js +15 -26
- package/lib/groups/ledger-commit-fold.d.ts +107 -0
- package/lib/groups/ledger-commit-fold.js +167 -0
- package/lib/groups/ledger-ingest.d.ts +124 -0
- package/lib/groups/ledger-ingest.js +212 -0
- package/lib/groups/ledger.d.ts +44 -0
- package/lib/groups/ledger.js +30 -0
- package/lib/groups/manager.d.ts +202 -19
- package/lib/groups/manager.js +623 -106
- package/lib/groups/mls-codec.d.ts +27 -6
- package/lib/groups/mls-codec.js +30 -15
- package/lib/groups/mls-encryptor.js +9 -6
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +12 -4
- package/lib/groups/mls-receive-errors.d.ts +0 -2
- package/lib/groups/mls-receive-errors.js +3 -6
- package/lib/groups/mls-state.d.ts +5 -3
- package/lib/groups/mls-state.js +27 -4
- package/lib/groups/peer-presence.d.ts +90 -0
- package/lib/groups/peer-presence.js +295 -0
- package/lib/groups/peer-selection.d.ts +37 -0
- package/lib/groups/peer-selection.js +72 -0
- package/lib/groups/roster-projection.d.ts +53 -0
- package/lib/groups/roster-projection.js +65 -0
- package/lib/groups/store-received-grant.d.ts +4 -3
- package/lib/groups/store-received-grant.js +80 -35
- package/lib/groups/store-received-revocation.d.ts +16 -13
- package/lib/groups/store-received-revocation.js +23 -22
- package/lib/hub/http-client.d.ts +10 -27
- package/lib/hub/http-client.js +2 -19
- package/lib/hub/hub-like.d.ts +57 -6
- package/lib/hub/hub-like.js +539 -52
- package/lib/hub/loopback-log-hub.d.ts +20 -0
- package/lib/hub/loopback-log-hub.js +178 -0
- package/lib/hub/manager.d.ts +25 -40
- package/lib/hub/manager.js +51 -94
- package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
- package/lib/hub/peer-scoped-hub-view.js +92 -0
- package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
- package/lib/hub/sender-scoped-hub-view.js +59 -0
- package/lib/hub/server-did.d.ts +67 -0
- package/lib/hub/server-did.js +131 -0
- package/lib/hub/wiring.d.ts +71 -14
- package/lib/hub/wiring.js +80 -23
- package/lib/index.d.ts +48 -13
- package/lib/index.js +257 -86
- package/lib/peer/connection-registry.d.ts +45 -0
- package/lib/peer/connection-registry.js +52 -0
- package/lib/peer/handlers.d.ts +69 -0
- package/lib/peer/handlers.js +110 -0
- package/lib/protocol.d.ts +213 -0
- package/lib/protocol.js +268 -0
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +805 -131
- package/lib/sync/access-default-sender.d.ts +23 -0
- package/lib/sync/access-default-sender.js +80 -0
- package/lib/sync/authorize.d.ts +57 -3
- package/lib/sync/authorize.js +45 -4
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +2 -3
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +55 -39
- package/lib/sync/errors.d.ts +9 -0
- package/lib/sync/errors.js +10 -0
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +13 -19
- package/lib/sync/handlers.js +54 -14
- package/lib/sync/hub-tunnel-sync-listener.d.ts +16 -6
- package/lib/sync/hub-tunnel-sync-listener.js +126 -37
- package/lib/sync/hub-tunnel-sync-provider.d.ts +31 -11
- package/lib/sync/hub-tunnel-sync-provider.js +68 -19
- package/lib/sync/merkle-channel.d.ts +9 -1
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/peer-registry.d.ts +14 -10
- package/lib/sync/peer-registry.js +0 -25
- package/lib/sync/sync-client.d.ts +11 -1
- package/lib/sync/sync-client.js +72 -10
- package/lib/sync/sync-manager.d.ts +22 -4
- package/lib/sync/sync-manager.js +39 -10
- package/lib/sync/tunnel-listeners.d.ts +52 -0
- package/lib/sync/tunnel-listeners.js +175 -0
- package/lib/sync/tunnel-observability.d.ts +25 -0
- package/lib/sync/tunnel-observability.js +63 -0
- package/lib/sync/tunnel-topics.d.ts +20 -0
- package/lib/sync/tunnel-topics.js +28 -0
- package/lib/types.d.ts +699 -28
- package/lib/types.js +0 -1
- package/lib/util/handler-error.d.ts +16 -0
- package/lib/util/handler-error.js +64 -0
- package/lib/util/mutex.d.ts +14 -1
- package/lib/util/mutex.js +37 -3
- package/package.json +59 -52
- package/lib/groups/ledger-entry.d.ts +0 -37
- package/lib/groups/ledger-entry.js +0 -56
- package/lib/groups/ledger-fold.d.ts +0 -56
- package/lib/groups/ledger-fold.js +0 -53
- package/lib/hub/did-observing-transport.d.ts +0 -75
- package/lib/hub/did-observing-transport.js +0 -126
- package/lib/hub/errors.d.ts +0 -24
- 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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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?:
|
|
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>>;
|
package/lib/sync/sync-client.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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?:
|
|
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
|
|
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:
|
|
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
|
}
|
package/lib/sync/sync-manager.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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;
|