mindoodb 0.0.84 → 0.0.86

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 (264) hide show
  1. package/README-server.md +1 -0
  2. package/README.md +6 -4
  3. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts +15 -0
  4. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
  5. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +75 -1
  6. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
  7. package/dist/appendonlystores/network/HttpTransport.d.ts +9 -1
  8. package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
  9. package/dist/appendonlystores/network/HttpTransport.js +38 -10
  10. package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
  11. package/dist/cloudflare/DurableObjectSql.d.ts +56 -0
  12. package/dist/cloudflare/DurableObjectSql.d.ts.map +1 -0
  13. package/dist/cloudflare/DurableObjectSql.js +180 -0
  14. package/dist/cloudflare/DurableObjectSql.js.map +1 -0
  15. package/dist/cloudflare/MindooDatabaseObject.d.ts +86 -0
  16. package/dist/cloudflare/MindooDatabaseObject.d.ts.map +1 -0
  17. package/dist/cloudflare/MindooDatabaseObject.js +614 -0
  18. package/dist/cloudflare/MindooDatabaseObject.js.map +1 -0
  19. package/dist/cloudflare/MindooServerObject.d.ts +63 -0
  20. package/dist/cloudflare/MindooServerObject.d.ts.map +1 -0
  21. package/dist/cloudflare/MindooServerObject.js +521 -0
  22. package/dist/cloudflare/MindooServerObject.js.map +1 -0
  23. package/dist/cloudflare/WorkersCryptoAdapter.d.ts +7 -0
  24. package/dist/cloudflare/WorkersCryptoAdapter.d.ts.map +1 -0
  25. package/dist/cloudflare/WorkersCryptoAdapter.js +14 -0
  26. package/dist/cloudflare/WorkersCryptoAdapter.js.map +1 -0
  27. package/dist/cloudflare/host.d.ts +49 -0
  28. package/dist/cloudflare/host.d.ts.map +1 -0
  29. package/dist/cloudflare/host.js +104 -0
  30. package/dist/cloudflare/host.js.map +1 -0
  31. package/dist/cloudflare/http.d.ts +91 -0
  32. package/dist/cloudflare/http.d.ts.map +1 -0
  33. package/dist/cloudflare/http.js +266 -0
  34. package/dist/cloudflare/http.js.map +1 -0
  35. package/dist/cloudflare/index.d.ts +12 -0
  36. package/dist/cloudflare/index.d.ts.map +1 -0
  37. package/dist/cloudflare/index.js +20 -0
  38. package/dist/cloudflare/index.js.map +1 -0
  39. package/dist/cloudflare/storeRpc.d.ts +22 -0
  40. package/dist/cloudflare/storeRpc.d.ts.map +1 -0
  41. package/dist/cloudflare/storeRpc.js +85 -0
  42. package/dist/cloudflare/storeRpc.js.map +1 -0
  43. package/dist/cloudflare/types.d.ts +101 -0
  44. package/dist/cloudflare/types.d.ts.map +1 -0
  45. package/dist/cloudflare/types.js +10 -0
  46. package/dist/cloudflare/types.js.map +1 -0
  47. package/dist/cloudflare/worker.d.ts +25 -0
  48. package/dist/cloudflare/worker.d.ts.map +1 -0
  49. package/dist/cloudflare/worker.js +170 -0
  50. package/dist/cloudflare/worker.js.map +1 -0
  51. package/dist/core/BaseMindooDB.d.ts +43 -1
  52. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  53. package/dist/core/BaseMindooDB.js +209 -87
  54. package/dist/core/BaseMindooDB.js.map +1 -1
  55. package/dist/core/BaseMindooTenant.d.ts +14 -0
  56. package/dist/core/BaseMindooTenant.d.ts.map +1 -1
  57. package/dist/core/BaseMindooTenant.js +46 -0
  58. package/dist/core/BaseMindooTenant.js.map +1 -1
  59. package/dist/core/BaseMindooTenantDirectory.d.ts +10 -0
  60. package/dist/core/BaseMindooTenantDirectory.d.ts.map +1 -1
  61. package/dist/core/BaseMindooTenantDirectory.js +79 -8
  62. package/dist/core/BaseMindooTenantDirectory.js.map +1 -1
  63. package/dist/core/ChangeDocDraft.d.ts +133 -0
  64. package/dist/core/ChangeDocDraft.d.ts.map +1 -0
  65. package/dist/core/ChangeDocDraft.js +492 -0
  66. package/dist/core/ChangeDocDraft.js.map +1 -0
  67. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts.map +1 -1
  68. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js +1 -0
  69. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js.map +1 -1
  70. package/dist/core/appendonlystores/network/AuthenticationService.d.ts +8 -0
  71. package/dist/core/appendonlystores/network/AuthenticationService.d.ts.map +1 -1
  72. package/dist/core/appendonlystores/network/AuthenticationService.js +19 -0
  73. package/dist/core/appendonlystores/network/AuthenticationService.js.map +1 -1
  74. package/dist/core/appendonlystores/network/IrohStreamIO.d.ts.map +1 -1
  75. package/dist/core/appendonlystores/network/IrohStreamIO.js +3 -23
  76. package/dist/core/appendonlystores/network/IrohStreamIO.js.map +1 -1
  77. package/dist/core/appendonlystores/network/NetworkTransport.d.ts +7 -1
  78. package/dist/core/appendonlystores/network/NetworkTransport.d.ts.map +1 -1
  79. package/dist/core/appendonlystores/network/types.d.ts +23 -0
  80. package/dist/core/appendonlystores/network/types.d.ts.map +1 -1
  81. package/dist/core/appendonlystores/network/types.js.map +1 -1
  82. package/dist/core/appendonlystores/rpc/storeRpcCodec.d.ts.map +1 -1
  83. package/dist/core/appendonlystores/rpc/storeRpcCodec.js +3 -23
  84. package/dist/core/appendonlystores/rpc/storeRpcCodec.js.map +1 -1
  85. package/dist/core/appendonlystores/sqlite/SqliteContentAddressedStore.d.ts.map +1 -1
  86. package/dist/core/appendonlystores/sqlite/SqliteContentAddressedStore.js +21 -30
  87. package/dist/core/appendonlystores/sqlite/SqliteContentAddressedStore.js.map +1 -1
  88. package/dist/core/appendonlystores/sqlite/SqliteDriver.d.ts +37 -1
  89. package/dist/core/appendonlystores/sqlite/SqliteDriver.d.ts.map +1 -1
  90. package/dist/core/appendonlystores/sqlite/SqliteDriver.js +99 -40
  91. package/dist/core/appendonlystores/sqlite/SqliteDriver.js.map +1 -1
  92. package/dist/core/appendonlystores/syncStores.d.ts.map +1 -1
  93. package/dist/core/appendonlystores/syncStores.js +253 -168
  94. package/dist/core/appendonlystores/syncStores.js.map +1 -1
  95. package/dist/core/appendonlystores/types.d.ts +8 -0
  96. package/dist/core/appendonlystores/types.d.ts.map +1 -1
  97. package/dist/core/appendonlystores/types.js.map +1 -1
  98. package/dist/core/expressions/evaluateExpression.d.ts +8 -0
  99. package/dist/core/expressions/evaluateExpression.d.ts.map +1 -1
  100. package/dist/core/expressions/evaluateExpression.js +29 -4
  101. package/dist/core/expressions/evaluateExpression.js.map +1 -1
  102. package/dist/core/expressions/metadata.js +4 -4
  103. package/dist/core/expressions/metadata.js.map +1 -1
  104. package/dist/core/types.d.ts +61 -2
  105. package/dist/core/types.d.ts.map +1 -1
  106. package/dist/core/types.js.map +1 -1
  107. package/dist/core/uri/MindooURI.d.ts.map +1 -1
  108. package/dist/core/uri/MindooURI.js +3 -24
  109. package/dist/core/uri/MindooURI.js.map +1 -1
  110. package/dist/core/utils/base64.d.ts +17 -0
  111. package/dist/core/utils/base64.d.ts.map +1 -0
  112. package/dist/core/utils/base64.js +54 -0
  113. package/dist/core/utils/base64.js.map +1 -0
  114. package/dist/core/utils/idGeneration.d.ts.map +1 -1
  115. package/dist/core/utils/idGeneration.js +14 -3
  116. package/dist/core/utils/idGeneration.js.map +1 -1
  117. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts.map +1 -1
  118. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js +1 -0
  119. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js.map +1 -1
  120. package/dist/node/server/FsServerDataStore.d.ts +23 -0
  121. package/dist/node/server/FsServerDataStore.d.ts.map +1 -0
  122. package/dist/node/server/FsServerDataStore.js +59 -0
  123. package/dist/node/server/FsServerDataStore.js.map +1 -0
  124. package/dist/node/server/IrohServerEndpoint.d.ts +1 -1
  125. package/dist/node/server/IrohServerEndpoint.d.ts.map +1 -1
  126. package/dist/node/server/IrohServerRpc.d.ts +5 -5
  127. package/dist/node/server/IrohServerRpc.d.ts.map +1 -1
  128. package/dist/node/server/IrohServerRpc.js +1 -1
  129. package/dist/node/server/IrohServerRpc.js.map +1 -1
  130. package/dist/node/server/MindooDBServer.d.ts +29 -150
  131. package/dist/node/server/MindooDBServer.d.ts.map +1 -1
  132. package/dist/node/server/MindooDBServer.js +160 -1568
  133. package/dist/node/server/MindooDBServer.js.map +1 -1
  134. package/dist/node/server/PurgedDocRegistry.d.ts +4 -27
  135. package/dist/node/server/PurgedDocRegistry.d.ts.map +1 -1
  136. package/dist/node/server/PurgedDocRegistry.js +6 -83
  137. package/dist/node/server/PurgedDocRegistry.js.map +1 -1
  138. package/dist/node/server/StoreFactory.d.ts +1 -1
  139. package/dist/node/server/StoreFactory.d.ts.map +1 -1
  140. package/dist/node/server/TenantManager.d.ts +16 -284
  141. package/dist/node/server/TenantManager.d.ts.map +1 -1
  142. package/dist/node/server/TenantManager.js +35 -1168
  143. package/dist/node/server/TenantManager.js.map +1 -1
  144. package/dist/node/server/add-to-network.js +1 -1
  145. package/dist/node/server/add-to-network.js.map +1 -1
  146. package/dist/node/server/compressionMiddleware.d.ts +14 -0
  147. package/dist/node/server/compressionMiddleware.d.ts.map +1 -1
  148. package/dist/node/server/compressionMiddleware.js +46 -3
  149. package/dist/node/server/compressionMiddleware.js.map +1 -1
  150. package/dist/node/server/config.d.ts +11 -15
  151. package/dist/node/server/config.d.ts.map +1 -1
  152. package/dist/node/server/config.js +30 -245
  153. package/dist/node/server/config.js.map +1 -1
  154. package/dist/node/server/expressAdapter.d.ts +23 -0
  155. package/dist/node/server/expressAdapter.d.ts.map +1 -0
  156. package/dist/node/server/expressAdapter.js +108 -0
  157. package/dist/node/server/expressAdapter.js.map +1 -0
  158. package/dist/node/server/nativeIrohStreamIO.d.ts +1 -1
  159. package/dist/node/server/nativeIrohStreamIO.d.ts.map +1 -1
  160. package/dist/node/server/peer/FileClusterAuditStorage.d.ts +16 -0
  161. package/dist/node/server/peer/FileClusterAuditStorage.d.ts.map +1 -0
  162. package/dist/node/server/peer/FileClusterAuditStorage.js +39 -0
  163. package/dist/node/server/peer/FileClusterAuditStorage.js.map +1 -0
  164. package/dist/node/server/peer/IrohPeerLink.d.ts +1 -1
  165. package/dist/node/server/peer/IrohPeerLink.d.ts.map +1 -1
  166. package/dist/node/server/peer/routes.d.ts +10 -44
  167. package/dist/node/server/peer/routes.d.ts.map +1 -1
  168. package/dist/node/server/peer/routes.js +33 -275
  169. package/dist/node/server/peer/routes.js.map +1 -1
  170. package/dist/node/server/resolveServerPassword.js +1 -1
  171. package/dist/node/server/resolveServerPassword.js.map +1 -1
  172. package/dist/node/server/server.js +1 -1
  173. package/dist/node/server/server.js.map +1 -1
  174. package/dist/node/server/serverinit.js +1 -1
  175. package/dist/node/server/serverinit.js.map +1 -1
  176. package/dist/server/CapabilityMatcher.d.ts +60 -0
  177. package/dist/server/CapabilityMatcher.d.ts.map +1 -0
  178. package/dist/server/CapabilityMatcher.js +138 -0
  179. package/dist/server/CapabilityMatcher.js.map +1 -0
  180. package/dist/server/DeviceDiscoveryService.d.ts +36 -0
  181. package/dist/server/DeviceDiscoveryService.d.ts.map +1 -0
  182. package/dist/server/DeviceDiscoveryService.js +165 -0
  183. package/dist/server/DeviceDiscoveryService.js.map +1 -0
  184. package/dist/server/PurgedDocRegistry.d.ts +35 -0
  185. package/dist/server/PurgedDocRegistry.d.ts.map +1 -0
  186. package/dist/server/PurgedDocRegistry.js +90 -0
  187. package/dist/server/PurgedDocRegistry.js.map +1 -0
  188. package/dist/server/ServerDataStore.d.ts +67 -0
  189. package/dist/server/ServerDataStore.d.ts.map +1 -0
  190. package/dist/server/ServerDataStore.js +88 -0
  191. package/dist/server/ServerDataStore.js.map +1 -0
  192. package/dist/server/SyncEventBus.d.ts +39 -0
  193. package/dist/server/SyncEventBus.d.ts.map +1 -0
  194. package/dist/server/SyncEventBus.js +41 -0
  195. package/dist/server/SyncEventBus.js.map +1 -0
  196. package/dist/server/SystemAdminAuth.d.ts +82 -0
  197. package/dist/server/SystemAdminAuth.d.ts.map +1 -0
  198. package/dist/server/SystemAdminAuth.js +228 -0
  199. package/dist/server/SystemAdminAuth.js.map +1 -0
  200. package/dist/server/TenantManager.d.ts +330 -0
  201. package/dist/server/TenantManager.d.ts.map +1 -0
  202. package/dist/server/TenantManager.js +1186 -0
  203. package/dist/server/TenantManager.js.map +1 -0
  204. package/dist/server/http/MindooDBServerCore.d.ts +313 -0
  205. package/dist/server/http/MindooDBServerCore.d.ts.map +1 -0
  206. package/dist/server/http/MindooDBServerCore.js +1403 -0
  207. package/dist/server/http/MindooDBServerCore.js.map +1 -0
  208. package/dist/server/http/types.d.ts +89 -0
  209. package/dist/server/http/types.d.ts.map +1 -0
  210. package/dist/server/http/types.js +29 -0
  211. package/dist/server/http/types.js.map +1 -0
  212. package/dist/server/index.d.ts +32 -0
  213. package/dist/server/index.d.ts.map +1 -0
  214. package/dist/server/index.js +76 -0
  215. package/dist/server/index.js.map +1 -0
  216. package/dist/server/peer/ClusterAuditLog.d.ts +71 -0
  217. package/dist/server/peer/ClusterAuditLog.d.ts.map +1 -0
  218. package/dist/server/peer/ClusterAuditLog.js +81 -0
  219. package/dist/server/peer/ClusterAuditLog.js.map +1 -0
  220. package/dist/server/peer/ClusterJobRunner.d.ts +43 -0
  221. package/dist/server/peer/ClusterJobRunner.d.ts.map +1 -0
  222. package/dist/server/peer/ClusterJobRunner.js +119 -0
  223. package/dist/server/peer/ClusterJobRunner.js.map +1 -0
  224. package/dist/server/peer/ClusterManager.d.ts +149 -0
  225. package/dist/server/peer/ClusterManager.d.ts.map +1 -0
  226. package/dist/server/peer/ClusterManager.js +292 -0
  227. package/dist/server/peer/ClusterManager.js.map +1 -0
  228. package/dist/server/peer/PeerAuthService.d.ts +69 -0
  229. package/dist/server/peer/PeerAuthService.d.ts.map +1 -0
  230. package/dist/server/peer/PeerAuthService.js +208 -0
  231. package/dist/server/peer/PeerAuthService.js.map +1 -0
  232. package/dist/server/peer/PeerReplicator.d.ts +303 -0
  233. package/dist/server/peer/PeerReplicator.d.ts.map +1 -0
  234. package/dist/server/peer/PeerReplicator.js +1071 -0
  235. package/dist/server/peer/PeerReplicator.js.map +1 -0
  236. package/dist/server/peer/clusterHttp.d.ts +113 -0
  237. package/dist/server/peer/clusterHttp.d.ts.map +1 -0
  238. package/dist/server/peer/clusterHttp.js +290 -0
  239. package/dist/server/peer/clusterHttp.js.map +1 -0
  240. package/dist/server/peer/types.d.ts +244 -0
  241. package/dist/server/peer/types.d.ts.map +1 -0
  242. package/dist/server/peer/types.js +189 -0
  243. package/dist/server/peer/types.js.map +1 -0
  244. package/dist/server/rateLimits.d.ts +119 -0
  245. package/dist/server/rateLimits.d.ts.map +1 -0
  246. package/dist/server/rateLimits.js +159 -0
  247. package/dist/server/rateLimits.js.map +1 -0
  248. package/dist/server/serverConfig.d.ts +37 -0
  249. package/dist/server/serverConfig.d.ts.map +1 -0
  250. package/dist/server/serverConfig.js +251 -0
  251. package/dist/server/serverConfig.js.map +1 -0
  252. package/dist/server/timestampProxy.d.ts +129 -0
  253. package/dist/server/timestampProxy.d.ts.map +1 -0
  254. package/dist/server/timestampProxy.js +361 -0
  255. package/dist/server/timestampProxy.js.map +1 -0
  256. package/dist/server/types.d.ts +267 -0
  257. package/dist/server/types.d.ts.map +1 -0
  258. package/dist/server/types.js +56 -0
  259. package/dist/server/types.js.map +1 -0
  260. package/dist/server/validation.d.ts +43 -0
  261. package/dist/server/validation.d.ts.map +1 -0
  262. package/dist/server/validation.js +104 -0
  263. package/dist/server/validation.js.map +1 -0
  264. package/package.json +9 -1
