@kubun/plugin-p2p 0.10.0 → 0.11.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 (136) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1319 -1
  3. package/lib/context/hub.js +150 -1
  4. package/lib/context/join.js +80 -1
  5. package/lib/context/sync.js +19 -1
  6. package/lib/context/types.d.ts +12 -3
  7. package/lib/context/types.js +28 -1
  8. package/lib/groups/admin-roster.d.ts +130 -0
  9. package/lib/groups/admin-roster.js +184 -0
  10. package/lib/groups/broadcast-codec.js +6 -1
  11. package/lib/groups/broadcast.d.ts +122 -38
  12. package/lib/groups/broadcast.js +627 -1
  13. package/lib/groups/circle-projection.d.ts +122 -0
  14. package/lib/groups/circle-projection.js +288 -0
  15. package/lib/groups/circle-reducers.d.ts +178 -0
  16. package/lib/groups/circle-reducers.js +198 -0
  17. package/lib/groups/events.d.ts +7 -2
  18. package/lib/groups/events.js +10 -1
  19. package/lib/groups/group-anchor.d.ts +63 -0
  20. package/lib/groups/group-anchor.js +107 -0
  21. package/lib/groups/group-crypto.d.ts +19 -0
  22. package/lib/groups/group-crypto.js +85 -0
  23. package/lib/groups/group-handle-registry.d.ts +69 -2
  24. package/lib/groups/group-handle-registry.js +243 -1
  25. package/lib/groups/group-handlers.d.ts +29 -0
  26. package/lib/groups/group-handlers.js +92 -0
  27. package/lib/groups/group-health-monitor.d.ts +56 -0
  28. package/lib/groups/group-health-monitor.js +139 -0
  29. package/lib/groups/group-mls.d.ts +35 -0
  30. package/lib/groups/group-mls.js +164 -0
  31. package/lib/groups/group-peer-manager.d.ts +108 -0
  32. package/lib/groups/group-peer-manager.js +344 -0
  33. package/lib/groups/group-protocols.d.ts +297 -0
  34. package/lib/groups/group-protocols.js +174 -0
  35. package/lib/groups/invite-payload.d.ts +1 -1
  36. package/lib/groups/invite-payload.js +26 -1
  37. package/lib/groups/join-utils.d.ts +1 -0
  38. package/lib/groups/join-utils.js +104 -1
  39. package/lib/groups/ledger-affected-events.d.ts +15 -0
  40. package/lib/groups/ledger-affected-events.js +96 -0
  41. package/lib/groups/ledger-entry.d.ts +37 -0
  42. package/lib/groups/ledger-entry.js +56 -0
  43. package/lib/groups/ledger-fold.d.ts +56 -0
  44. package/lib/groups/ledger-fold.js +53 -0
  45. package/lib/groups/manager.d.ts +29 -71
  46. package/lib/groups/manager.js +281 -1
  47. package/lib/groups/mls-codec.d.ts +1 -1
  48. package/lib/groups/mls-codec.js +18 -1
  49. package/lib/groups/mls-encryptor.d.ts +2 -2
  50. package/lib/groups/mls-encryptor.js +30 -1
  51. package/lib/groups/mls-group-handle.d.ts +1 -1
  52. package/lib/groups/mls-group-handle.js +10 -1
  53. package/lib/groups/mls-json.js +17 -1
  54. package/lib/groups/mls-receive-errors.d.ts +29 -0
  55. package/lib/groups/mls-receive-errors.js +42 -0
  56. package/lib/groups/mls-state.d.ts +1 -1
  57. package/lib/groups/mls-state.js +56 -1
  58. package/lib/groups/store-received-grant.d.ts +4 -1
  59. package/lib/groups/store-received-grant.js +116 -1
  60. package/lib/groups/store-received-revocation.d.ts +3 -3
  61. package/lib/groups/store-received-revocation.js +82 -1
  62. package/lib/hub/ack-backoff.d.ts +22 -0
  63. package/lib/hub/ack-backoff.js +88 -0
  64. package/lib/hub/did-observing-transport.d.ts +13 -2
  65. package/lib/hub/did-observing-transport.js +126 -1
  66. package/lib/hub/errors.d.ts +4 -8
  67. package/lib/hub/errors.js +33 -1
  68. package/lib/hub/http-client.d.ts +28 -3
  69. package/lib/hub/http-client.js +32 -1
  70. package/lib/hub/hub-like.d.ts +24 -0
  71. package/lib/hub/hub-like.js +535 -0
  72. package/lib/hub/manager.d.ts +5 -4
  73. package/lib/hub/manager.js +198 -1
  74. package/lib/hub/wiring.d.ts +28 -41
  75. package/lib/hub/wiring.js +132 -1
  76. package/lib/index.d.ts +29 -13
  77. package/lib/index.js +392 -1
  78. package/lib/protocol.d.ts +8 -1
  79. package/lib/protocol.js +251 -1
  80. package/lib/schema.d.ts +2 -1
  81. package/lib/schema.js +471 -2
  82. package/lib/sync/authorize.d.ts +25 -0
  83. package/lib/sync/authorize.js +73 -0
  84. package/lib/sync/broadcast-queue.js +183 -1
  85. package/lib/sync/broadcast-sender.js +179 -1
  86. package/lib/sync/catalog-scope.js +68 -1
  87. package/lib/sync/errors.d.ts +12 -0
  88. package/lib/sync/errors.js +19 -0
  89. package/lib/sync/forwarder.js +277 -1
  90. package/lib/sync/handlers.d.ts +3 -22
  91. package/lib/sync/handlers.js +160 -1
  92. package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
  93. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  94. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  95. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  96. package/lib/sync/merkle-apply.d.ts +5 -0
  97. package/lib/sync/merkle-apply.js +194 -1
  98. package/lib/sync/merkle-channel.d.ts +19 -0
  99. package/lib/sync/merkle-channel.js +182 -1
  100. package/lib/sync/merkle-tree.js +120 -1
  101. package/lib/sync/peer-registry.d.ts +8 -1
  102. package/lib/sync/peer-registry.js +107 -1
  103. package/lib/sync/receive-access-gate.js +24 -1
  104. package/lib/sync/scope-resolver.js +41 -1
  105. package/lib/sync/sync-client.d.ts +5 -3
  106. package/lib/sync/sync-client.js +192 -1
  107. package/lib/sync/sync-manager.d.ts +2 -2
  108. package/lib/sync/sync-manager.js +272 -1
  109. package/lib/types.d.ts +41 -0
  110. package/lib/types.js +2 -1
  111. package/lib/util/mutex.js +10 -1
  112. package/lib/utils.d.ts +5 -0
  113. package/lib/utils.js +21 -0
  114. package/package.json +48 -41
  115. package/lib/groups/broadcast-service.d.ts +0 -189
  116. package/lib/groups/broadcast-service.js +0 -1
  117. package/lib/groups/wire-frame.d.ts +0 -32
  118. package/lib/groups/wire-frame.js +0 -1
  119. package/lib/hub/connection-pool.d.ts +0 -43
  120. package/lib/hub/connection-pool.js +0 -1
  121. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  122. package/lib/hub/forward-remote-broadcast.js +0 -1
  123. package/lib/hub/group-channel.d.ts +0 -67
  124. package/lib/hub/group-channel.js +0 -1
  125. package/lib/hub/hub-connection.d.ts +0 -96
  126. package/lib/hub/hub-connection.js +0 -1
  127. package/lib/hub/receive-handler.d.ts +0 -33
  128. package/lib/hub/receive-handler.js +0 -1
  129. package/lib/hub/relay-manager.d.ts +0 -127
  130. package/lib/hub/relay-manager.js +0 -1
  131. package/lib/hub/send-handler.d.ts +0 -40
  132. package/lib/hub/send-handler.js +0 -1
  133. package/lib/hub/tunnel-inbox.d.ts +0 -20
  134. package/lib/hub/tunnel-inbox.js +0 -1
  135. package/lib/hub/wait-for-gate.d.ts +0 -14
  136. package/lib/hub/wait-for-gate.js +0 -1
