mindoodb 0.0.53 → 0.0.55

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