@kubun/plugin-p2p 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1646 -1
  3. package/lib/context/hub.js +151 -1
  4. package/lib/context/join.js +115 -1
  5. package/lib/context/peer.d.ts +92 -0
  6. package/lib/context/peer.js +1216 -0
  7. package/lib/context/require-admin.d.ts +57 -0
  8. package/lib/context/require-admin.js +57 -0
  9. package/lib/context/sync.d.ts +12 -0
  10. package/lib/context/sync.js +252 -1
  11. package/lib/context/types.d.ts +58 -22
  12. package/lib/context/types.js +48 -1
  13. package/lib/groups/access-default-token.d.ts +44 -0
  14. package/lib/groups/access-default-token.js +103 -0
  15. package/lib/groups/admin-roster.d.ts +55 -0
  16. package/lib/groups/admin-roster.js +69 -0
  17. package/lib/groups/anchor-store.d.ts +15 -0
  18. package/lib/groups/anchor-store.js +37 -0
  19. package/lib/groups/app-cursor-store.d.ts +17 -0
  20. package/lib/groups/app-cursor-store.js +34 -0
  21. package/lib/groups/broadcast-codec.js +6 -1
  22. package/lib/groups/broadcast.d.ts +238 -106
  23. package/lib/groups/broadcast.js +702 -1
  24. package/lib/groups/catalog-token.d.ts +50 -0
  25. package/lib/groups/catalog-token.js +96 -0
  26. package/lib/groups/circle-projection.d.ts +90 -0
  27. package/lib/groups/circle-projection.js +202 -0
  28. package/lib/groups/circle-reducers.d.ts +139 -0
  29. package/lib/groups/circle-reducers.js +128 -0
  30. package/lib/groups/commit-adoption.d.ts +77 -0
  31. package/lib/groups/commit-adoption.js +108 -0
  32. package/lib/groups/commit-journal.d.ts +14 -0
  33. package/lib/groups/commit-journal.js +65 -0
  34. package/lib/groups/control-request.d.ts +66 -0
  35. package/lib/groups/control-request.js +101 -0
  36. package/lib/groups/events.d.ts +19 -2
  37. package/lib/groups/events.js +10 -1
  38. package/lib/groups/group-anchor.d.ts +24 -0
  39. package/lib/groups/group-anchor.js +22 -0
  40. package/lib/groups/group-crypto.d.ts +42 -0
  41. package/lib/groups/group-crypto.js +159 -0
  42. package/lib/groups/group-handle-registry.d.ts +138 -9
  43. package/lib/groups/group-handle-registry.js +533 -1
  44. package/lib/groups/group-handlers.d.ts +30 -0
  45. package/lib/groups/group-handlers.js +218 -0
  46. package/lib/groups/group-health-monitor.d.ts +11 -0
  47. package/lib/groups/group-health-monitor.js +139 -1
  48. package/lib/groups/group-mls.d.ts +66 -0
  49. package/lib/groups/group-mls.js +696 -0
  50. package/lib/groups/group-peer-manager.d.ts +301 -0
  51. package/lib/groups/group-peer-manager.js +1336 -0
  52. package/lib/groups/group-protocols.d.ts +754 -0
  53. package/lib/groups/group-protocols.js +448 -0
  54. package/lib/groups/invite-payload.d.ts +49 -1
  55. package/lib/groups/invite-payload.js +26 -1
  56. package/lib/groups/join-request-identity.d.ts +21 -0
  57. package/lib/groups/join-request-identity.js +43 -0
  58. package/lib/groups/join-utils.d.ts +61 -0
  59. package/lib/groups/join-utils.js +366 -1
  60. package/lib/groups/ledger-affected-events.d.ts +20 -0
  61. package/lib/groups/ledger-affected-events.js +85 -0
  62. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  63. package/lib/groups/ledger-commit-fold.js +167 -0
  64. package/lib/groups/ledger-ingest.d.ts +124 -0
  65. package/lib/groups/ledger-ingest.js +212 -0
  66. package/lib/groups/ledger.d.ts +44 -0
  67. package/lib/groups/ledger.js +30 -0
  68. package/lib/groups/manager.d.ts +217 -85
  69. package/lib/groups/manager.js +798 -1
  70. package/lib/groups/mls-codec.d.ts +28 -7
  71. package/lib/groups/mls-codec.js +33 -1
  72. package/lib/groups/mls-encryptor.d.ts +2 -2
  73. package/lib/groups/mls-encryptor.js +33 -1
  74. package/lib/groups/mls-group-handle.d.ts +37 -2
  75. package/lib/groups/mls-group-handle.js +18 -1
  76. package/lib/groups/mls-json.js +17 -1
  77. package/lib/groups/mls-receive-errors.d.ts +27 -0
  78. package/lib/groups/mls-receive-errors.js +39 -0
  79. package/lib/groups/mls-state.d.ts +6 -4
  80. package/lib/groups/mls-state.js +79 -1
  81. package/lib/groups/peer-presence.d.ts +90 -0
  82. package/lib/groups/peer-presence.js +295 -0
  83. package/lib/groups/peer-selection.d.ts +37 -0
  84. package/lib/groups/peer-selection.js +72 -0
  85. package/lib/groups/roster-projection.d.ts +53 -0
  86. package/lib/groups/roster-projection.js +65 -0
  87. package/lib/groups/store-received-grant.d.ts +7 -3
  88. package/lib/groups/store-received-grant.js +161 -1
  89. package/lib/groups/store-received-revocation.d.ts +19 -16
  90. package/lib/groups/store-received-revocation.js +83 -1
  91. package/lib/hub/ack-backoff.d.ts +22 -0
  92. package/lib/hub/ack-backoff.js +88 -0
  93. package/lib/hub/http-client.d.ts +18 -10
  94. package/lib/hub/http-client.js +15 -1
  95. package/lib/hub/hub-like.d.ts +75 -0
  96. package/lib/hub/hub-like.js +1022 -0
  97. package/lib/hub/loopback-log-hub.d.ts +20 -0
  98. package/lib/hub/loopback-log-hub.js +178 -0
  99. package/lib/hub/manager.d.ts +25 -40
  100. package/lib/hub/manager.js +155 -1
  101. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  102. package/lib/hub/peer-scoped-hub-view.js +92 -0
  103. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  104. package/lib/hub/sender-scoped-hub-view.js +59 -0
  105. package/lib/hub/server-did.d.ts +67 -0
  106. package/lib/hub/server-did.js +131 -0
  107. package/lib/hub/wiring.d.ts +77 -51
  108. package/lib/hub/wiring.js +189 -1
  109. package/lib/index.d.ts +58 -22
  110. package/lib/index.js +563 -1
  111. package/lib/peer/connection-registry.d.ts +45 -0
  112. package/lib/peer/connection-registry.js +52 -0
  113. package/lib/peer/handlers.d.ts +69 -0
  114. package/lib/peer/handlers.js +110 -0
  115. package/lib/protocol.d.ts +214 -1
  116. package/lib/protocol.js +519 -1
  117. package/lib/schema.d.ts +3 -1
  118. package/lib/schema.js +1150 -39
  119. package/lib/sync/access-default-sender.d.ts +23 -0
  120. package/lib/sync/access-default-sender.js +80 -0
  121. package/lib/sync/authorize.d.ts +58 -4
  122. package/lib/sync/authorize.js +114 -1
  123. package/lib/sync/broadcast-queue.d.ts +3 -4
  124. package/lib/sync/broadcast-queue.js +182 -1
  125. package/lib/sync/broadcast-sender.js +179 -1
  126. package/lib/sync/catalog-scope.d.ts +8 -7
  127. package/lib/sync/catalog-scope.js +84 -1
  128. package/lib/sync/errors.d.ts +12 -2
  129. package/lib/sync/errors.js +29 -1
  130. package/lib/sync/forwarder.d.ts +13 -19
  131. package/lib/sync/forwarder.js +271 -1
  132. package/lib/sync/handlers.js +200 -1
  133. package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
  134. package/lib/sync/hub-tunnel-sync-listener.js +261 -1
  135. package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
  136. package/lib/sync/hub-tunnel-sync-provider.js +113 -1
  137. package/lib/sync/merkle-apply.d.ts +5 -0
  138. package/lib/sync/merkle-apply.js +194 -1
  139. package/lib/sync/merkle-channel.d.ts +11 -3
  140. package/lib/sync/merkle-channel.js +0 -0
  141. package/lib/sync/merkle-tree.js +120 -1
  142. package/lib/sync/peer-registry.d.ts +15 -11
  143. package/lib/sync/peer-registry.js +82 -1
  144. package/lib/sync/receive-access-gate.js +24 -1
  145. package/lib/sync/scope-resolver.js +41 -1
  146. package/lib/sync/sync-client.d.ts +14 -4
  147. package/lib/sync/sync-client.js +254 -1
  148. package/lib/sync/sync-manager.d.ts +24 -6
  149. package/lib/sync/sync-manager.js +301 -1
  150. package/lib/sync/tunnel-listeners.d.ts +52 -0
  151. package/lib/sync/tunnel-listeners.js +175 -0
  152. package/lib/sync/tunnel-observability.d.ts +25 -0
  153. package/lib/sync/tunnel-observability.js +63 -0
  154. package/lib/sync/tunnel-topics.d.ts +20 -0
  155. package/lib/sync/tunnel-topics.js +28 -0
  156. package/lib/types.d.ts +704 -22
  157. package/lib/types.js +1 -1
  158. package/lib/util/handler-error.d.ts +16 -0
  159. package/lib/util/handler-error.js +64 -0
  160. package/lib/util/mutex.d.ts +14 -1
  161. package/lib/util/mutex.js +44 -1
  162. package/lib/utils.js +21 -1
  163. package/package.json +58 -47
  164. package/lib/groups/broadcast-service.d.ts +0 -263
  165. package/lib/groups/broadcast-service.js +0 -1
  166. package/lib/groups/rejoin-codec.d.ts +0 -14
  167. package/lib/groups/rejoin-codec.js +0 -1
  168. package/lib/groups/wire-frame.d.ts +0 -34
  169. package/lib/groups/wire-frame.js +0 -1
  170. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  171. package/lib/hub/circle-catchup-requester.js +0 -1
  172. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  173. package/lib/hub/circle-catchup-responder.js +0 -1
  174. package/lib/hub/connection-pool.d.ts +0 -43
  175. package/lib/hub/connection-pool.js +0 -1
  176. package/lib/hub/did-observing-transport.d.ts +0 -64
  177. package/lib/hub/did-observing-transport.js +0 -1
  178. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  179. package/lib/hub/epoch-stale-detector.js +0 -1
  180. package/lib/hub/errors.d.ts +0 -30
  181. package/lib/hub/errors.js +0 -1
  182. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  183. package/lib/hub/forward-remote-broadcast.js +0 -1
  184. package/lib/hub/group-channel.d.ts +0 -89
  185. package/lib/hub/group-channel.js +0 -1
  186. package/lib/hub/hub-connection.d.ts +0 -96
  187. package/lib/hub/hub-connection.js +0 -1
  188. package/lib/hub/receive-handler.d.ts +0 -51
  189. package/lib/hub/receive-handler.js +0 -1
  190. package/lib/hub/rejoin-manager.d.ts +0 -78
  191. package/lib/hub/rejoin-manager.js +0 -1
  192. package/lib/hub/rejoin-responder.d.ts +0 -32
  193. package/lib/hub/rejoin-responder.js +0 -1
  194. package/lib/hub/relay-manager.d.ts +0 -142
  195. package/lib/hub/relay-manager.js +0 -1
  196. package/lib/hub/send-handler.d.ts +0 -40
  197. package/lib/hub/send-handler.js +0 -1
  198. package/lib/hub/tunnel-inbox.d.ts +0 -20
  199. package/lib/hub/tunnel-inbox.js +0 -1
  200. package/lib/hub/wait-for-gate.d.ts +0 -14
  201. package/lib/hub/wait-for-gate.js +0 -1
