@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.
- package/lib/context/delegation.js +130 -1
- package/lib/context/group.js +1646 -1
- package/lib/context/hub.js +151 -1
- package/lib/context/join.js +115 -1
- 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 +252 -1
- package/lib/context/types.d.ts +58 -22
- package/lib/context/types.js +48 -1
- 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 +55 -0
- package/lib/groups/admin-roster.js +69 -0
- 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-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +238 -106
- package/lib/groups/broadcast.js +702 -1
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +90 -0
- package/lib/groups/circle-projection.js +202 -0
- package/lib/groups/circle-reducers.d.ts +139 -0
- package/lib/groups/circle-reducers.js +128 -0
- 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 +19 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +24 -0
- package/lib/groups/group-anchor.js +22 -0
- package/lib/groups/group-crypto.d.ts +42 -0
- package/lib/groups/group-crypto.js +159 -0
- package/lib/groups/group-handle-registry.d.ts +138 -9
- package/lib/groups/group-handle-registry.js +533 -1
- package/lib/groups/group-handlers.d.ts +30 -0
- package/lib/groups/group-handlers.js +218 -0
- package/lib/groups/group-health-monitor.d.ts +11 -0
- package/lib/groups/group-health-monitor.js +139 -1
- package/lib/groups/group-mls.d.ts +66 -0
- package/lib/groups/group-mls.js +696 -0
- package/lib/groups/group-peer-manager.d.ts +301 -0
- package/lib/groups/group-peer-manager.js +1336 -0
- package/lib/groups/group-protocols.d.ts +754 -0
- package/lib/groups/group-protocols.js +448 -0
- package/lib/groups/invite-payload.d.ts +49 -1
- package/lib/groups/invite-payload.js +26 -1
- 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 +61 -0
- package/lib/groups/join-utils.js +366 -1
- package/lib/groups/ledger-affected-events.d.ts +20 -0
- package/lib/groups/ledger-affected-events.js +85 -0
- 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 +217 -85
- package/lib/groups/manager.js +798 -1
- package/lib/groups/mls-codec.d.ts +28 -7
- package/lib/groups/mls-codec.js +33 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +33 -1
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +18 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +27 -0
- package/lib/groups/mls-receive-errors.js +39 -0
- package/lib/groups/mls-state.d.ts +6 -4
- package/lib/groups/mls-state.js +79 -1
- 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 +7 -3
- package/lib/groups/store-received-grant.js +161 -1
- package/lib/groups/store-received-revocation.d.ts +19 -16
- package/lib/groups/store-received-revocation.js +83 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/http-client.d.ts +18 -10
- package/lib/hub/http-client.js +15 -1
- package/lib/hub/hub-like.d.ts +75 -0
- package/lib/hub/hub-like.js +1022 -0
- 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 +155 -1
- 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 +77 -51
- package/lib/hub/wiring.js +189 -1
- package/lib/index.d.ts +58 -22
- package/lib/index.js +563 -1
- 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 +214 -1
- package/lib/protocol.js +519 -1
- package/lib/schema.d.ts +3 -1
- package/lib/schema.js +1150 -39
- 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 +58 -4
- package/lib/sync/authorize.js +114 -1
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +182 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +84 -1
- package/lib/sync/errors.d.ts +12 -2
- package/lib/sync/errors.js +29 -1
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +271 -1
- package/lib/sync/handlers.js +200 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
- package/lib/sync/hub-tunnel-sync-listener.js +261 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
- package/lib/sync/hub-tunnel-sync-provider.js +113 -1
- package/lib/sync/merkle-apply.d.ts +5 -0
- package/lib/sync/merkle-apply.js +194 -1
- package/lib/sync/merkle-channel.d.ts +11 -3
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +15 -11
- package/lib/sync/peer-registry.js +82 -1
- package/lib/sync/receive-access-gate.js +24 -1
- package/lib/sync/scope-resolver.js +41 -1
- package/lib/sync/sync-client.d.ts +14 -4
- package/lib/sync/sync-client.js +254 -1
- package/lib/sync/sync-manager.d.ts +24 -6
- package/lib/sync/sync-manager.js +301 -1
- 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 +704 -22
- package/lib/types.js +1 -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 +44 -1
- package/lib/utils.js +21 -1
- package/package.json +58 -47
- package/lib/groups/broadcast-service.d.ts +0 -263
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/rejoin-codec.d.ts +0 -14
- package/lib/groups/rejoin-codec.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -34
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/circle-catchup-requester.d.ts +0 -58
- package/lib/hub/circle-catchup-requester.js +0 -1
- package/lib/hub/circle-catchup-responder.d.ts +0 -50
- package/lib/hub/circle-catchup-responder.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/did-observing-transport.d.ts +0 -64
- package/lib/hub/did-observing-transport.js +0 -1
- package/lib/hub/epoch-stale-detector.d.ts +0 -18
- package/lib/hub/epoch-stale-detector.js +0 -1
- package/lib/hub/errors.d.ts +0 -30
- package/lib/hub/errors.js +0 -1
- package/lib/hub/forward-remote-broadcast.d.ts +0 -15
- package/lib/hub/forward-remote-broadcast.js +0 -1
- package/lib/hub/group-channel.d.ts +0 -89
- package/lib/hub/group-channel.js +0 -1
- package/lib/hub/hub-connection.d.ts +0 -96
- package/lib/hub/hub-connection.js +0 -1
- package/lib/hub/receive-handler.d.ts +0 -51
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/rejoin-manager.d.ts +0 -78
- package/lib/hub/rejoin-manager.js +0 -1
- package/lib/hub/rejoin-responder.d.ts +0 -32
- package/lib/hub/rejoin-responder.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -142
- package/lib/hub/relay-manager.js +0 -1
- package/lib/hub/send-handler.d.ts +0 -40
- package/lib/hub/send-handler.js +0 -1
- package/lib/hub/tunnel-inbox.d.ts +0 -20
- package/lib/hub/tunnel-inbox.js +0 -1
- package/lib/hub/wait-for-gate.d.ts +0 -14
- package/lib/hub/wait-for-gate.js +0 -1
|
@@ -1 +1,179 @@
|
|
|
1
|
-
import{catalogMatchesDoc
|
|
1
|
+
import { catalogMatchesDoc, resolveAccessRule } from '@kubun/engine';
|
|
2
|
+
import { DocumentID } from '@kubun/id';
|
|
3
|
+
import { getGraphStore } from '@kubun/store-graph';
|
|
4
|
+
import { getP2PStore } from '@kubun/store-p2p';
|
|
5
|
+
import { computeBroadcastScopes } from './scope-resolver.js';
|
|
6
|
+
/**
|
|
7
|
+
* Default push-sync config. Disabled by default — push-sync is opt-in even
|
|
8
|
+
* when a hub is configured. Apps that want immediate broadcast on local
|
|
9
|
+
* authoring must explicitly set `pushSync: { enabled: true }`.
|
|
10
|
+
*/ export const DEFAULT_PUSH_SYNC_CONFIG = {
|
|
11
|
+
enabled: false
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Default rule used by the broadcast sender when the engine is not carrying
|
|
15
|
+
* an explicit server-side default access level. Kept conservative (only the
|
|
16
|
+
* owner receives) so undeclared models never accidentally fan out.
|
|
17
|
+
*/ const DEFAULT_ACCESS_LEVEL = {
|
|
18
|
+
read: 'only_owner',
|
|
19
|
+
write: 'only_owner'
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Subscribe to `engine:mutation:authored` and, for each locally-authored
|
|
23
|
+
* mutation, compute the set of MLS group scopes to broadcast to (via the
|
|
24
|
+
* pure `computeBroadcastScopes`) and fire per-scope
|
|
25
|
+
* `mutation:apply` broadcasts through the provided `scheduleBroadcast` hook.
|
|
26
|
+
*
|
|
27
|
+
* The subscriber swallows errors — a failure here must not propagate into the
|
|
28
|
+
* engine's event loop and stall other listeners. Errors are logged via the
|
|
29
|
+
* optional logger.
|
|
30
|
+
*
|
|
31
|
+
* Returns an unsubscribe function — the caller is responsible for invoking it
|
|
32
|
+
* on plugin dispose.
|
|
33
|
+
*/ export function wireBroadcastSender(params) {
|
|
34
|
+
const defaultAccessLevel = params.defaultAccessLevel ?? DEFAULT_ACCESS_LEVEL;
|
|
35
|
+
return params.eventBus.on('engine:mutation:authored', async (event)=>{
|
|
36
|
+
try {
|
|
37
|
+
await handleAuthoredMutation(event, {
|
|
38
|
+
...params,
|
|
39
|
+
defaultAccessLevel
|
|
40
|
+
});
|
|
41
|
+
} catch (error) {
|
|
42
|
+
params.logger?.warn('broadcast-sender handler failed', {
|
|
43
|
+
documentID: event.documentID,
|
|
44
|
+
error: String(error)
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async function handleAuthoredMutation(event, params) {
|
|
50
|
+
const graphStore = await getGraphStore(params.db);
|
|
51
|
+
const p2pStore = await getP2PStore(params.db);
|
|
52
|
+
// 1. Fetch document. If it no longer exists (unexpected, since the apply
|
|
53
|
+
// just completed) there is nothing to route — drop silently.
|
|
54
|
+
const doc = await graphStore.getDocument(DocumentID.fromString(event.documentID));
|
|
55
|
+
if (doc == null) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// 2. Resolve the effective read rule — broadcast scope follows read access.
|
|
59
|
+
const rule = await resolveAccessRule(doc, doc.model, doc.owner, 'read', {
|
|
60
|
+
getUserModelAccessDefault: (ownerDID, modelID, permissionType)=>graphStore.getUserModelAccessDefault(ownerDID, modelID, permissionType),
|
|
61
|
+
isMemberOfAnyCircle: (viewerDID, circleIDs)=>p2pStore.isMemberOfAnyCircle(viewerDID, circleIDs),
|
|
62
|
+
isMemberOfAnyGroup: (viewerDID, groupIDs)=>p2pStore.isMemberOfAnyGroup(viewerDID, groupIDs),
|
|
63
|
+
getModelInterfaces: (modelID)=>graphStore.getModelInterfaces(modelID)
|
|
64
|
+
}, params.defaultAccessLevel);
|
|
65
|
+
// Short-circuit only_owner — scope resolver would also drop this, but we
|
|
66
|
+
// can avoid all the catalog lookups.
|
|
67
|
+
if (rule.level === 'only_owner') {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// 3. Enumerate sender's circles and catalog-match per circle. A sender's MLS
|
|
71
|
+
// group is a candidate broadcast scope iff at least one of the sender's
|
|
72
|
+
// circles within that group covers this document.
|
|
73
|
+
//
|
|
74
|
+
// Pre-fetch every catalog referenced across senderCircles in ONE batched
|
|
75
|
+
// query — replaces an O(circles × catalogs) sequential `getCatalog` loop.
|
|
76
|
+
// catalogMatchesDoc reads are then run in parallel since they have no
|
|
77
|
+
// inter-iteration ordering.
|
|
78
|
+
const senderCircles = await p2pStore.getCirclesForMember(params.selfDID);
|
|
79
|
+
const catalogIDSet = new Set();
|
|
80
|
+
for (const circle of senderCircles){
|
|
81
|
+
for (const catalogID of circle.catalog_ids ?? []){
|
|
82
|
+
catalogIDSet.add(catalogID);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const catalogsByID = catalogIDSet.size > 0 ? await graphStore.getCatalogs(Array.from(catalogIDSet)) : new Map();
|
|
86
|
+
const matchInputs = [];
|
|
87
|
+
for (const circle of senderCircles){
|
|
88
|
+
for (const catalogID of circle.catalog_ids ?? []){
|
|
89
|
+
const catalog = catalogsByID.get(catalogID);
|
|
90
|
+
if (catalog == null) continue;
|
|
91
|
+
matchInputs.push({
|
|
92
|
+
groupID: circle.group_id,
|
|
93
|
+
criteria: catalog.filter_criteria
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const matchResults = await Promise.all(matchInputs.map(async (input)=>({
|
|
98
|
+
groupID: input.groupID,
|
|
99
|
+
matched: await catalogMatchesDoc(input.criteria, doc, p2pStore)
|
|
100
|
+
})));
|
|
101
|
+
const matchedGroupIDs = new Set();
|
|
102
|
+
for (const r of matchResults){
|
|
103
|
+
if (r.matched) matchedGroupIDs.add(r.groupID);
|
|
104
|
+
}
|
|
105
|
+
const senderGroupIDs = Array.from(matchedGroupIDs);
|
|
106
|
+
if (senderGroupIDs.length === 0) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// 4. Build the ResolvedAccessRule expected by computeBroadcastScopes.
|
|
110
|
+
const allowedGroupIDs = new Set(rule.allowedGroups ?? []);
|
|
111
|
+
// Single batched query for allowedCircles → parent group IDs. Replaces a
|
|
112
|
+
// per-circle `getCircle` loop.
|
|
113
|
+
const allowedCircleParentGroupIDs = new Set();
|
|
114
|
+
if (rule.allowedCircles != null && rule.allowedCircles.length > 0) {
|
|
115
|
+
const circlesByID = await p2pStore.getCircles(rule.allowedCircles);
|
|
116
|
+
for (const circle of circlesByID.values()){
|
|
117
|
+
if (circle.group_id != null) {
|
|
118
|
+
allowedCircleParentGroupIDs.add(circle.group_id);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Single batched query for allowedDIDs → groups. Replaces a per-DID
|
|
123
|
+
// `getGroupsForMember` loop.
|
|
124
|
+
const allowedDIDGroupIDs = new Set();
|
|
125
|
+
if (rule.allowedDIDs != null && rule.allowedDIDs.length > 0) {
|
|
126
|
+
const groupsByDID = await p2pStore.getGroupsForMembers(rule.allowedDIDs);
|
|
127
|
+
for (const groups of groupsByDID.values()){
|
|
128
|
+
for (const g of groups){
|
|
129
|
+
allowedDIDGroupIDs.add(g.id);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const resolvedRule = {
|
|
134
|
+
level: rule.level,
|
|
135
|
+
allowedGroupIDs,
|
|
136
|
+
allowedCircleParentGroupIDs,
|
|
137
|
+
allowedDIDGroupIDs
|
|
138
|
+
};
|
|
139
|
+
// 5. Resolve final broadcast scopes.
|
|
140
|
+
const scopes = computeBroadcastScopes({
|
|
141
|
+
resolvedRule,
|
|
142
|
+
senderGroupIDs
|
|
143
|
+
});
|
|
144
|
+
if (scopes.length === 0) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// 6. Per scope — broadcast_log dedup (returns false if already recorded)
|
|
148
|
+
// then enqueue into the per-target-group batch queue. Dedup MUST run before
|
|
149
|
+
// enqueue so duplicate entries never enter the queue.
|
|
150
|
+
//
|
|
151
|
+
// Record-before-send: a recorded entry whose queue.enqueue throws will
|
|
152
|
+
// not be retried. This is at-most-once delivery by design; missing
|
|
153
|
+
// broadcasts are recovered via merkle-pull, not by replaying the
|
|
154
|
+
// authored event.
|
|
155
|
+
//
|
|
156
|
+
// This loop stays SEQUENTIAL (no Promise.all). `recordBroadcast` ordering
|
|
157
|
+
// matters for the at-most-once invariant, and the queue's per-group state
|
|
158
|
+
// machines have not been audited for concurrent enqueue safety. The earlier
|
|
159
|
+
// catalog-match/getCircles/getGroupsForMembers calls were parallelized
|
|
160
|
+
// because they are pure DB reads with no inter-iteration ordering — this
|
|
161
|
+
// path performs side-effecting writes and external dispatch.
|
|
162
|
+
for (const groupID of scopes){
|
|
163
|
+
const recorded = await p2pStore.recordBroadcast({
|
|
164
|
+
docID: event.documentID,
|
|
165
|
+
version: event.version,
|
|
166
|
+
mlsGroupID: groupID
|
|
167
|
+
});
|
|
168
|
+
if (!recorded) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const entry = {
|
|
172
|
+
mutationJWT: event.mutationJWT,
|
|
173
|
+
docID: event.documentID,
|
|
174
|
+
version: event.version,
|
|
175
|
+
modelID: event.modelID
|
|
176
|
+
};
|
|
177
|
+
params.queue.enqueue(groupID, entry, params.selfDID);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { StoreProvider } from '@kubun/db';
|
|
2
2
|
export type CatalogSyncScope = {
|
|
3
3
|
modelIDs: Array<string>;
|
|
4
4
|
owners: Array<string> | undefined;
|
|
5
|
-
requiredClusterIDs: Array<string>;
|
|
6
|
-
missingClusterIDs: Array<string>;
|
|
7
5
|
};
|
|
8
6
|
/**
|
|
9
7
|
* Resolve catalog criteria to sync-compatible scopes.
|
|
10
8
|
*
|
|
11
9
|
* Takes catalog IDs, resolves their criteria, and returns:
|
|
12
|
-
* - modelIDs: union of all model filters from all catalogs
|
|
10
|
+
* - modelIDs: union of all model filters from all catalogs, with an omitted
|
|
11
|
+
* filter expanded to every model this device has deployed
|
|
13
12
|
* - owners: union of all owner filters (explicit + circle members)
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
*
|
|
14
|
+
* Which of those models the requester lacks is NOT decided here: the negotiate
|
|
15
|
+
* handler answers that over the scopes it accepted, which is the only set that
|
|
16
|
+
* production actually sends.
|
|
16
17
|
*/
|
|
17
|
-
export declare function resolveCatalogSyncScopes(db:
|
|
18
|
+
export declare function resolveCatalogSyncScopes(db: StoreProvider, catalogIDs: Array<string>): Promise<CatalogSyncScope>;
|
|
@@ -1 +1,84 @@
|
|
|
1
|
-
import{getGraphStore
|
|
1
|
+
import { getGraphStore } from '@kubun/store-graph';
|
|
2
|
+
import { getP2PStore } from '@kubun/store-p2p';
|
|
3
|
+
/**
|
|
4
|
+
* Resolve catalog criteria to sync-compatible scopes.
|
|
5
|
+
*
|
|
6
|
+
* Takes catalog IDs, resolves their criteria, and returns:
|
|
7
|
+
* - modelIDs: union of all model filters from all catalogs, with an omitted
|
|
8
|
+
* filter expanded to every model this device has deployed
|
|
9
|
+
* - owners: union of all owner filters (explicit + circle members)
|
|
10
|
+
*
|
|
11
|
+
* Which of those models the requester lacks is NOT decided here: the negotiate
|
|
12
|
+
* handler answers that over the scopes it accepted, which is the only set that
|
|
13
|
+
* production actually sends.
|
|
14
|
+
*/ export async function resolveCatalogSyncScopes(db, catalogIDs) {
|
|
15
|
+
const [graphStore, p2pStore] = await Promise.all([
|
|
16
|
+
getGraphStore(db),
|
|
17
|
+
getP2PStore(db)
|
|
18
|
+
]);
|
|
19
|
+
const allModelIDs = new Set();
|
|
20
|
+
const allOwners = new Set();
|
|
21
|
+
let hasOwnerFilter = false;
|
|
22
|
+
// Catalogs are advisory: only a locally-activated catalog scopes sync. A known
|
|
23
|
+
// (not-activated) or unknown catalog contributes nothing. Read as one batch —
|
|
24
|
+
// the loop below needs every record anyway, and `resolveCatalogScope` reads the
|
|
25
|
+
// row again for itself, so a per-id read here would be the second of three.
|
|
26
|
+
const catalogs = await graphStore.getCatalogs(catalogIDs);
|
|
27
|
+
const circleIDs = new Set();
|
|
28
|
+
// Read once, and only if some catalog omits its model filter.
|
|
29
|
+
let deployedModelIDs;
|
|
30
|
+
const getDeployedModelIDs = async ()=>{
|
|
31
|
+
deployedModelIDs ??= await graphStore.listDocumentModelIDs();
|
|
32
|
+
return deployedModelIDs;
|
|
33
|
+
};
|
|
34
|
+
for (const catalogID of catalogIDs){
|
|
35
|
+
const catalog = catalogs.get(catalogID);
|
|
36
|
+
if (catalog == null || catalog.active !== 1) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const scope = await graphStore.resolveCatalogScope(catalogID);
|
|
40
|
+
const criteria = catalog.filter_criteria;
|
|
41
|
+
const catalogCircles = criteria.circles != null && criteria.circles.length > 0 ? criteria.circles : undefined;
|
|
42
|
+
if (scope.owners != null) {
|
|
43
|
+
hasOwnerFilter = true;
|
|
44
|
+
for (const o of scope.owners){
|
|
45
|
+
allOwners.add(o);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (catalogCircles != null) {
|
|
49
|
+
hasOwnerFilter = true;
|
|
50
|
+
for (const circleID of catalogCircles){
|
|
51
|
+
circleIDs.add(circleID);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (scope.models != null) {
|
|
55
|
+
for (const m of scope.models){
|
|
56
|
+
allModelIDs.add(m);
|
|
57
|
+
}
|
|
58
|
+
} else if (scope.owners != null || catalogCircles != null) {
|
|
59
|
+
// "Omitted means no constraint" is only true where a dimension can be
|
|
60
|
+
// enumerated: expanding models at resolution time also covers a model
|
|
61
|
+
// deployed after the catalog was written. Owners stay anchorless on
|
|
62
|
+
// purpose — "everyone" is not a set — so the expansion is gated on the
|
|
63
|
+
// catalog naming owners or circles. Without that gate a criteria-less
|
|
64
|
+
// catalog would widen the SERVING side, where an absent owner filter
|
|
65
|
+
// means every distinct owner of every model.
|
|
66
|
+
for (const m of (await getDeployedModelIDs())){
|
|
67
|
+
allModelIDs.add(m);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Resolve circle-based owners via the p2p store, every catalog's circles at
|
|
72
|
+
// once. `listOwnersForCircles` unions `listCircleOwners` over the set: explicit
|
|
73
|
+
// members plus, for an open circle, the whole group's members — the pull-owner
|
|
74
|
+
// set mirrors the open-access predicate.
|
|
75
|
+
if (circleIDs.size > 0) {
|
|
76
|
+
for (const owner of (await p2pStore.listOwnersForCircles(Array.from(circleIDs)))){
|
|
77
|
+
allOwners.add(owner);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
modelIDs: Array.from(allModelIDs),
|
|
82
|
+
owners: hasOwnerFilter ? Array.from(allOwners) : undefined
|
|
83
|
+
};
|
|
84
|
+
}
|
package/lib/sync/errors.d.ts
CHANGED
|
@@ -5,7 +5,17 @@
|
|
|
5
5
|
* so server-side logging and tests can assert the reason.
|
|
6
6
|
*/
|
|
7
7
|
export declare class SyncAccessDeniedError extends Error {
|
|
8
|
-
|
|
9
|
-
readonly reason: string;
|
|
8
|
+
#private;
|
|
10
9
|
constructor(reason: string, message?: string);
|
|
10
|
+
get code(): 'KB08';
|
|
11
|
+
get reason(): string;
|
|
12
|
+
/**
|
|
13
|
+
* Carried alongside the code when the handler boundary translates this into a
|
|
14
|
+
* wire error, so a caller can tell the denials apart without parsing the
|
|
15
|
+
* message. The reasons are a fixed categorical set and disclose nothing the
|
|
16
|
+
* caller did not supply.
|
|
17
|
+
*/
|
|
18
|
+
get data(): {
|
|
19
|
+
reason: string;
|
|
20
|
+
};
|
|
11
21
|
}
|
package/lib/sync/errors.js
CHANGED
|
@@ -1 +1,29 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by the sync handlers when a request is denied before any store
|
|
3
|
+
* read — for example a forged subject, an unauthorized scope, or a
|
|
4
|
+
* disallowed direction. Tagged with the protocol-level error code `KB08`
|
|
5
|
+
* so server-side logging and tests can assert the reason.
|
|
6
|
+
*/ export class SyncAccessDeniedError extends Error {
|
|
7
|
+
#reason;
|
|
8
|
+
constructor(reason, message){
|
|
9
|
+
super(message ?? `Sync access denied: ${reason}`);
|
|
10
|
+
this.name = 'SyncAccessDeniedError';
|
|
11
|
+
this.#reason = reason;
|
|
12
|
+
}
|
|
13
|
+
get code() {
|
|
14
|
+
return 'KB08';
|
|
15
|
+
}
|
|
16
|
+
get reason() {
|
|
17
|
+
return this.#reason;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Carried alongside the code when the handler boundary translates this into a
|
|
21
|
+
* wire error, so a caller can tell the denials apart without parsing the
|
|
22
|
+
* message. The reasons are a fixed categorical set and disclose nothing the
|
|
23
|
+
* caller did not supply.
|
|
24
|
+
*/ get data() {
|
|
25
|
+
return {
|
|
26
|
+
reason: this.#reason
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
package/lib/sync/forwarder.d.ts
CHANGED
|
@@ -71,27 +71,21 @@ export type EvaluateAndForwardResult = {
|
|
|
71
71
|
skipped: number;
|
|
72
72
|
};
|
|
73
73
|
/**
|
|
74
|
-
* Evaluate forwarding candidates for
|
|
75
|
-
*
|
|
76
|
-
* `scheduleBroadcast`.
|
|
74
|
+
* Evaluate forwarding candidates for one just-applied `mutation:apply` entry and
|
|
75
|
+
* forward to each survivor via `scheduleBroadcast`.
|
|
77
76
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* document.
|
|
84
|
-
* 4. Filter (when `forwarding` is a function) — narrows further.
|
|
85
|
-
* 5. Dedup — `recordBroadcast` BEFORE schedule. False return → skip silently.
|
|
86
|
-
* 6. Schedule — single-entry `mutation:apply` broadcast with
|
|
87
|
-
* `senderPeerDID = selfDID`.
|
|
77
|
+
* Gates per candidate group, in order: the source group is excluded first;
|
|
78
|
+
* `computeBroadcastScopes` against the post-apply rule; a circle the local peer
|
|
79
|
+
* belongs to in that group must hold a catalog whose `filter_criteria` matches
|
|
80
|
+
* the document; then the caller's `forwarding` filter when it is a function.
|
|
81
|
+
* `recordBroadcast` runs BEFORE the schedule and a false return skips silently.
|
|
88
82
|
*
|
|
89
|
-
* Per-candidate errors are isolated
|
|
90
|
-
*
|
|
83
|
+
* Per-candidate errors are isolated — one bad candidate logs and the rest are
|
|
84
|
+
* still evaluated — and the function never throws.
|
|
91
85
|
*
|
|
92
|
-
*
|
|
93
|
-
* batch queue
|
|
94
|
-
* with mixing locally-authored and forwarded entries in one
|
|
95
|
-
*
|
|
86
|
+
* Each candidate gets its OWN single-entry broadcast, `senderPeerDID = selfDID`,
|
|
87
|
+
* rather than going through the batch queue, whose `senderPeerDID`-mismatch
|
|
88
|
+
* flush conflicts with mixing locally-authored and forwarded entries in one
|
|
89
|
+
* batch.
|
|
96
90
|
*/
|
|
97
91
|
export declare function evaluateAndForward(params: EvaluateAndForwardParams): Promise<EvaluateAndForwardResult>;
|
package/lib/sync/forwarder.js
CHANGED
|
@@ -1 +1,271 @@
|
|
|
1
|
-
import{catalogMatchesDoc
|
|
1
|
+
import { catalogMatchesDoc, resolveAccessRule } from '@kubun/engine';
|
|
2
|
+
import { computeBroadcastScopes } from './scope-resolver.js';
|
|
3
|
+
// The store-read catch blocks below degrade gracefully on a transient failure
|
|
4
|
+
// (treat the result as missing and skip the forward). A SyntaxError / TypeError
|
|
5
|
+
// / ReferenceError / RangeError is a bug or corrupt data, not a transient
|
|
6
|
+
// condition — rethrow it so it surfaces instead of being silently swallowed.
|
|
7
|
+
export function rethrowIfProgrammerError(error) {
|
|
8
|
+
if (error instanceof SyntaxError || error instanceof TypeError || error instanceof ReferenceError || error instanceof RangeError) {
|
|
9
|
+
throw error;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Evaluate forwarding candidates for one just-applied `mutation:apply` entry and
|
|
14
|
+
* forward to each survivor via `scheduleBroadcast`.
|
|
15
|
+
*
|
|
16
|
+
* Gates per candidate group, in order: the source group is excluded first;
|
|
17
|
+
* `computeBroadcastScopes` against the post-apply rule; a circle the local peer
|
|
18
|
+
* belongs to in that group must hold a catalog whose `filter_criteria` matches
|
|
19
|
+
* the document; then the caller's `forwarding` filter when it is a function.
|
|
20
|
+
* `recordBroadcast` runs BEFORE the schedule and a false return skips silently.
|
|
21
|
+
*
|
|
22
|
+
* Per-candidate errors are isolated — one bad candidate logs and the rest are
|
|
23
|
+
* still evaluated — and the function never throws.
|
|
24
|
+
*
|
|
25
|
+
* Each candidate gets its OWN single-entry broadcast, `senderPeerDID = selfDID`,
|
|
26
|
+
* rather than going through the batch queue, whose `senderPeerDID`-mismatch
|
|
27
|
+
* flush conflicts with mixing locally-authored and forwarded entries in one
|
|
28
|
+
* batch.
|
|
29
|
+
*/ export async function evaluateAndForward(params) {
|
|
30
|
+
// 1. Zero-overhead short-circuit when forwarding is disabled.
|
|
31
|
+
if (params.forwarding === false) {
|
|
32
|
+
return {
|
|
33
|
+
forwarded: 0,
|
|
34
|
+
skipped: 0
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const filter = params.forwarding === true ? ()=>true : params.forwarding;
|
|
38
|
+
// 2. Enumerate self group memberships and exclude the source group.
|
|
39
|
+
const myGroups = await params.p2pStore.getGroupsForMember(params.selfDID);
|
|
40
|
+
const candidateGroupIDs = myGroups.map((g)=>g.id).filter((id)=>id !== params.sourceGroupID);
|
|
41
|
+
if (candidateGroupIDs.length === 0) {
|
|
42
|
+
return {
|
|
43
|
+
forwarded: 0,
|
|
44
|
+
skipped: 0
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// 3. Resolve the effective read rule against the post-apply state. Same
|
|
48
|
+
// db-shape wrappers as `processBroadcast` uses for `createReceiveAccessGate`.
|
|
49
|
+
let rule;
|
|
50
|
+
try {
|
|
51
|
+
rule = await resolveAccessRule(params.postState, params.postState.model, params.postState.owner, 'read', {
|
|
52
|
+
getUserModelAccessDefault: (ownerDID, modelID, permissionType)=>params.graphStore.getUserModelAccessDefault(ownerDID, modelID, permissionType),
|
|
53
|
+
isMemberOfAnyCircle: (viewerDID, circleIDs)=>params.p2pStore.isMemberOfAnyCircle(viewerDID, circleIDs),
|
|
54
|
+
isMemberOfAnyGroup: (viewerDID, groupIDs)=>params.p2pStore.isMemberOfAnyGroup(viewerDID, groupIDs),
|
|
55
|
+
getModelInterfaces: (modelID)=>params.graphStore.getModelInterfaces(modelID)
|
|
56
|
+
}, params.defaultAccessLevel);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
rethrowIfProgrammerError(error);
|
|
59
|
+
params.logger?.warn('forwarder: resolveAccessRule failed, skipping forward', {
|
|
60
|
+
docID: params.entry.docID,
|
|
61
|
+
version: params.entry.version,
|
|
62
|
+
sourceGroupID: params.sourceGroupID,
|
|
63
|
+
error: String(error)
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
forwarded: 0,
|
|
67
|
+
skipped: 0
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// 4. Build the resolved-rule shape consumed by computeBroadcastScopes. This
|
|
71
|
+
// mirrors the sender's pre-resolution in broadcast-sender.ts.
|
|
72
|
+
const allowedGroupIDs = new Set(rule.allowedGroups ?? []);
|
|
73
|
+
// Single batched query for allowedCircles → parent group IDs. Replaces a
|
|
74
|
+
// per-circle `getCircle` loop. A query-level failure aborts only this
|
|
75
|
+
// sub-step (with a log) and yields an empty set, mirroring the prior
|
|
76
|
+
// per-circle skip-on-error behavior in aggregate.
|
|
77
|
+
const allowedCircleParentGroupIDs = new Set();
|
|
78
|
+
if (rule.allowedCircles != null && rule.allowedCircles.length > 0) {
|
|
79
|
+
try {
|
|
80
|
+
const circlesByID = await params.p2pStore.getCircles(rule.allowedCircles);
|
|
81
|
+
for (const circle of circlesByID.values()){
|
|
82
|
+
if (circle.group_id != null) {
|
|
83
|
+
allowedCircleParentGroupIDs.add(circle.group_id);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch (error) {
|
|
87
|
+
rethrowIfProgrammerError(error);
|
|
88
|
+
params.logger?.warn('forwarder: getCircles failed, skipping allowedCircles', {
|
|
89
|
+
count: rule.allowedCircles.length,
|
|
90
|
+
error: String(error)
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Single batched query for allowedDIDs → groups. Replaces a per-DID
|
|
95
|
+
// `getGroupsForMember` loop.
|
|
96
|
+
const allowedDIDGroupIDs = new Set();
|
|
97
|
+
if (rule.allowedDIDs != null && rule.allowedDIDs.length > 0) {
|
|
98
|
+
try {
|
|
99
|
+
const groupsByDID = await params.p2pStore.getGroupsForMembers(rule.allowedDIDs);
|
|
100
|
+
for (const groups of groupsByDID.values()){
|
|
101
|
+
for (const g of groups){
|
|
102
|
+
allowedDIDGroupIDs.add(g.id);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
rethrowIfProgrammerError(error);
|
|
107
|
+
params.logger?.warn('forwarder: getGroupsForMembers failed, skipping allowedDIDs', {
|
|
108
|
+
count: rule.allowedDIDs.length,
|
|
109
|
+
error: String(error)
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const resolvedRule = {
|
|
114
|
+
level: rule.level,
|
|
115
|
+
allowedGroupIDs,
|
|
116
|
+
allowedCircleParentGroupIDs,
|
|
117
|
+
allowedDIDGroupIDs
|
|
118
|
+
};
|
|
119
|
+
// 5. Apply access gate via computeBroadcastScopes.
|
|
120
|
+
const accessPermitted = computeBroadcastScopes({
|
|
121
|
+
resolvedRule,
|
|
122
|
+
senderGroupIDs: candidateGroupIDs
|
|
123
|
+
});
|
|
124
|
+
if (accessPermitted.length === 0) {
|
|
125
|
+
return {
|
|
126
|
+
forwarded: 0,
|
|
127
|
+
skipped: 0
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// 6. Resolve self circles once and bucket by group_id for catalog-match
|
|
131
|
+
// lookups. A candidate group is catalog-eligible iff at least one of the
|
|
132
|
+
// local peer's circles WITHIN that group has a catalog matching the doc.
|
|
133
|
+
let myCircles;
|
|
134
|
+
try {
|
|
135
|
+
myCircles = await params.p2pStore.getCirclesForMember(params.selfDID);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
rethrowIfProgrammerError(error);
|
|
138
|
+
params.logger?.warn('forwarder: getCirclesForMember failed', {
|
|
139
|
+
selfDID: params.selfDID,
|
|
140
|
+
error: String(error)
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
forwarded: 0,
|
|
144
|
+
skipped: 0
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const accessSet = new Set(accessPermitted);
|
|
148
|
+
const circlesByGroup = new Map();
|
|
149
|
+
for (const circle of myCircles){
|
|
150
|
+
if (!accessSet.has(circle.group_id)) continue;
|
|
151
|
+
const list = circlesByGroup.get(circle.group_id);
|
|
152
|
+
if (list != null) {
|
|
153
|
+
list.push(circle);
|
|
154
|
+
} else {
|
|
155
|
+
circlesByGroup.set(circle.group_id, [
|
|
156
|
+
circle
|
|
157
|
+
]);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// 6b. Pre-fetch every catalog referenced by the relevant circles in a
|
|
161
|
+
// single batched DB query. This collapses O(candidates × circles ×
|
|
162
|
+
// catalogs) sequential awaits in the candidate loop down to ONE DB call
|
|
163
|
+
// per evaluation. Catalogs can be shared across circles, so the Set
|
|
164
|
+
// already dedups IDs before the call.
|
|
165
|
+
const catalogIDSet = new Set();
|
|
166
|
+
for (const circles of circlesByGroup.values()){
|
|
167
|
+
for (const circle of circles){
|
|
168
|
+
for (const catalogID of circle.catalog_ids ?? []){
|
|
169
|
+
catalogIDSet.add(catalogID);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
let catalogByID = new Map();
|
|
174
|
+
if (catalogIDSet.size > 0) {
|
|
175
|
+
try {
|
|
176
|
+
catalogByID = await params.graphStore.getCatalogs(Array.from(catalogIDSet));
|
|
177
|
+
} catch (error) {
|
|
178
|
+
rethrowIfProgrammerError(error);
|
|
179
|
+
// Single-query failure: treat all catalogs as missing (preserves the
|
|
180
|
+
// prior per-getCatalog-failure semantics in aggregate). Candidate
|
|
181
|
+
// groups whose catalogs are now unresolved will fall through the
|
|
182
|
+
// catalog gate as no-match.
|
|
183
|
+
params.logger?.warn('forwarder: getCatalogs failed, treating all as missing', {
|
|
184
|
+
count: catalogIDSet.size,
|
|
185
|
+
error: String(error)
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
let forwarded = 0;
|
|
190
|
+
let skipped = 0;
|
|
191
|
+
// 7. Evaluate each candidate group SEQUENTIALLY. This loop is deliberately
|
|
192
|
+
// not parallelized:
|
|
193
|
+
// - `filter(ctx)` is a user-supplied callback whose side effects (e.g.
|
|
194
|
+
// logging, metrics) may rely on candidate order.
|
|
195
|
+
// - `recordBroadcast` ordering matters for the at-most-once invariant —
|
|
196
|
+
// a failure mid-loop must not leave broadcasts recorded for groups
|
|
197
|
+
// that were never scheduled.
|
|
198
|
+
// - `scheduleBroadcast` ordering keeps test assertions stable and
|
|
199
|
+
// surfaces failures deterministically.
|
|
200
|
+
// Pure DB reads upstream (catalog batch fetch, getCircles,
|
|
201
|
+
// getGroupsForMembers) are batched/parallel — only this side-effecting
|
|
202
|
+
// dispatch stays sequential.
|
|
203
|
+
for (const candidateGroupID of accessPermitted){
|
|
204
|
+
try {
|
|
205
|
+
// Catalog gate.
|
|
206
|
+
const circlesInGroup = circlesByGroup.get(candidateGroupID) ?? [];
|
|
207
|
+
let catalogEligible = false;
|
|
208
|
+
for (const circle of circlesInGroup){
|
|
209
|
+
const catalogIDs = circle.catalog_ids ?? [];
|
|
210
|
+
for (const catalogID of catalogIDs){
|
|
211
|
+
const catalog = catalogByID.get(catalogID);
|
|
212
|
+
if (catalog == null) continue;
|
|
213
|
+
if (await catalogMatchesDoc(catalog.filter_criteria, params.postState, params.p2pStore)) {
|
|
214
|
+
catalogEligible = true;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (catalogEligible) break;
|
|
219
|
+
}
|
|
220
|
+
if (!catalogEligible) {
|
|
221
|
+
skipped++;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
// Filter gate.
|
|
225
|
+
const ctx = {
|
|
226
|
+
docID: params.entry.docID,
|
|
227
|
+
modelID: params.entry.modelID,
|
|
228
|
+
ownerDID: params.postState.owner,
|
|
229
|
+
sourceGroupID: params.sourceGroupID,
|
|
230
|
+
candidateGroupID,
|
|
231
|
+
postState: params.postState
|
|
232
|
+
};
|
|
233
|
+
const allow = await filter(ctx);
|
|
234
|
+
if (!allow) {
|
|
235
|
+
skipped++;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
// Dedup BEFORE schedule — record-before-send pattern (sender mirror).
|
|
239
|
+
const recorded = await params.p2pStore.recordBroadcast({
|
|
240
|
+
docID: params.entry.docID,
|
|
241
|
+
version: params.entry.version,
|
|
242
|
+
mlsGroupID: candidateGroupID
|
|
243
|
+
});
|
|
244
|
+
if (!recorded) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const message = {
|
|
248
|
+
type: 'mutation:apply',
|
|
249
|
+
entries: [
|
|
250
|
+
params.entry
|
|
251
|
+
],
|
|
252
|
+
senderPeerDID: params.selfDID
|
|
253
|
+
};
|
|
254
|
+
await params.scheduleBroadcast(candidateGroupID, message);
|
|
255
|
+
forwarded++;
|
|
256
|
+
} catch (error) {
|
|
257
|
+
// Per-candidate isolation: log and continue with rest.
|
|
258
|
+
params.logger?.warn('forwarder: candidate evaluation failed', {
|
|
259
|
+
docID: params.entry.docID,
|
|
260
|
+
version: params.entry.version,
|
|
261
|
+
candidateGroupID,
|
|
262
|
+
sourceGroupID: params.sourceGroupID,
|
|
263
|
+
error: String(error)
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
forwarded,
|
|
269
|
+
skipped
|
|
270
|
+
};
|
|
271
|
+
}
|