@@ -0,0 +1,1071 @@
1
+ "use strict";
2
+ /**
3
+ * Server-to-server replication with one trusted peer.
4
+ *
5
+ * One replicator instance per entry in `trusted-servers.json` that carries a
6
+ * `url`. It owns everything about that link: the peer session, the set of
7
+ * tenants both sides hold, the mirror loop over every database, the live push
8
+ * out of {@link SyncEventBus}, and the SSE subscription that pulls the other
9
+ * direction.
10
+ *
11
+ * ## What it replicates
12
+ *
13
+ * Everything both servers already have in common, and nothing else. The tenant
14
+ * set is intersected via a Bloom summary (see {@link refreshIntersection}), so
15
+ * a server never learns which tenants it does *not* host on the other side. For
16
+ * each shared tenant it mirrors **all** databases — the union of the local and
17
+ * remote database lists — in both stores (`docs` and `attachments`).
18
+ *
19
+ * ## Convergence invariants
20
+ *
21
+ * 1. **Directory first.** Databases are ordered by
22
+ * {@link orderDatabasesForReplication}: `directory`, then `userdirectory`,
23
+ * then the rest. An application entry signed by a key the peer does not
24
+ * trust yet is rejected per entry, so the grants have to land first.
25
+ * 2. **Cursor holds on rejection.** Unlike a client, a peer must not skip past
26
+ * a rejected entry — that is permanent divergence between two servers that
27
+ * are supposed to be identical. The peer path passes
28
+ * `rejectionPolicy: "hold"`, which clamps the cursor so the next run
29
+ * re-offers the entry once the missing trust has replicated.
30
+ * 3. **Receipts are preserved, not re-stamped.** Enforced on the receiving side
31
+ * in `ServerNetworkContentAddressedStore`; this side just transports.
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.PeerReplicator = void 0;
35
+ exports.rollUpHealth = rollUpHealth;
36
+ const HttpTransport_1 = require("../../appendonlystores/network/HttpTransport");
37
+ const ClientNetworkContentAddressedStore_1 = require("../../appendonlystores/network/ClientNetworkContentAddressedStore");
38
+ const IrohNetworkTransport_1 = require("../../core/appendonlystores/network/IrohNetworkTransport");
39
+ const irohLocator_1 = require("../../core/appendonlystores/network/irohLocator");
40
+ const types_1 = require("../../core/types");
41
+ const types_2 = require("../../core/appendonlystores/network/types");
42
+ const logging_1 = require("../../core/logging");
43
+ const bloom_1 = require("../../core/appendonlystores/bloom");
44
+ const base64_1 = require("../../core/utils/base64");
45
+ const types_3 = require("../../core/appendonlystores/types");
46
+ const purgedDocumentGuard_1 = require("../../core/appendonlystores/purgedDocumentGuard");
47
+ const syncStores_1 = require("../../core/appendonlystores/syncStores");
48
+ const types_4 = require("./types");
49
+ /** Token lifetime is an hour on the server; refresh well before that. */
50
+ const PEER_TOKEN_REFRESH_MS = 45 * 60 * 1000;
51
+ const RECONNECT_BASE_MS = 1000;
52
+ const RECONNECT_MAX_MS = 60000;
53
+ const IDLE_SYNC_INTERVAL_MS = 5 * 60 * 1000;
54
+ /** Live pushes are coalesced over this window so a burst becomes one run. */
55
+ const PUSH_COALESCE_MS = 250;
56
+ class PeerReplicator {
57
+ constructor(peer, host) {
58
+ this.running = false;
59
+ this.paused = false;
60
+ this.sessionState = "disabled";
61
+ this.eventsConnected = false;
62
+ this.token = null;
63
+ this.tokenIssuedAt = 0;
64
+ this.tokenPromise = null;
65
+ this.intersection = [];
66
+ /** Tenant a run that hit its deadline did not finish; the next run starts there. */
67
+ this.resumeTenantId = null;
68
+ this.intersectionAt = null;
69
+ this.tenants = new Map();
70
+ this.transferredEntries = 0;
71
+ this.transferredBytes = 0;
72
+ this.syncInFlight = null;
73
+ this.pendingScopes = new Set();
74
+ this.pushTimer = null;
75
+ this.idleTimer = null;
76
+ this.reconnectTimer = null;
77
+ this.reconnectAttempt = 0;
78
+ this.sseAbort = null;
79
+ this.irohLink = null;
80
+ this.unsubscribeBus = null;
81
+ this.lastError = null;
82
+ this.lastErrorClass = null;
83
+ this.lastErrorAt = null;
84
+ this.lastSyncAt = null;
85
+ /**
86
+ * Scan cursors, per (source, target) store pair.
87
+ *
88
+ * In-process only, deliberately: a restarted server pays one metadata scan
89
+ * per pair and then re-establishes the fast path from the store head. Cheap
90
+ * enough, and it avoids a second on-disk format to keep consistent with the
91
+ * stores it describes.
92
+ */
93
+ this.cursors = new Map();
94
+ this.peer = peer;
95
+ this.host = host;
96
+ const settings = (0, types_4.normalizePeerSettings)(peer);
97
+ this.direction = settings.direction;
98
+ this.role = settings.role;
99
+ this.attachments = settings.attachments;
100
+ this.logger =
101
+ host.logger?.createChild(`Peer:${peer.name}`) ??
102
+ new logging_1.MindooLogger((0, logging_1.getDefaultLogLevel)(), `Peer:${peer.name}`, true);
103
+ }
104
+ get name() {
105
+ return this.peer.name;
106
+ }
107
+ get url() {
108
+ return this.peer.url;
109
+ }
110
+ /** Replace the peer config in place (peer CRUD) without dropping cursors. */
111
+ updatePeer(peer) {
112
+ this.peer = peer;
113
+ const settings = (0, types_4.normalizePeerSettings)(peer);
114
+ this.direction = settings.direction;
115
+ this.role = settings.role;
116
+ this.attachments = settings.attachments;
117
+ // Identity or endpoint may have changed; force a fresh session.
118
+ this.token = null;
119
+ this.tokenIssuedAt = 0;
120
+ void this.irohLink?.close();
121
+ this.irohLink = null;
122
+ }
123
+ /** Roles and endpoint, for `GET /system/cluster/topology`. */
124
+ topologyEntry() {
125
+ return {
126
+ name: this.peer.name,
127
+ url: this.peer.url ?? null,
128
+ direction: this.direction,
129
+ role: this.role,
130
+ attachments: this.attachments,
131
+ sessionActive: this.running && !this.paused && this.sessionState === "connected",
132
+ };
133
+ }
134
+ start() {
135
+ if (this.running)
136
+ return;
137
+ if (!this.peer.url) {
138
+ this.logger.debug(`No url configured, replication disabled`);
139
+ return;
140
+ }
141
+ if (this.direction === "disabled") {
142
+ this.sessionState = "disabled";
143
+ return;
144
+ }
145
+ if (!(0, types_4.shouldOpenSessionTo)(this.host.localRole, this.role)) {
146
+ // Spoke to spoke: the hub already carries this traffic. The trust entry
147
+ // stays so the peer can still authenticate inbound.
148
+ this.sessionState = "disabled";
149
+ return;
150
+ }
151
+ this.running = true;
152
+ this.sessionState = "connecting";
153
+ this.unsubscribeBus = this.host.eventBus.subscribe((event) => {
154
+ this.onLocalChange(event);
155
+ });
156
+ this.idleTimer = setInterval(() => {
157
+ void this.syncNow().catch(() => {
158
+ // Errors are already recorded on the status snapshot.
159
+ });
160
+ }, IDLE_SYNC_INTERVAL_MS);
161
+ this.idleTimer.unref?.();
162
+ void this.bootstrap();
163
+ }
164
+ async stop() {
165
+ this.running = false;
166
+ this.unsubscribeBus?.();
167
+ this.unsubscribeBus = null;
168
+ if (this.pushTimer)
169
+ clearTimeout(this.pushTimer);
170
+ if (this.idleTimer)
171
+ clearInterval(this.idleTimer);
172
+ if (this.reconnectTimer)
173
+ clearTimeout(this.reconnectTimer);
174
+ this.pushTimer = null;
175
+ this.idleTimer = null;
176
+ this.reconnectTimer = null;
177
+ this.sseAbort?.abort();
178
+ this.sseAbort = null;
179
+ this.eventsConnected = false;
180
+ void this.irohLink?.close();
181
+ this.irohLink = null;
182
+ this.sessionState = "disabled";
183
+ try {
184
+ await this.syncInFlight;
185
+ }
186
+ catch {
187
+ // A sync failing during shutdown is not interesting.
188
+ }
189
+ }
190
+ pause() {
191
+ this.paused = true;
192
+ this.sessionState = "paused";
193
+ this.sseAbort?.abort();
194
+ this.sseAbort = null;
195
+ this.eventsConnected = false;
196
+ }
197
+ resume() {
198
+ if (!this.paused)
199
+ return;
200
+ this.paused = false;
201
+ this.sessionState = "connecting";
202
+ this.reconnectAttempt = 0;
203
+ void this.bootstrap();
204
+ }
205
+ async bootstrap() {
206
+ if (!this.running || this.paused)
207
+ return;
208
+ try {
209
+ await this.refreshIntersection();
210
+ await this.syncNow();
211
+ this.startEventStream();
212
+ this.reconnectAttempt = 0;
213
+ }
214
+ catch (error) {
215
+ this.recordError(error);
216
+ this.scheduleReconnect();
217
+ }
218
+ }
219
+ scheduleReconnect() {
220
+ if (!this.running || this.paused || this.reconnectTimer)
221
+ return;
222
+ const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(this.reconnectAttempt, 6));
223
+ this.reconnectAttempt += 1;
224
+ this.sessionState = "backoff";
225
+ this.reconnectTimer = setTimeout(() => {
226
+ this.reconnectTimer = null;
227
+ void this.bootstrap();
228
+ }, delay);
229
+ this.reconnectTimer.unref?.();
230
+ }
231
+ // ---------------------------------------------------------------- session
232
+ async ensureToken() {
233
+ const fresh = this.token && Date.now() - this.tokenIssuedAt < PEER_TOKEN_REFRESH_MS;
234
+ if (fresh)
235
+ return this.token;
236
+ if (this.tokenPromise)
237
+ return this.tokenPromise;
238
+ this.tokenPromise = this.authenticate().finally(() => {
239
+ this.tokenPromise = null;
240
+ });
241
+ return this.tokenPromise;
242
+ }
243
+ async authenticate() {
244
+ const signingKey = await this.host.getSigningKey();
245
+ if (!signingKey) {
246
+ throw new Error("Server identity is locked; cannot authenticate with peer");
247
+ }
248
+ this.sessionState = "connecting";
249
+ // The peer looks us up by signing key, not by name: the key is the identity
250
+ // in `trusted-servers.json`, the name is only a label.
251
+ const challengeResponse = await this.peerFetch("/system/peer/challenge", {
252
+ method: "POST",
253
+ body: { publicsignkey: this.host.localSigningPublicKey },
254
+ authenticated: false,
255
+ });
256
+ const challenge = challengeResponse.challenge;
257
+ if (!challenge) {
258
+ throw new Error("Peer did not return a challenge");
259
+ }
260
+ const subtle = this.host.cryptoAdapter.getSubtle();
261
+ const signature = await subtle.sign({ name: "Ed25519" }, signingKey, new TextEncoder().encode(challenge));
262
+ const authResponse = (await this.peerFetch("/system/peer/authenticate", {
263
+ method: "POST",
264
+ body: {
265
+ challenge,
266
+ signature: (0, base64_1.bytesToBase64)(new Uint8Array(signature)),
267
+ },
268
+ authenticated: false,
269
+ }));
270
+ if (!authResponse.success || !authResponse.token) {
271
+ throw new Error(authResponse.error || "Peer authentication failed");
272
+ }
273
+ this.token = authResponse.token;
274
+ this.tokenIssuedAt = Date.now();
275
+ this.sessionState = "connected";
276
+ return this.token;
277
+ }
278
+ isIrohPeer() {
279
+ return typeof this.peer.url === "string" && (0, irohLocator_1.isIrohLocator)(this.peer.url);
280
+ }
281
+ async getIrohLink() {
282
+ if (this.irohLink) {
283
+ return this.irohLink;
284
+ }
285
+ const io = this.host.getIrohStreamIO?.() ?? null;
286
+ if (!io) {
287
+ throw new Error(`Iroh peer ${this.peer.name} requires iroh.enabled and a ready Iroh endpoint`);
288
+ }
289
+ const parsed = (0, irohLocator_1.parseIrohLocator)(this.peer.url);
290
+ if (!parsed.ticket) {
291
+ throw new Error(`Iroh peer ${this.peer.name} url is an endpoint id only; store a full ticket to dial`);
292
+ }
293
+ if (!this.host.openIrohLink) {
294
+ throw new Error(`Iroh peer ${this.peer.name} cannot be dialled: Iroh is not available on this host`);
295
+ }
296
+ this.irohLink = this.host.openIrohLink(io, parsed.ticket);
297
+ return this.irohLink;
298
+ }
299
+ async peerFetch(path, init = {}) {
300
+ const base = this.peer.url;
301
+ if (!base)
302
+ throw new Error(`Peer ${this.peer.name} has no url`);
303
+ if (this.isIrohPeer()) {
304
+ return this.irohPeerFetch(path, init);
305
+ }
306
+ const headers = { "Content-Type": "application/json" };
307
+ if (init.authenticated !== false) {
308
+ headers.Authorization = `Bearer ${await this.ensureToken()}`;
309
+ }
310
+ const response = await fetch(`${base.replace(/\/$/, "")}${path}`, {
311
+ method: init.method ?? "GET",
312
+ headers,
313
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
314
+ signal: init.signal,
315
+ });
316
+ if (!response.ok) {
317
+ const text = await response.text().catch(() => "");
318
+ if (response.status === 401 || response.status === 403) {
319
+ // Force a re-handshake on the next call: the peer may have rotated its
320
+ // trusted-servers entry or restarted with a new challenge store.
321
+ this.token = null;
322
+ }
323
+ throw new Error(`Peer ${this.peer.name} ${path} failed (${response.status}): ${text}`);
324
+ }
325
+ return response.json();
326
+ }
327
+ async irohPeerFetch(path, init) {
328
+ const link = await this.getIrohLink();
329
+ const body = (init.body ?? {});
330
+ try {
331
+ if (path === "/system/peer/challenge") {
332
+ return await link.rpc("peer.challenge", [body.publicsignkey]);
333
+ }
334
+ if (path === "/system/peer/authenticate") {
335
+ const signature = body.signature;
336
+ const bytes = signature instanceof Uint8Array
337
+ ? signature
338
+ : (0, base64_1.base64ToBytes)(String(signature ?? ""));
339
+ return await link.rpc("peer.authenticate", [body.challenge, bytes]);
340
+ }
341
+ const token = init.authenticated !== false ? await this.ensureToken() : "";
342
+ if (path === "/system/peer/tenant-bloom") {
343
+ return await link.rpc("peer.tenantBloom", [token, body.bloom]);
344
+ }
345
+ if (path.startsWith("/system/peer/databases")) {
346
+ const tenantId = new URL(`http://peer${path}`).searchParams.get("tenantId") ?? "";
347
+ return await link.rpc("peer.listDatabases", [token, tenantId]);
348
+ }
349
+ if (path === "/system/peer/sync") {
350
+ return await link.rpc("peer.sync", [token, body]);
351
+ }
352
+ throw new Error(`No Iroh mapping for ${path}`);
353
+ }
354
+ catch (error) {
355
+ const message = error instanceof Error ? error.message : String(error);
356
+ if (/Invalid or expired peer token|Unknown peer|Authentication failed/i.test(message)) {
357
+ this.token = null;
358
+ }
359
+ throw new Error(`Peer ${this.peer.name} ${path} failed: ${message}`);
360
+ }
361
+ }
362
+ // ----------------------------------------------------------- intersection
363
+ /**
364
+ * Recompute the set of tenants both servers host.
365
+ *
366
+ * We send a Bloom summary of our tenant ids; the peer answers with the ids on
367
+ * *its* side that the filter might contain. Those are candidates only — a
368
+ * Bloom filter has false positives — so the result is intersected against the
369
+ * authoritative local list before anything is replicated. That second step is
370
+ * what makes the false-positive rate a performance question rather than a
371
+ * correctness one.
372
+ */
373
+ async refreshIntersection() {
374
+ const localTenants = this.host.listTenants();
375
+ const localSet = new Set(localTenants);
376
+ const summary = (0, bloom_1.createIdBloomSummary)(localTenants);
377
+ const response = (await this.peerFetch("/system/peer/tenant-bloom", {
378
+ method: "POST",
379
+ body: { bloom: summary },
380
+ }));
381
+ const candidates = Array.isArray(response.tenantIds) ? response.tenantIds : [];
382
+ const shared = candidates.filter((tenantId) => localSet.has(tenantId)).sort();
383
+ this.intersection = shared;
384
+ this.intersectionAt = new Date().toISOString();
385
+ for (const tenantId of this.tenants.keys()) {
386
+ if (!shared.includes(tenantId))
387
+ this.tenants.delete(tenantId);
388
+ }
389
+ for (const tenantId of shared) {
390
+ if (!this.tenants.has(tenantId)) {
391
+ this.tenants.set(tenantId, {
392
+ tenantId,
393
+ databases: [],
394
+ lastSyncAt: null,
395
+ lag: 0,
396
+ retryQueueDepth: 0,
397
+ rejectedEntries: 0,
398
+ errorClass: null,
399
+ suppressUpToPeerOrder: 0,
400
+ });
401
+ }
402
+ }
403
+ return shared;
404
+ }
405
+ // ------------------------------------------------------------------- sync
406
+ /**
407
+ * Run one full replication pass, optionally narrowed to a tenant or database.
408
+ *
409
+ * Concurrent calls collapse onto the in-flight run: the caller that arrives
410
+ * second gets the same promise rather than a second parallel mirror of the
411
+ * same stores.
412
+ */
413
+ async syncNow(scope) {
414
+ if (this.syncInFlight)
415
+ return this.syncInFlight;
416
+ this.syncInFlight = this.runSync(scope).finally(() => {
417
+ this.syncInFlight = null;
418
+ });
419
+ return this.syncInFlight;
420
+ }
421
+ /**
422
+ * Re-offer entries the peer rejected.
423
+ *
424
+ * Under the hold policy the cursor is already clamped, so a plain sync run is
425
+ * the retry — this exists as a named admin action so the UI has something to
426
+ * call that says what it does.
427
+ */
428
+ async retryRejected(scope) {
429
+ return this.syncNow(scope);
430
+ }
431
+ async runSync(scope) {
432
+ const result = {
433
+ peer: this.peer.name,
434
+ tenants: 0,
435
+ databases: 0,
436
+ pushed: 0,
437
+ pulled: 0,
438
+ rejected: 0,
439
+ held: 0,
440
+ errors: [],
441
+ };
442
+ if (this.paused || this.direction === "disabled" || !this.peer.url) {
443
+ return result;
444
+ }
445
+ if (this.intersection.length === 0) {
446
+ await this.refreshIntersection().catch((error) => {
447
+ this.recordError(error);
448
+ result.errors.push(errorMessage(error));
449
+ });
450
+ }
451
+ let tenantIds = scope?.tenantId
452
+ ? this.intersection.filter((id) => id === scope.tenantId)
453
+ : this.intersection;
454
+ const resumeAt = this.resumeTenantId ? tenantIds.indexOf(this.resumeTenantId) : -1;
455
+ if (resumeAt > 0) {
456
+ tenantIds = [...tenantIds.slice(resumeAt), ...tenantIds.slice(0, resumeAt)];
457
+ }
458
+ const pastDeadline = () => scope?.deadline !== undefined && Date.now() >= scope.deadline;
459
+ for (const tenantId of tenantIds) {
460
+ if (pastDeadline()) {
461
+ result.deferred = true;
462
+ this.resumeTenantId = tenantId;
463
+ break;
464
+ }
465
+ const runtime = this.tenants.get(tenantId);
466
+ if (!runtime)
467
+ continue;
468
+ runtime.rejectedEntries = 0;
469
+ runtime.retryQueueDepth = 0;
470
+ runtime.errorClass = null;
471
+ try {
472
+ const databases = await this.databasesFor(tenantId, scope?.dbId);
473
+ // Only a full-tenant run knows the complete list; a scoped run must not
474
+ // shrink what the status endpoint reports.
475
+ if (!scope?.dbId)
476
+ runtime.databases = databases;
477
+ result.tenants += 1;
478
+ for (const [dbId, storeKind] of this.mirrorPlan(databases)) {
479
+ if (pastDeadline()) {
480
+ result.deferred = true;
481
+ break;
482
+ }
483
+ if (storeKind === types_1.StoreKind.docs)
484
+ result.databases += 1;
485
+ const pair = await this.syncStorePair(tenantId, dbId, storeKind);
486
+ result.pushed += pair.pushed;
487
+ result.pulled += pair.pulled;
488
+ result.rejected += pair.rejected;
489
+ runtime.rejectedEntries += pair.rejected;
490
+ this.transferredEntries += pair.pushed + pair.pulled;
491
+ this.transferredBytes += pair.bytes;
492
+ if (pair.held) {
493
+ runtime.retryQueueDepth += 1;
494
+ result.held += 1;
495
+ }
496
+ if (pair.deniedReason) {
497
+ // Visible in the status read-model as a backoff rather than an
498
+ // outage, so an operator can tell "waiting on a purge that has not
499
+ // replicated yet" from "this peer is unreachable".
500
+ runtime.errorClass = "access-denied";
501
+ this.lastError = pair.deniedReason;
502
+ this.lastErrorClass = "access-denied";
503
+ this.lastErrorAt = new Date().toISOString();
504
+ }
505
+ }
506
+ if (result.deferred) {
507
+ this.resumeTenantId = tenantId;
508
+ break;
509
+ }
510
+ runtime.lastSyncAt = new Date().toISOString();
511
+ }
512
+ catch (error) {
513
+ const message = errorMessage(error);
514
+ runtime.errorClass = (0, types_4.classifyPeerError)(error);
515
+ result.errors.push(`${tenantId}: ${message}`);
516
+ this.recordError(error);
517
+ }
518
+ }
519
+ if (!result.deferred && !scope?.tenantId) {
520
+ this.resumeTenantId = null;
521
+ }
522
+ this.lastSyncAt = new Date().toISOString();
523
+ // A held pair is not a clean run: the backoff has to stay visible until the
524
+ // entries actually land, or the status flips to healthy while the mirror is
525
+ // still incomplete.
526
+ if (result.errors.length === 0 && result.held === 0) {
527
+ this.lastError = null;
528
+ this.lastErrorClass = null;
529
+ this.lastErrorAt = null;
530
+ this.sessionState = "connected";
531
+ }
532
+ return result;
533
+ }
534
+ /**
535
+ * The (database, store) pairs to mirror for one tenant, in order.
536
+ *
537
+ * `eager` interleaves — each database's documents are immediately followed by
538
+ * its attachments, so a database is complete before the next one starts.
539
+ * `lazy` runs every document store first and only then the attachment stores:
540
+ * a run that is interrupted (or a link that is slow) has then spent its
541
+ * bandwidth on the text, which is what makes documents readable, rather than
542
+ * on blobs that can be fetched later. `never` skips attachments entirely.
543
+ *
544
+ * Database order comes from {@link orderDatabasesForReplication} either way —
545
+ * invariant 1 outranks the attachment policy.
546
+ */
547
+ *mirrorPlan(databases) {
548
+ if (this.attachments === "eager") {
549
+ for (const dbId of databases) {
550
+ yield [dbId, types_1.StoreKind.docs];
551
+ yield [dbId, types_1.StoreKind.attachments];
552
+ }
553
+ return;
554
+ }
555
+ for (const dbId of databases) {
556
+ yield [dbId, types_1.StoreKind.docs];
557
+ }
558
+ if (this.attachments === "never")
559
+ return;
560
+ for (const dbId of databases) {
561
+ yield [dbId, types_1.StoreKind.attachments];
562
+ }
563
+ }
564
+ /**
565
+ * Databases to mirror for a tenant: the union of both sides, in invariant
566
+ * order (invariant 1).
567
+ *
568
+ * Union rather than intersection, because a database that exists only on the
569
+ * peer is exactly the thing a fresh mirror needs to pull.
570
+ */
571
+ async databasesFor(tenantId, onlyDbId) {
572
+ const local = await this.host.listDatabases(tenantId);
573
+ let remote = [];
574
+ try {
575
+ const response = (await this.peerFetch(`/system/peer/databases?tenantId=${encodeURIComponent(tenantId)}`));
576
+ remote = Array.isArray(response.databases) ? response.databases : [];
577
+ }
578
+ catch (error) {
579
+ this.logger.debug(`Could not list peer databases for ${tenantId}: ${errorMessage(error)}`);
580
+ }
581
+ const union = (0, types_4.orderDatabasesForReplication)(new Set([...local, ...remote]));
582
+ return onlyDbId ? union.filter((dbId) => dbId === onlyDbId) : union;
583
+ }
584
+ async syncStorePair(tenantId, dbId, storeKind) {
585
+ const localStore = await this.host.getLocalStore(tenantId, dbId, storeKind);
586
+ const remoteStore = await this.createRemoteStore(tenantId, dbId, storeKind);
587
+ let pushed = 0;
588
+ let pulled = 0;
589
+ let bytes = 0;
590
+ let rejected = 0;
591
+ let held = false;
592
+ let deniedReason;
593
+ // Both directions use `rejectionPolicy: "hold"` (invariant 2). A client may
594
+ // skip a poisoned entry forever; two servers that are meant to be identical
595
+ // may not, so the cursor stays clamped until the entry is accepted.
596
+ //
597
+ // A denial can also arrive for the batch as a whole rather than per entry —
598
+ // a purged document or a revoked decryption key makes the target refuse
599
+ // everything in the request. That is the same condition seen from further
600
+ // away: hold this pair, leave the cursor where it is, and carry on with the
601
+ // tenant's other databases. Treating it as a tenant-level failure would let
602
+ // one purged document stop the whole mirror.
603
+ if ((0, types_4.directionAllowsPull)(this.direction)) {
604
+ const getPurgedDocIds = this.host.getPurgedDocIds?.bind(this.host);
605
+ const pullTarget = dbId === types_1.DIRECTORY_DB_ID || !getPurgedDocIds
606
+ ? localStore
607
+ : (0, purgedDocumentGuard_1.guardStoreAgainstPurgedDocuments)(localStore, async () => getPurgedDocIds(tenantId, dbId), this.logger);
608
+ const result = await this.transfer(remoteStore, pullTarget);
609
+ pulled = result.transferred;
610
+ bytes += result.bytes;
611
+ rejected += result.rejected;
612
+ held || (held = result.held);
613
+ deniedReason ?? (deniedReason = result.deniedReason);
614
+ // The directory is mirrored first, so purges it carries are executed
615
+ // (and the registry armed) before any database they apply to.
616
+ if (dbId === types_1.DIRECTORY_DB_ID &&
617
+ storeKind === types_1.StoreKind.docs &&
618
+ pulled > 0 &&
619
+ this.host.onDirectoryPulled) {
620
+ try {
621
+ await this.host.onDirectoryPulled(tenantId);
622
+ }
623
+ catch (error) {
624
+ this.logger.error(`Executing replicated purge requests for ${tenantId} failed: ${errorMessage(error)}`);
625
+ }
626
+ }
627
+ }
628
+ if ((0, types_4.directionAllowsPush)(this.direction)) {
629
+ const result = await this.transfer(localStore, remoteStore);
630
+ pushed = result.transferred;
631
+ bytes += result.bytes;
632
+ rejected += result.rejected;
633
+ held || (held = result.held);
634
+ deniedReason ?? (deniedReason = result.deniedReason);
635
+ if (pushed > 0) {
636
+ // Remember how far the peer's log advanced because of us, so its SSE
637
+ // notification for these very entries is recognised as our own echo.
638
+ const runtime = this.tenants.get(tenantId);
639
+ const head = await remoteStore.getStoreHead?.().catch(() => null);
640
+ if (runtime && head?.maxReceiptOrder !== undefined) {
641
+ runtime.suppressUpToPeerOrder = Math.max(runtime.suppressUpToPeerOrder, head.maxReceiptOrder);
642
+ }
643
+ }
644
+ }
645
+ return { pushed, pulled, bytes, rejected, held, deniedReason };
646
+ }
647
+ /**
648
+ * One leg of a store pair, with a batch-wide denial folded into `held`.
649
+ *
650
+ * See {@link syncStorePair}: a denial means "not now", so the cursor must
651
+ * stay put and the pair is retried on the next run. Any other failure is a
652
+ * real error and propagates to the tenant loop.
653
+ */
654
+ async transfer(source, target) {
655
+ try {
656
+ const limits = this.host.transferLimits;
657
+ const result = await (0, syncStores_1.syncEntriesBetweenStores)(source, target, undefined, {
658
+ logger: this.logger,
659
+ cursors: this.cursorStore(),
660
+ rejectionPolicy: "hold",
661
+ ...(limits?.maxConcurrentBatches ? { maxConcurrentBatches: limits.maxConcurrentBatches } : {}),
662
+ ...(limits
663
+ ? {
664
+ transferBatchSize: source.getStoreKind() === types_1.StoreKind.attachments
665
+ ? limits.attachmentsBatchSize
666
+ : limits.docsBatchSize,
667
+ }
668
+ : {}),
669
+ });
670
+ // Entries the cursor moved past will not be offered again — either
671
+ // because the refusal can never pass, or because it was supposed to and
672
+ // did not within the retry budget. This log line is the only place they
673
+ // are announced: the counters below make them visible in the status
674
+ // read-model, but not why.
675
+ const final = result.abandoned ?? [];
676
+ if (final.length > 0) {
677
+ const byClass = new Map();
678
+ for (const entry of final) {
679
+ const key = (0, types_3.rejectionClassOf)(entry);
680
+ byClass.set(key, (byClass.get(key) ?? 0) + 1);
681
+ }
682
+ const breakdown = [...byClass]
683
+ .map(([cls, count]) => `${count} ${cls}`)
684
+ .join(", ");
685
+ this.logger.warn(`Peer refused ${final.length} entr${final.length === 1 ? "y" : "ies"} for good ` +
686
+ `(${breakdown}); moving the cursor past them. First reason: ` +
687
+ (0, types_4.redactEntryIds)(final[0].reason));
688
+ }
689
+ return {
690
+ transferred: result.transferred,
691
+ bytes: result.transferredBytes ?? 0,
692
+ rejected: result.rejected?.length ?? 0,
693
+ held: result.cursorHeldForRetry === true,
694
+ };
695
+ }
696
+ catch (error) {
697
+ if ((0, types_4.classifyPeerError)(error) !== "access-denied")
698
+ throw error;
699
+ this.logger.warn(`Peer refused a batch, holding for retry: ${errorMessage(error)}`);
700
+ return {
701
+ transferred: 0,
702
+ bytes: 0,
703
+ rejected: 0,
704
+ held: true,
705
+ deniedReason: (0, types_4.redactEntryIds)(errorMessage(error)),
706
+ };
707
+ }
708
+ }
709
+ async createRemoteStore(tenantId, dbId, storeKind) {
710
+ const encryptionKey = await this.host.getEncryptionKey();
711
+ if (!encryptionKey) {
712
+ throw new Error("Server identity is locked; cannot decrypt peer entries");
713
+ }
714
+ const signingKey = await this.host.getSigningKey();
715
+ if (!signingKey) {
716
+ throw new Error("Server identity is locked; cannot sign for peer");
717
+ }
718
+ // The tenant segment belongs in the base url, not just in the config: the
719
+ // transport builds `${baseUrl}/sync/${storeKind}/...` and the server routes
720
+ // sync under `/:tenantId/sync/*`. Passing the bare server url — as the old
721
+ // per-tenant ServerSync did — makes every request land on `/sync/...`,
722
+ // where Express reads "sync" as the tenant id and answers 404.
723
+ const transport = this.isIrohPeer()
724
+ ? new IrohNetworkTransport_1.IrohNetworkTransport((await this.getIrohLink()).asStreamIO(), (0, irohLocator_1.parseIrohLocator)(this.peer.url).ticket, {
725
+ tenantId,
726
+ dbId,
727
+ storeKind,
728
+ })
729
+ : new HttpTransport_1.HttpTransport({
730
+ baseUrl: `${this.peer.url.replace(/\/$/, "")}/${encodeURIComponent(tenantId)}`,
731
+ tenantId,
732
+ dbId,
733
+ storeKind,
734
+ });
735
+ const store = new ClientNetworkContentAddressedStore_1.ClientNetworkContentAddressedStore(dbId, storeKind, transport, this.host.cryptoAdapter, this.host.localServerName, signingKey, encryptionKey, this.logger);
736
+ // The tenant handshake would fail: a peer holds no per-tenant grant. It
737
+ // carries the cluster-level peer token instead.
738
+ store.setExternalTokenProvider(() => this.ensureToken());
739
+ return store;
740
+ }
741
+ cursorStore() {
742
+ return {
743
+ get: (key) => this.cursors.get(key) ?? null,
744
+ save: (key, record) => {
745
+ this.cursors.set(key, record);
746
+ },
747
+ delete: (key) => {
748
+ this.cursors.delete(key);
749
+ },
750
+ };
751
+ }
752
+ // ------------------------------------------------------------- live push
753
+ acceptsLocalChange(event) {
754
+ if (!this.running || this.paused)
755
+ return false;
756
+ if (!(0, types_4.directionAllowsPush)(this.direction))
757
+ return false;
758
+ // Do not bounce a peer's own write straight back at it. Other peers still
759
+ // receive it, which is how a spoke's write reaches the mesh via its hub.
760
+ if (event.originPeer === this.peer.name)
761
+ return false;
762
+ if (!this.intersection.includes(event.tenantId))
763
+ return false;
764
+ if (this.attachments === "never" && event.storeKind === types_1.StoreKind.attachments)
765
+ return false;
766
+ return true;
767
+ }
768
+ /**
769
+ * Push a local change now and resolve when done, instead of coalescing it
770
+ * on a timer. For hosts that cannot rely on timers outliving the request
771
+ * that reported the change.
772
+ */
773
+ async replicateLocalChange(event) {
774
+ if (!this.acceptsLocalChange(event))
775
+ return;
776
+ try {
777
+ await this.syncNow({ tenantId: event.tenantId, dbId: event.dbId });
778
+ }
779
+ catch (error) {
780
+ this.recordError(error);
781
+ }
782
+ }
783
+ onLocalChange(event) {
784
+ if (!this.acceptsLocalChange(event))
785
+ return;
786
+ this.pendingScopes.add(`${event.tenantId}\u0000${event.dbId}`);
787
+ if (this.pushTimer)
788
+ return;
789
+ this.pushTimer = setTimeout(() => {
790
+ this.pushTimer = null;
791
+ const scopes = [...this.pendingScopes];
792
+ this.pendingScopes.clear();
793
+ void this.flushScopes(scopes);
794
+ }, PUSH_COALESCE_MS);
795
+ this.pushTimer.unref?.();
796
+ }
797
+ /**
798
+ * Round-robin over the pending (tenant, db) scopes.
799
+ *
800
+ * Sequential on purpose: a hub with many spokes should not open a hundred
801
+ * parallel sessions to one peer, and one busy tenant must not starve the
802
+ * others — each pending scope gets its turn in the order it was queued.
803
+ */
804
+ async flushScopes(scopes) {
805
+ for (const scope of scopes) {
806
+ const [tenantId, dbId] = scope.split("\u0000");
807
+ try {
808
+ await this.syncNow({ tenantId, dbId });
809
+ }
810
+ catch (error) {
811
+ this.recordError(error);
812
+ }
813
+ }
814
+ }
815
+ // -------------------------------------------------------------- SSE pull
816
+ startEventStream() {
817
+ if (this.host.liveEventStream === false)
818
+ return;
819
+ if (!this.running || this.paused)
820
+ return;
821
+ if (!(0, types_4.directionAllowsPull)(this.direction))
822
+ return;
823
+ if (this.sseAbort)
824
+ return;
825
+ const controller = new AbortController();
826
+ this.sseAbort = controller;
827
+ void this.consumeEventStream(controller).catch((error) => {
828
+ if (!controller.signal.aborted) {
829
+ this.recordError(error);
830
+ this.scheduleReconnect();
831
+ }
832
+ });
833
+ }
834
+ async consumeEventStream(controller) {
835
+ if (this.isIrohPeer()) {
836
+ await this.consumeIrohEventStream(controller);
837
+ return;
838
+ }
839
+ const base = this.peer.url;
840
+ const token = await this.ensureToken();
841
+ const response = await fetch(`${base.replace(/\/$/, "")}/system/peer/events`, {
842
+ headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" },
843
+ signal: controller.signal,
844
+ });
845
+ if (!response.ok || !response.body) {
846
+ throw new Error(`Peer event stream failed (${response.status})`);
847
+ }
848
+ this.sessionState = "connected";
849
+ this.eventsConnected = true;
850
+ this.reconnectAttempt = 0;
851
+ const reader = response.body.getReader();
852
+ const decoder = new TextDecoder();
853
+ let buffer = "";
854
+ while (!controller.signal.aborted) {
855
+ const { done, value } = await reader.read();
856
+ if (done)
857
+ break;
858
+ buffer += decoder.decode(value, { stream: true });
859
+ let separator = buffer.indexOf("\n\n");
860
+ while (separator !== -1) {
861
+ const frame = buffer.slice(0, separator);
862
+ buffer = buffer.slice(separator + 2);
863
+ this.handleEventFrame(frame);
864
+ separator = buffer.indexOf("\n\n");
865
+ }
866
+ }
867
+ if (this.sseAbort === controller) {
868
+ this.sseAbort = null;
869
+ this.eventsConnected = false;
870
+ // The peer closed the stream. Catch up once (entries may have landed
871
+ // while we were disconnected) and then re-subscribe.
872
+ if (this.running && !this.paused) {
873
+ void this.syncNow().catch(() => undefined);
874
+ this.scheduleReconnect();
875
+ }
876
+ }
877
+ }
878
+ async consumeIrohEventStream(controller) {
879
+ const link = await this.getIrohLink();
880
+ const token = await this.ensureToken();
881
+ this.sessionState = "connected";
882
+ this.eventsConnected = true;
883
+ this.reconnectAttempt = 0;
884
+ try {
885
+ await link.subscribeEvents(token, (event) => this.handleChangeEvent(event), controller.signal);
886
+ }
887
+ finally {
888
+ if (this.sseAbort === controller) {
889
+ this.sseAbort = null;
890
+ this.eventsConnected = false;
891
+ if (this.running && !this.paused && !controller.signal.aborted) {
892
+ void this.syncNow().catch(() => undefined);
893
+ this.scheduleReconnect();
894
+ }
895
+ }
896
+ }
897
+ }
898
+ handleEventFrame(frame) {
899
+ const dataLine = frame
900
+ .split("\n")
901
+ .find((line) => line.startsWith("data:"));
902
+ if (!dataLine)
903
+ return;
904
+ let event;
905
+ try {
906
+ event = JSON.parse(dataLine.slice(5).trim());
907
+ }
908
+ catch {
909
+ return;
910
+ }
911
+ this.handleChangeEvent(event);
912
+ }
913
+ handleChangeEvent(event) {
914
+ if (!event.tenantId || !event.dbId)
915
+ return;
916
+ if (!this.intersection.includes(event.tenantId))
917
+ return;
918
+ if (this.attachments === "never" && event.storeKind === types_1.StoreKind.attachments)
919
+ return;
920
+ // Echo suppression: this notification describes entries we ourselves just
921
+ // pushed, so pulling them back would be a round trip for nothing.
922
+ const runtime = this.tenants.get(event.tenantId);
923
+ if (runtime &&
924
+ event.maxReceiptOrder !== undefined &&
925
+ event.maxReceiptOrder <= runtime.suppressUpToPeerOrder) {
926
+ return;
927
+ }
928
+ this.pendingScopes.add(`${event.tenantId}\u0000${event.dbId}`);
929
+ if (this.pushTimer)
930
+ return;
931
+ this.pushTimer = setTimeout(() => {
932
+ this.pushTimer = null;
933
+ const scopes = [...this.pendingScopes];
934
+ this.pendingScopes.clear();
935
+ void this.flushScopes(scopes);
936
+ }, PUSH_COALESCE_MS);
937
+ this.pushTimer.unref?.();
938
+ }
939
+ // ------------------------------------------------------------- reporting
940
+ recordError(error) {
941
+ this.lastError = (0, types_4.redactEntryIds)(errorMessage(error));
942
+ this.lastErrorClass = (0, types_4.classifyPeerError)(error);
943
+ this.lastErrorAt = new Date().toISOString();
944
+ if (this.lastErrorClass === "network") {
945
+ this.sessionState = "unreachable";
946
+ }
947
+ else if (this.sessionState !== "paused" && this.sessionState !== "disabled") {
948
+ this.sessionState = "backoff";
949
+ }
950
+ this.logger.warn(`Replication error: ${this.lastError}`);
951
+ }
952
+ /**
953
+ * Status snapshot for `GET /system/cluster/status`.
954
+ *
955
+ * Read straight from in-memory replication state — no store scans — so the
956
+ * admin UI can poll it without competing with replication for I/O. Contains
957
+ * counts and classified reasons only: never a docId, never an author key.
958
+ */
959
+ snapshot() {
960
+ const tenants = [...this.tenants.values()].map((runtime) => this.tenantStatus(runtime));
961
+ const { health, reasons } = rollUpHealth(tenants, this.peerLevelReasons());
962
+ return {
963
+ name: this.peer.name,
964
+ url: this.peer.url ?? null,
965
+ direction: this.direction,
966
+ role: this.role,
967
+ attachments: this.attachments,
968
+ state: this.sessionState,
969
+ health,
970
+ reasons,
971
+ sharedTenants: this.intersection.length,
972
+ lastIntersectionAt: this.intersectionAt,
973
+ lastSyncAt: this.lastSyncAt,
974
+ eventsConnected: this.eventsConnected,
975
+ lag: tenants.reduce((sum, tenant) => sum + tenant.lag, 0),
976
+ retryQueueDepth: tenants.reduce((sum, tenant) => sum + tenant.retryQueueDepth, 0),
977
+ rejectedEntries: tenants.reduce((sum, tenant) => sum + tenant.rejectedEntries, 0),
978
+ transferredEntries: this.transferredEntries,
979
+ transferredBytes: this.transferredBytes,
980
+ lastError: this.lastError,
981
+ lastErrorClass: this.lastErrorClass,
982
+ lastErrorAt: this.lastErrorAt,
983
+ };
984
+ }
985
+ tenantSnapshot(tenantId) {
986
+ const runtime = this.tenants.get(tenantId);
987
+ return runtime ? this.tenantStatus(runtime) : null;
988
+ }
989
+ tenantStatus(runtime) {
990
+ const reasons = [];
991
+ if (runtime.retryQueueDepth > 0)
992
+ reasons.push("entries-awaiting-directory-trust");
993
+ if (runtime.errorClass === "access-denied")
994
+ reasons.push("access-denied-backoff");
995
+ const { health } = rollUpHealth([], reasons);
996
+ return {
997
+ tenantId: runtime.tenantId,
998
+ databases: runtime.databases,
999
+ lastSyncAt: runtime.lastSyncAt,
1000
+ lag: runtime.lag,
1001
+ retryQueueDepth: runtime.retryQueueDepth,
1002
+ rejectedEntries: runtime.rejectedEntries,
1003
+ health,
1004
+ reasons,
1005
+ };
1006
+ }
1007
+ /** Reasons that come from the link itself rather than from any one tenant. */
1008
+ peerLevelReasons() {
1009
+ const reasons = [];
1010
+ if (!this.peer.url)
1011
+ reasons.push("no-url-configured");
1012
+ if (this.direction === "disabled")
1013
+ reasons.push("session-disabled");
1014
+ if (this.paused)
1015
+ reasons.push("session-paused");
1016
+ if (this.sessionState === "unreachable")
1017
+ reasons.push("peer-unreachable");
1018
+ if (this.lastErrorClass === "auth")
1019
+ reasons.push("peer-auth-failed");
1020
+ if (this.running &&
1021
+ !this.paused &&
1022
+ !this.eventsConnected &&
1023
+ (0, types_4.directionAllowsPull)(this.direction)) {
1024
+ reasons.push("events-disconnected");
1025
+ }
1026
+ if (this.intersectionAt === null && this.running)
1027
+ reasons.push("intersection-stale");
1028
+ return reasons;
1029
+ }
1030
+ }
1031
+ exports.PeerReplicator = PeerReplicator;
1032
+ /**
1033
+ * Collapse reasons into one health verdict, shared by the per-tenant, per-peer
1034
+ * and per-node rollups so all three agree on the thresholds.
1035
+ *
1036
+ * `stalled` outranks `degraded` deliberately. A clamped cursor is the visible
1037
+ * precursor of divergence: the link looks healthy, entries keep flowing, and
1038
+ * yet one database is quietly stuck behind an entry the peer will not accept
1039
+ * until the directory catches up. That is the state an operator most needs to
1040
+ * see, so a transient error elsewhere must not mask it.
1041
+ */
1042
+ function rollUpHealth(tenants, extraReasons = []) {
1043
+ const reasons = new Set(extraReasons);
1044
+ for (const tenant of tenants) {
1045
+ for (const reason of tenant.reasons)
1046
+ reasons.add(reason);
1047
+ }
1048
+ let health = "ok";
1049
+ if (reasons.has("session-disabled") || reasons.has("session-paused")) {
1050
+ // Deliberately off is not unhealthy; it is just not running.
1051
+ health = "ok";
1052
+ }
1053
+ else if (reasons.has("peer-unreachable") ||
1054
+ reasons.has("peer-auth-failed") ||
1055
+ reasons.has("no-url-configured")) {
1056
+ health = "down";
1057
+ }
1058
+ else if (reasons.has("entries-awaiting-directory-trust")) {
1059
+ health = "stalled";
1060
+ }
1061
+ else if (reasons.size > 0) {
1062
+ health = "degraded";
1063
+ }
1064
+ return { health, reasons: [...reasons] };
1065
+ }
1066
+ function errorMessage(error) {
1067
+ if (error instanceof types_2.NetworkError)
1068
+ return `${error.type}: ${error.message}`;
1069
+ return error instanceof Error ? error.message : String(error);
1070
+ }
1071
+ //# sourceMappingURL=PeerReplicator.js.map