@@ -1 +1,179 @@
1
- import{catalogMatchesDoc as e,resolveAccessRule as r}from"@kubun/engine";import{DocumentID as t}from"@kubun/id";import{getGraphStore as o}from"@kubun/store-graph";import{getP2PStore as l}from"@kubun/store-p2p";import{computeBroadcastScopes as a}from"./scope-resolver.js";export const DEFAULT_PUSH_SYNC_CONFIG={enabled:!1};let n={read:"only_owner",write:"only_owner"};export function wireBroadcastSender(e){let r=e.defaultAccessLevel??n;return e.eventBus.on("engine:mutation:authored",async t=>{try{await i(t,{...e,defaultAccessLevel:r})}catch(r){e.logger?.warn("broadcast-sender handler failed",{documentID:t.documentID,error:String(r)})}})}async function i(n,i){let s=await o(i.db),d=await l(i.db),u=await s.getDocument(t.fromString(n.documentID));if(null==u)return;let f=await r(u,u.model,u.owner,"read",{getUserModelAccessDefault:(e,r,t)=>s.getUserModelAccessDefault(e,r,t),isMemberOfAnyCircle:(e,r)=>d.isMemberOfAnyCircle(e,r),isMemberOfAnyGroup:(e,r)=>d.isMemberOfAnyGroup(e,r),getModelInterfaces:e=>s.getModelInterfaces(e)},i.defaultAccessLevel);if("only_owner"===f.level)return;let c=await d.getCirclesForMember(i.selfDID),m=new Set;for(let e of c)for(let r of e.catalog_ids??[])m.add(r);let w=m.size>0?await s.getCatalogs(Array.from(m)):new Map,g=[];for(let e of c)for(let r of e.catalog_ids??[]){let t=w.get(r);null!=t&&g.push({groupID:e.group_id,criteria:t.filter_criteria})}let D=await Promise.all(g.map(async r=>({groupID:r.groupID,matched:await e(r.criteria,u,d)}))),p=new Set;for(let e of D)e.matched&&p.add(e.groupID);let I=Array.from(p);if(0===I.length)return;let b=new Set(f.allowedGroups??[]),v=new Set;if(null!=f.allowedCircles&&f.allowedCircles.length>0)for(let e of(await d.getCircles(f.allowedCircles)).values())null!=e.group_id&&v.add(e.group_id);let y=new Set;if(null!=f.allowedDIDs&&f.allowedDIDs.length>0)for(let e of(await d.getGroupsForMembers(f.allowedDIDs)).values())for(let r of e)y.add(r.id);let _=a({resolvedRule:{level:f.level,allowedGroupIDs:b,allowedCircleParentGroupIDs:v,allowedDIDGroupIDs:y},senderGroupIDs:I});if(0!==_.length)for(let e of _){if(!await d.recordBroadcast({docID:n.documentID,version:n.version,mlsGroupID:e}))continue;let r={mutationJWT:n.mutationJWT,docID:n.documentID,version:n.version,modelID:n.modelID};i.queue.enqueue(e,r,i.selfDID)}}
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 { KubunDB } from '@kubun/db';
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
- * - requiredClusterIDs: cluster IDs for all required models
15
- * - missingClusterIDs: cluster IDs for models not in knownModelIDs
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: KubunDB, catalogIDs: Array<string>, knownModelIDs?: Array<string>): Promise<CatalogSyncScope>;
18
+ export declare function resolveCatalogSyncScopes(db: StoreProvider, catalogIDs: Array<string>): Promise<CatalogSyncScope>;
@@ -1 +1,84 @@
1
- import{getGraphStore as e}from"@kubun/store-graph";import{getP2PStore as r}from"@kubun/store-p2p";export async function resolveCatalogSyncScopes(l,t,o){let[a,i]=await Promise.all([e(l),r(l)]),s=new Set,f=new Set,n=!1;for(let e of t){let r=await a.resolveCatalogScope(e);if(null!=r.models)for(let e of r.models)s.add(e);if(null!=r.owners)for(let e of(n=!0,r.owners))f.add(e);let l=await a.getCatalog(e);if(null!=l){let e=l.filter_criteria;if(null!=e.circles&&e.circles.length>0)for(let r of(n=!0,e.circles))for(let e of(await i.listCircleMembers(r)))f.add(e.member_did)}}let d=Array.from(s),m=n?Array.from(f):void 0,u=new Set(o??[]),c=new Set,w=new Set;return await Promise.all(d.map(async e=>{let r=await a.getClusterForModel(e);null!=r&&(c.add(r),u.has(e)||w.add(r))})),{modelIDs:d,owners:m,requiredClusterIDs:Array.from(c),missingClusterIDs:Array.from(w)}}
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
+ }
@@ -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
- readonly code: "KB08";
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
  }