@@ -1 +1,183 @@
1
- export const DEFAULT_BROADCAST_BATCH_CONFIG={enabled:!0,windowMs:500,maxCount:20,maxBytes:65536};export function createBroadcastQueue(e){let{config:t,scheduleBroadcast:r,logger:n}=e,l=new Map,o=!1;function u(e){let t=l.get(e);if(null==t||0===t.entries.length)return;null!=t.timer&&(clearTimeout(t.timer),t.timer=null);let o=t.entries,u=t.senderPeerDID;l.delete(e);try{r(e,{type:"mutation:apply",entries:o,senderPeerDID:u})}catch(t){n?.warn("broadcast-queue flush failed",{targetGroupID:e,entryCount:o.length,error:String(t)})}}return{enqueue:function(e,i,a){if(o)return void n?.warn("broadcast-queue enqueue after dispose, dropping entry",{targetGroupID:e,docID:i.docID});if(!t.enabled){try{r(e,{type:"mutation:apply",entries:[i],senderPeerDID:a})}catch(t){n?.warn("broadcast-queue pass-through scheduleBroadcast failed",{targetGroupID:e,docID:i.docID,error:String(t)})}return}let s=JSON.stringify(i).length;if(s>=t.maxBytes){let t=l.get(e);null!=t&&t.entries.length>0&&u(e);try{r(e,{type:"mutation:apply",entries:[i],senderPeerDID:a})}catch(t){n?.warn("broadcast-queue oversize-entry scheduleBroadcast failed",{targetGroupID:e,docID:i.docID,error:String(t)})}return}let d=l.get(e);if(null!=d&&d.senderPeerDID!==a&&(u(e),d=void 0),null!=d&&d.bytes+s>t.maxBytes&&(u(e),d=void 0),null==d){l.set(e,{entries:[i],bytes:s,senderPeerDID:a,timer:null});let r=l.get(e);return null!=r&&r.entries.length>=t.maxCount?void u(e):void!function(e){let r=l.get(e);if(null==r||null!=r.timer)return;let n=setTimeout(()=>{u(e)},t.windowMs),o=n.unref;"function"==typeof o&&o.call(n),r.timer=n}(e)}d.entries.push(i),d.bytes+=s,d.entries.length>=t.maxCount&&u(e)},dispose:function(){if(!o)for(let e of(o=!0,Array.from(l.keys())))u(e)}}}
1
+ export const DEFAULT_BROADCAST_BATCH_CONFIG = {
2
+ enabled: true,
3
+ windowMs: 500,
4
+ maxCount: 20,
5
+ maxBytes: 64 * 1024
6
+ };
7
+ /**
8
+ * Build a per-target-group batch queue around a `scheduleBroadcast` hook.
9
+ *
10
+ * The `mutation:apply` broadcast variant carries `entries: Array<...>` —
11
+ * a queue flush emits ONE broadcast with the accumulated entries, not one
12
+ * broadcast per entry.
13
+ *
14
+ * Each enqueued entry's wire size is approximated via `JSON.stringify(entry).length`.
15
+ * The accumulated byte budget tracks per-batch payload size only — message
16
+ * envelope (`type`, `senderPeerDID`) overhead is intentionally ignored to keep
17
+ * accounting cheap and predictable.
18
+ *
19
+ * Sender-DID consistency: the queue tracks the `senderPeerDID` of the first
20
+ * entry in each batch. If a later enqueue arrives with a different sender
21
+ * (defensive — in practice senderPeerDID is constant per peer), the prior
22
+ * batch is flushed and a fresh batch starts. This preserves the invariant
23
+ * that every flushed `mutation:apply` message carries a single coherent
24
+ * `senderPeerDID`.
25
+ */ export function createBroadcastQueue(params) {
26
+ const { config, scheduleBroadcast, logger } = params;
27
+ const queues = new Map();
28
+ let disposed = false;
29
+ function clearTimer(state) {
30
+ if (state.timer != null) {
31
+ clearTimeout(state.timer);
32
+ state.timer = null;
33
+ }
34
+ }
35
+ function flush(targetGroupID) {
36
+ const state = queues.get(targetGroupID);
37
+ if (state == null || state.entries.length === 0) {
38
+ return;
39
+ }
40
+ clearTimer(state);
41
+ const entries = state.entries;
42
+ const senderPeerDID = state.senderPeerDID;
43
+ queues.delete(targetGroupID);
44
+ try {
45
+ scheduleBroadcast(targetGroupID, {
46
+ type: 'mutation:apply',
47
+ entries,
48
+ senderPeerDID
49
+ });
50
+ } catch (error) {
51
+ logger?.warn('broadcast-queue flush failed', {
52
+ targetGroupID,
53
+ entryCount: entries.length,
54
+ error: String(error)
55
+ });
56
+ }
57
+ }
58
+ function startTimer(targetGroupID) {
59
+ const state = queues.get(targetGroupID);
60
+ if (state == null || state.timer != null) {
61
+ return;
62
+ }
63
+ const timer = setTimeout(()=>{
64
+ flush(targetGroupID);
65
+ }, config.windowMs);
66
+ // Don't hold the process open just for a pending broadcast batch.
67
+ // Node returns a Timeout object with `unref()`; browsers return a number.
68
+ const maybeUnref = timer.unref;
69
+ if (typeof maybeUnref === 'function') {
70
+ maybeUnref.call(timer);
71
+ }
72
+ state.timer = timer;
73
+ }
74
+ function appendToFreshBatch(targetGroupID, entry, entryBytes, senderPeerDID) {
75
+ const state = {
76
+ entries: [
77
+ entry
78
+ ],
79
+ bytes: entryBytes,
80
+ senderPeerDID,
81
+ timer: null
82
+ };
83
+ queues.set(targetGroupID, state);
84
+ }
85
+ function enqueue(targetGroupID, entry, senderPeerDID) {
86
+ if (disposed) {
87
+ logger?.warn('broadcast-queue enqueue after dispose, dropping entry', {
88
+ targetGroupID,
89
+ docID: entry.docID
90
+ });
91
+ return;
92
+ }
93
+ // Pass-through mode: emit each entry as its own single-entry broadcast.
94
+ if (!config.enabled) {
95
+ try {
96
+ scheduleBroadcast(targetGroupID, {
97
+ type: 'mutation:apply',
98
+ entries: [
99
+ entry
100
+ ],
101
+ senderPeerDID
102
+ });
103
+ } catch (error) {
104
+ logger?.warn('broadcast-queue pass-through scheduleBroadcast failed', {
105
+ targetGroupID,
106
+ docID: entry.docID,
107
+ error: String(error)
108
+ });
109
+ }
110
+ return;
111
+ }
112
+ const entryBytes = JSON.stringify(entry).length;
113
+ // Oversize lone entry: skip the queue and ship as its own batch immediately.
114
+ // If a batch is already queued for this group, flush it first to preserve
115
+ // ordering (older entries ship before this lone giant).
116
+ if (entryBytes >= config.maxBytes) {
117
+ const existing = queues.get(targetGroupID);
118
+ if (existing != null && existing.entries.length > 0) {
119
+ flush(targetGroupID);
120
+ }
121
+ try {
122
+ scheduleBroadcast(targetGroupID, {
123
+ type: 'mutation:apply',
124
+ entries: [
125
+ entry
126
+ ],
127
+ senderPeerDID
128
+ });
129
+ } catch (error) {
130
+ logger?.warn('broadcast-queue oversize-entry scheduleBroadcast failed', {
131
+ targetGroupID,
132
+ docID: entry.docID,
133
+ error: String(error)
134
+ });
135
+ }
136
+ return;
137
+ }
138
+ let state = queues.get(targetGroupID);
139
+ // Sender mismatch: flush prior batch, start fresh.
140
+ if (state != null && state.senderPeerDID !== senderPeerDID) {
141
+ flush(targetGroupID);
142
+ state = undefined;
143
+ }
144
+ // Byte threshold: would adding this entry overflow? Flush first, start new batch.
145
+ if (state != null && state.bytes + entryBytes > config.maxBytes) {
146
+ flush(targetGroupID);
147
+ state = undefined;
148
+ }
149
+ if (state == null) {
150
+ appendToFreshBatch(targetGroupID, entry, entryBytes, senderPeerDID);
151
+ // Start the window timer for this new batch — only if we won't flush
152
+ // synchronously below for count reasons.
153
+ const fresh = queues.get(targetGroupID);
154
+ if (fresh != null && fresh.entries.length >= config.maxCount) {
155
+ flush(targetGroupID);
156
+ return;
157
+ }
158
+ startTimer(targetGroupID);
159
+ return;
160
+ }
161
+ state.entries.push(entry);
162
+ state.bytes += entryBytes;
163
+ // Count threshold: flush synchronously without waiting for the timer.
164
+ if (state.entries.length >= config.maxCount) {
165
+ flush(targetGroupID);
166
+ }
167
+ }
168
+ function dispose() {
169
+ if (disposed) {
170
+ return;
171
+ }
172
+ disposed = true;
173
+ // Snapshot keys — flush mutates the map.
174
+ const targets = Array.from(queues.keys());
175
+ for (const targetGroupID of targets){
176
+ flush(targetGroupID);
177
+ }
178
+ }
179
+ return {
180
+ enqueue,
181
+ dispose
182
+ };
183
+ }
@@ -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 +1,68 @@
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
8
+ * - owners: union of all owner filters (explicit + circle members)
9
+ * - requiredClusterIDs: cluster IDs for all required models
10
+ * - missingClusterIDs: cluster IDs for models not in knownModelIDs
11
+ */ export async function resolveCatalogSyncScopes(db, catalogIDs, knownModelIDs) {
12
+ const [graphStore, p2pStore] = await Promise.all([
13
+ getGraphStore(db),
14
+ getP2PStore(db)
15
+ ]);
16
+ const allModelIDs = new Set();
17
+ const allOwners = new Set();
18
+ let hasOwnerFilter = false;
19
+ for (const catalogID of catalogIDs){
20
+ const scope = await graphStore.resolveCatalogScope(catalogID);
21
+ if (scope.models != null) {
22
+ for (const m of scope.models){
23
+ allModelIDs.add(m);
24
+ }
25
+ }
26
+ if (scope.owners != null) {
27
+ hasOwnerFilter = true;
28
+ for (const o of scope.owners){
29
+ allOwners.add(o);
30
+ }
31
+ }
32
+ // Resolve circle-based owners via p2p store
33
+ const catalog = await graphStore.getCatalog(catalogID);
34
+ if (catalog != null) {
35
+ const criteria = catalog.filter_criteria;
36
+ if (criteria.circles != null && criteria.circles.length > 0) {
37
+ hasOwnerFilter = true;
38
+ for (const circleID of criteria.circles){
39
+ const members = await p2pStore.listCircleMembers(circleID);
40
+ for (const member of members){
41
+ allOwners.add(member.member_did);
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }
47
+ const modelIDs = Array.from(allModelIDs);
48
+ const owners = hasOwnerFilter ? Array.from(allOwners) : undefined;
49
+ // Resolve clusters for all models in a single pass
50
+ const knownSet = new Set(knownModelIDs ?? []);
51
+ const requiredClusterIDs = new Set();
52
+ const missingClusterIDs = new Set();
53
+ await Promise.all(modelIDs.map(async (modelID)=>{
54
+ const clusterID = await graphStore.getClusterForModel(modelID);
55
+ if (clusterID != null) {
56
+ requiredClusterIDs.add(clusterID);
57
+ if (!knownSet.has(modelID)) {
58
+ missingClusterIDs.add(clusterID);
59
+ }
60
+ }
61
+ }));
62
+ return {
63
+ modelIDs,
64
+ owners,
65
+ requiredClusterIDs: Array.from(requiredClusterIDs),
66
+ missingClusterIDs: Array.from(missingClusterIDs)
67
+ };
68
+ }
@@ -0,0 +1,12 @@
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
+ */
7
+ export declare class SyncAccessDeniedError extends Error {
8
+ #private;
9
+ constructor(reason: string, message?: string);
10
+ get code(): 'KB08';
11
+ get reason(): string;
12
+ }
@@ -0,0 +1,19 @@
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
+ }