mindoodb 0.0.85 → 0.0.88

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