@@ -1 +1,29 @@
1
- export class SyncAccessDeniedError extends Error{code="KB08";reason;constructor(e,r){super(r??`Sync access denied: ${e}`),this.name="SyncAccessDeniedError",this.reason=e}}
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
+ }
@@ -71,27 +71,21 @@ export type EvaluateAndForwardResult = {
71
71
  skipped: number;
72
72
  };
73
73
  /**
74
- * Evaluate forwarding candidates for a single mutation:apply entry the local
75
- * peer has just applied, and forward to each surviving candidate group via
76
- * `scheduleBroadcast`.
74
+ * Evaluate forwarding candidates for one just-applied `mutation:apply` entry and
75
+ * forward to each survivor via `scheduleBroadcast`.
77
76
  *
78
- * Pipeline (per candidate group):
79
- * 1. Source group exclusion `sourceGroupID` is filtered out before any check.
80
- * 2. Access gate `computeBroadcastScopes` against the post-apply rule.
81
- * 3. Catalog gate at least one circle the local peer belongs to in the
82
- * candidate group must have a catalog whose `filter_criteria` matches the
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: one bad candidate logs + continues; the
90
- * rest still get evaluated. The function never throws.
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
- * Routing decision: each candidate gets its OWN single-entry broadcast (no
93
- * batch queue). The queue's `senderPeerDID`-mismatch flush conflicts
94
- * with mixing locally-authored and forwarded entries in one batch — deferring
95
- * batched-forwarding keeps the receive path simple.
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>;
@@ -1 +1,271 @@
1
- import{catalogMatchesDoc as r,resolveAccessRule as e}from"@kubun/engine";import{computeBroadcastScopes as o}from"./scope-resolver.js";export function rethrowIfProgrammerError(r){if(r instanceof SyntaxError||r instanceof TypeError||r instanceof ReferenceError||r instanceof RangeError)throw r}export async function evaluateAndForward(t){let a,l;if(!1===t.forwarding)return{forwarded:0,skipped:0};let i=!0===t.forwarding?()=>!0:t.forwarding,n=(await t.p2pStore.getGroupsForMember(t.selfDID)).map(r=>r.id).filter(r=>r!==t.sourceGroupID);if(0===n.length)return{forwarded:0,skipped:0};try{a=await e(t.postState,t.postState.model,t.postState.owner,"read",{getUserModelAccessDefault:(r,e,o)=>t.graphStore.getUserModelAccessDefault(r,e,o),isMemberOfAnyCircle:(r,e)=>t.p2pStore.isMemberOfAnyCircle(r,e),isMemberOfAnyGroup:(r,e)=>t.p2pStore.isMemberOfAnyGroup(r,e),getModelInterfaces:r=>t.graphStore.getModelInterfaces(r)},t.defaultAccessLevel)}catch(r){return rethrowIfProgrammerError(r),t.logger?.warn("forwarder: resolveAccessRule failed, skipping forward",{docID:t.entry.docID,version:t.entry.version,sourceGroupID:t.sourceGroupID,error:String(r)}),{forwarded:0,skipped:0}}let s=new Set(a.allowedGroups??[]),f=new Set;if(null!=a.allowedCircles&&a.allowedCircles.length>0)try{for(let r of(await t.p2pStore.getCircles(a.allowedCircles)).values())null!=r.group_id&&f.add(r.group_id)}catch(r){rethrowIfProgrammerError(r),t.logger?.warn("forwarder: getCircles failed, skipping allowedCircles",{count:a.allowedCircles.length,error:String(r)})}let d=new Set;if(null!=a.allowedDIDs&&a.allowedDIDs.length>0)try{for(let r of(await t.p2pStore.getGroupsForMembers(a.allowedDIDs)).values())for(let e of r)d.add(e.id)}catch(r){rethrowIfProgrammerError(r),t.logger?.warn("forwarder: getGroupsForMembers failed, skipping allowedDIDs",{count:a.allowedDIDs.length,error:String(r)})}let p=o({resolvedRule:{level:a.level,allowedGroupIDs:s,allowedCircleParentGroupIDs:f,allowedDIDGroupIDs:d},senderGroupIDs:n});if(0===p.length)return{forwarded:0,skipped:0};try{l=await t.p2pStore.getCirclesForMember(t.selfDID)}catch(r){return rethrowIfProgrammerError(r),t.logger?.warn("forwarder: getCirclesForMember failed",{selfDID:t.selfDID,error:String(r)}),{forwarded:0,skipped:0}}let c=new Set(p),g=new Map;for(let r of l){if(!c.has(r.group_id))continue;let e=g.get(r.group_id);null!=e?e.push(r):g.set(r.group_id,[r])}let u=new Set;for(let r of g.values())for(let e of r)for(let r of e.catalog_ids??[])u.add(r);let w=new Map;if(u.size>0)try{w=await t.graphStore.getCatalogs(Array.from(u))}catch(r){rethrowIfProgrammerError(r),t.logger?.warn("forwarder: getCatalogs failed, treating all as missing",{count:u.size,error:String(r)})}let D=0,I=0;for(let e of p)try{let o=g.get(e)??[],a=!1;for(let e of o){for(let o of e.catalog_ids??[]){let e=w.get(o);if(null!=e&&await r(e.filter_criteria,t.postState,t.p2pStore)){a=!0;break}}if(a)break}if(!a){I++;continue}let l={docID:t.entry.docID,modelID:t.entry.modelID,ownerDID:t.postState.owner,sourceGroupID:t.sourceGroupID,candidateGroupID:e,postState:t.postState};if(!await i(l)){I++;continue}if(!await t.p2pStore.recordBroadcast({docID:t.entry.docID,version:t.entry.version,mlsGroupID:e}))continue;let n={type:"mutation:apply",entries:[t.entry],senderPeerDID:t.selfDID};await t.scheduleBroadcast(e,n),D++}catch(r){t.logger?.warn("forwarder: candidate evaluation failed",{docID:t.entry.docID,version:t.entry.version,candidateGroupID:e,sourceGroupID:t.sourceGroupID,error:String(r)})}return{forwarded:D,skipped:I}}
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
+ }