hungry-ghost-hive 0.45.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/cli/commands/cluster.d.ts.map +1 -1
  2. package/dist/cli/commands/cluster.js +348 -1
  3. package/dist/cli/commands/cluster.js.map +1 -1
  4. package/dist/cli/commands/cluster.test.js +313 -9
  5. package/dist/cli/commands/cluster.test.js.map +1 -1
  6. package/dist/cli/commands/req-spawn.test.d.ts +2 -0
  7. package/dist/cli/commands/req-spawn.test.d.ts.map +1 -0
  8. package/dist/cli/commands/req-spawn.test.js +116 -0
  9. package/dist/cli/commands/req-spawn.test.js.map +1 -0
  10. package/dist/cli/commands/req.d.ts.map +1 -1
  11. package/dist/cli/commands/req.js +21 -13
  12. package/dist/cli/commands/req.js.map +1 -1
  13. package/dist/cluster/cluster-http-server.d.ts +32 -0
  14. package/dist/cluster/cluster-http-server.d.ts.map +1 -1
  15. package/dist/cluster/cluster-http-server.js +42 -0
  16. package/dist/cluster/cluster-http-server.js.map +1 -1
  17. package/dist/cluster/distributed-runtime-coverage.test.js +9 -0
  18. package/dist/cluster/distributed-runtime-coverage.test.js.map +1 -1
  19. package/dist/cluster/distributed-system.test.js +135 -0
  20. package/dist/cluster/distributed-system.test.js.map +1 -1
  21. package/dist/cluster/events.d.ts +23 -0
  22. package/dist/cluster/events.d.ts.map +1 -1
  23. package/dist/cluster/events.js +74 -0
  24. package/dist/cluster/events.js.map +1 -1
  25. package/dist/cluster/heartbeat-manager.d.ts +2 -0
  26. package/dist/cluster/heartbeat-manager.d.ts.map +1 -1
  27. package/dist/cluster/heartbeat-manager.js +42 -6
  28. package/dist/cluster/heartbeat-manager.js.map +1 -1
  29. package/dist/cluster/membership.test.d.ts +2 -0
  30. package/dist/cluster/membership.test.d.ts.map +1 -0
  31. package/dist/cluster/membership.test.js +416 -0
  32. package/dist/cluster/membership.test.js.map +1 -0
  33. package/dist/cluster/partition-safety.test.d.ts +2 -0
  34. package/dist/cluster/partition-safety.test.d.ts.map +1 -0
  35. package/dist/cluster/partition-safety.test.js +440 -0
  36. package/dist/cluster/partition-safety.test.js.map +1 -0
  37. package/dist/cluster/raft-state-machine.d.ts +33 -1
  38. package/dist/cluster/raft-state-machine.d.ts.map +1 -1
  39. package/dist/cluster/raft-state-machine.js +65 -3
  40. package/dist/cluster/raft-state-machine.js.map +1 -1
  41. package/dist/cluster/raft-store.d.ts +26 -1
  42. package/dist/cluster/raft-store.d.ts.map +1 -1
  43. package/dist/cluster/raft-store.js +137 -0
  44. package/dist/cluster/raft-store.js.map +1 -1
  45. package/dist/cluster/replication-lag.test.d.ts +2 -0
  46. package/dist/cluster/replication-lag.test.d.ts.map +1 -0
  47. package/dist/cluster/replication-lag.test.js +239 -0
  48. package/dist/cluster/replication-lag.test.js.map +1 -0
  49. package/dist/cluster/replication.d.ts +2 -2
  50. package/dist/cluster/replication.d.ts.map +1 -1
  51. package/dist/cluster/replication.js +1 -1
  52. package/dist/cluster/replication.js.map +1 -1
  53. package/dist/cluster/runtime.d.ts +78 -0
  54. package/dist/cluster/runtime.d.ts.map +1 -1
  55. package/dist/cluster/runtime.js +400 -13
  56. package/dist/cluster/runtime.js.map +1 -1
  57. package/dist/cluster/state-recovery.test.d.ts +2 -0
  58. package/dist/cluster/state-recovery.test.d.ts.map +1 -0
  59. package/dist/cluster/state-recovery.test.js +310 -0
  60. package/dist/cluster/state-recovery.test.js.map +1 -0
  61. package/dist/cluster/types.d.ts +30 -0
  62. package/dist/cluster/types.d.ts.map +1 -1
  63. package/dist/config/schema.d.ts +48 -0
  64. package/dist/config/schema.d.ts.map +1 -1
  65. package/dist/config/schema.js +11 -0
  66. package/dist/config/schema.js.map +1 -1
  67. package/dist/context-files/generator.js +1 -1
  68. package/dist/context-files/generator.js.map +1 -1
  69. package/dist/context-files/generator.test.js +51 -0
  70. package/dist/context-files/generator.test.js.map +1 -1
  71. package/dist/orchestrator/orphan-recovery.d.ts +1 -1
  72. package/dist/orchestrator/orphan-recovery.d.ts.map +1 -1
  73. package/dist/orchestrator/orphan-recovery.js +4 -4
  74. package/dist/orchestrator/orphan-recovery.js.map +1 -1
  75. package/dist/orchestrator/prompt-templates.d.ts +3 -1
  76. package/dist/orchestrator/prompt-templates.d.ts.map +1 -1
  77. package/dist/orchestrator/prompt-templates.js +45 -8
  78. package/dist/orchestrator/prompt-templates.js.map +1 -1
  79. package/dist/orchestrator/prompt-templates.test.js +210 -0
  80. package/dist/orchestrator/prompt-templates.test.js.map +1 -1
  81. package/dist/orchestrator/scheduler.d.ts +1 -0
  82. package/dist/orchestrator/scheduler.d.ts.map +1 -1
  83. package/dist/orchestrator/scheduler.js +15 -10
  84. package/dist/orchestrator/scheduler.js.map +1 -1
  85. package/dist/orchestrator/scheduler.test.js +97 -6
  86. package/dist/orchestrator/scheduler.test.js.map +1 -1
  87. package/package.json +1 -1
  88. package/src/cli/commands/cluster.test.ts +387 -9
  89. package/src/cli/commands/cluster.ts +486 -1
  90. package/src/cli/commands/req-spawn.test.ts +153 -0
  91. package/src/cli/commands/req.ts +31 -18
  92. package/src/cluster/cluster-http-server.ts +80 -0
  93. package/src/cluster/distributed-runtime-coverage.test.ts +9 -0
  94. package/src/cluster/distributed-system.test.ts +168 -0
  95. package/src/cluster/events.ts +90 -0
  96. package/src/cluster/heartbeat-manager.ts +48 -6
  97. package/src/cluster/membership.test.ts +498 -0
  98. package/src/cluster/partition-safety.test.ts +523 -0
  99. package/src/cluster/raft-state-machine.ts +76 -4
  100. package/src/cluster/raft-store.ts +167 -1
  101. package/src/cluster/replication-lag.test.ts +284 -0
  102. package/src/cluster/replication.ts +6 -0
  103. package/src/cluster/runtime.ts +551 -12
  104. package/src/cluster/state-recovery.test.ts +420 -0
  105. package/src/cluster/types.ts +32 -0
  106. package/src/config/schema.ts +11 -0
  107. package/src/context-files/generator.test.ts +55 -0
  108. package/src/context-files/generator.ts +5 -5
  109. package/src/orchestrator/orphan-recovery.ts +32 -13
  110. package/src/orchestrator/prompt-templates.test.ts +263 -0
  111. package/src/orchestrator/prompt-templates.ts +49 -8
  112. package/src/orchestrator/scheduler.test.ts +129 -6
  113. package/src/orchestrator/scheduler.ts +46 -20
@@ -4,6 +4,7 @@ import { createHash } from 'crypto';
4
4
  import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
5
5
  import { join } from 'path';
6
6
  import type { ClusterEvent } from './replication.js';
7
+ import type { RaftSnapshot, VersionVector } from './types.js';
7
8
 
8
9
  export type DurableLogEntryType =
9
10
  | 'runtime'
@@ -13,7 +14,8 @@ export type DurableLogEntryType =
13
14
  | 'heartbeat_sent'
14
15
  | 'heartbeat_received'
15
16
  | 'state_transition'
16
- | 'cluster_event';
17
+ | 'cluster_event'
18
+ | 'membership_change';
17
19
 
18
20
  export interface DurableRaftState {
19
21
  node_id: string;
@@ -43,6 +45,15 @@ export interface DurableRaftLogEntry {
43
45
  created_at: string;
44
46
  }
45
47
 
48
+ export interface CompactionResult {
49
+ /** Number of log entries removed */
50
+ entries_removed: number;
51
+ /** Number of log entries retained (after snapshot index) */
52
+ entries_retained: number;
53
+ /** The snapshot index */
54
+ snapshot_index: number;
55
+ }
56
+
46
57
  interface RaftStoreOptions {
47
58
  clusterDir: string;
48
59
  nodeId: string;
@@ -51,19 +62,23 @@ interface RaftStoreOptions {
51
62
  export class RaftMetadataStore {
52
63
  private readonly statePath: string;
53
64
  private readonly logPath: string;
65
+ private readonly snapshotPath: string;
54
66
  private readonly nodeId: string;
55
67
 
56
68
  private state: DurableRaftState;
57
69
  private knownEventIds = new Set<string>();
70
+ private snapshot: RaftSnapshot | null = null;
58
71
 
59
72
  constructor(options: RaftStoreOptions) {
60
73
  this.nodeId = options.nodeId;
61
74
  this.statePath = join(options.clusterDir, 'raft-state.json');
62
75
  this.logPath = join(options.clusterDir, 'raft-log.ndjson');
76
+ this.snapshotPath = join(options.clusterDir, 'raft-snapshot.json');
63
77
 
64
78
  mkdirSync(options.clusterDir, { recursive: true });
65
79
 
66
80
  this.state = this.loadOrCreateState();
81
+ this.loadSnapshot();
67
82
  this.rebuildFromLog();
68
83
  this.persistState();
69
84
  }
@@ -180,6 +195,157 @@ export class RaftMetadataStore {
180
195
  return this.knownEventIds.has(eventId);
181
196
  }
182
197
 
198
+ getSnapshot(): RaftSnapshot | null {
199
+ return this.snapshot;
200
+ }
201
+
202
+ getLogEntryCount(): number {
203
+ if (!existsSync(this.logPath)) return 0;
204
+ try {
205
+ const content = readFileSync(this.logPath, 'utf-8');
206
+ if (!content.trim()) return 0;
207
+ return content.split('\n').filter(Boolean).length;
208
+ } catch {
209
+ return 0;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Create a snapshot at the current state, capturing the version vector
215
+ * and known event IDs for deduplication continuity.
216
+ */
217
+ createSnapshot(versionVector: VersionVector): RaftSnapshot {
218
+ const snapshot: RaftSnapshot = {
219
+ last_included_index: this.state.last_log_index,
220
+ last_included_term: this.state.last_log_term,
221
+ version_vector: { ...versionVector },
222
+ known_event_ids: Array.from(this.knownEventIds),
223
+ created_at: new Date().toISOString(),
224
+ };
225
+
226
+ this.persistSnapshot(snapshot);
227
+ this.snapshot = snapshot;
228
+ return snapshot;
229
+ }
230
+
231
+ /**
232
+ * Compact the raft log by removing all entries at or before the snapshot index.
233
+ * Only entries after the snapshot index are retained.
234
+ */
235
+ compactLog(): CompactionResult {
236
+ if (!this.snapshot) {
237
+ return { entries_removed: 0, entries_retained: 0, snapshot_index: 0 };
238
+ }
239
+
240
+ const snapshotIndex = this.snapshot.last_included_index;
241
+
242
+ if (!existsSync(this.logPath)) {
243
+ return { entries_removed: 0, entries_retained: 0, snapshot_index: snapshotIndex };
244
+ }
245
+
246
+ let content: string;
247
+ try {
248
+ content = readFileSync(this.logPath, 'utf-8');
249
+ } catch {
250
+ return { entries_removed: 0, entries_retained: 0, snapshot_index: snapshotIndex };
251
+ }
252
+
253
+ if (!content.trim()) {
254
+ return { entries_removed: 0, entries_retained: 0, snapshot_index: snapshotIndex };
255
+ }
256
+
257
+ const lines = content.split('\n').filter(Boolean);
258
+ const retained: string[] = [];
259
+ let removed = 0;
260
+
261
+ for (const line of lines) {
262
+ try {
263
+ const entry = JSON.parse(line) as Partial<DurableRaftLogEntry>;
264
+ const index = toNonNegativeInt(entry.index);
265
+ if (index > snapshotIndex) {
266
+ retained.push(line);
267
+ } else {
268
+ removed++;
269
+ }
270
+ } catch {
271
+ // Drop malformed lines during compaction
272
+ removed++;
273
+ }
274
+ }
275
+
276
+ // Atomic write: write to temp file then rename
277
+ try {
278
+ const temp = `${this.logPath}.tmp`;
279
+ writeFileSync(temp, retained.length > 0 ? retained.join('\n') + '\n' : '', 'utf-8');
280
+ renameSync(temp, this.logPath);
281
+ } catch (error) {
282
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
283
+ throw error;
284
+ }
285
+ }
286
+
287
+ return {
288
+ entries_removed: removed,
289
+ entries_retained: retained.length,
290
+ snapshot_index: snapshotIndex,
291
+ };
292
+ }
293
+
294
+ private loadSnapshot(): void {
295
+ if (!existsSync(this.snapshotPath)) return;
296
+
297
+ try {
298
+ const raw = readFileSync(this.snapshotPath, 'utf-8');
299
+ const parsed = JSON.parse(raw) as Partial<RaftSnapshot>;
300
+
301
+ if (
302
+ typeof parsed.last_included_index !== 'number' ||
303
+ typeof parsed.last_included_term !== 'number'
304
+ ) {
305
+ return;
306
+ }
307
+
308
+ this.snapshot = {
309
+ last_included_index: parsed.last_included_index,
310
+ last_included_term: parsed.last_included_term,
311
+ version_vector: parsed.version_vector || {},
312
+ known_event_ids: Array.isArray(parsed.known_event_ids) ? parsed.known_event_ids : [],
313
+ created_at: parsed.created_at || new Date().toISOString(),
314
+ };
315
+
316
+ // Restore known event IDs from snapshot
317
+ for (const id of this.snapshot.known_event_ids) {
318
+ this.knownEventIds.add(id);
319
+ }
320
+
321
+ // Ensure state reflects at least the snapshot's progress
322
+ this.state.last_log_index = Math.max(
323
+ this.state.last_log_index,
324
+ this.snapshot.last_included_index
325
+ );
326
+ this.state.last_log_term = Math.max(
327
+ this.state.last_log_term,
328
+ this.snapshot.last_included_term
329
+ );
330
+ this.state.commit_index = Math.max(this.state.commit_index, this.state.last_log_index);
331
+ this.state.last_applied = Math.max(this.state.last_applied, this.state.commit_index);
332
+ } catch {
333
+ // Ignore corrupt snapshots; the log is still authoritative.
334
+ }
335
+ }
336
+
337
+ private persistSnapshot(snapshot: RaftSnapshot): void {
338
+ try {
339
+ const temp = `${this.snapshotPath}.tmp`;
340
+ writeFileSync(temp, JSON.stringify(snapshot, null, 2), 'utf-8');
341
+ renameSync(temp, this.snapshotPath);
342
+ } catch (error) {
343
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
344
+ throw error;
345
+ }
346
+ }
347
+ }
348
+
183
349
  private loadOrCreateState(): DurableRaftState {
184
350
  if (existsSync(this.statePath)) {
185
351
  try {
@@ -0,0 +1,284 @@
1
+ // Licensed under the Hungry Ghost Hive License. See LICENSE.
2
+
3
+ import { mkdirSync, mkdtempSync, rmSync } from 'fs';
4
+ import { createServer } from 'net';
5
+ import { tmpdir } from 'os';
6
+ import { join } from 'path';
7
+ import { afterEach, describe, expect, it } from 'vitest';
8
+ import type { ClusterConfig } from '../config/schema.js';
9
+ import { createDatabase } from '../db/client.js';
10
+ import { ClusterRuntime, fetchReplicationLag } from './runtime.js';
11
+
12
+ const tempRoots: string[] = [];
13
+
14
+ afterEach(() => {
15
+ for (const root of tempRoots.splice(0)) {
16
+ rmSync(root, { recursive: true, force: true });
17
+ }
18
+ });
19
+
20
+ describe('replication lag tracking', () => {
21
+ it('returns null when cluster is disabled', async () => {
22
+ const result = await fetchReplicationLag({
23
+ enabled: false,
24
+ node_id: 'node-test',
25
+ listen_host: '127.0.0.1',
26
+ listen_port: 9999,
27
+ public_url: 'http://127.0.0.1:9999',
28
+ peers: [],
29
+ heartbeat_interval_ms: 2000,
30
+ election_timeout_min_ms: 3000,
31
+ election_timeout_max_ms: 6000,
32
+ sync_interval_ms: 5000,
33
+ request_timeout_ms: 5000,
34
+ story_similarity_threshold: 0.92,
35
+ });
36
+
37
+ expect(result).toBeNull();
38
+ });
39
+
40
+ it('getReplicationLag returns empty peers when no peers configured', async () => {
41
+ if (!(await canListenOnLocalhost())) return;
42
+
43
+ const root = mkdtempSync(join(tmpdir(), 'hive-repl-lag-'));
44
+ tempRoots.push(root);
45
+ const hiveDir = join(root, '.hive');
46
+ mkdirSync(hiveDir, { recursive: true });
47
+
48
+ const { runtime } = await startRuntimeWithRetries(hiveDir, {
49
+ enabled: true,
50
+ node_id: 'node-lag-test',
51
+ listen_host: '127.0.0.1',
52
+ peers: [],
53
+ heartbeat_interval_ms: 100,
54
+ election_timeout_min_ms: 150,
55
+ election_timeout_max_ms: 250,
56
+ sync_interval_ms: 200,
57
+ request_timeout_ms: 500,
58
+ story_similarity_threshold: 0.92,
59
+ });
60
+
61
+ const lag = runtime.getReplicationLag();
62
+ expect(lag.node_id).toBe('node-lag-test');
63
+ expect(lag.peers).toEqual([]);
64
+ expect(lag.last_sync_at).toBeNull();
65
+ expect(lag.total_local_events).toBe(0);
66
+ expect(lag.version_vector).toEqual({});
67
+
68
+ await runtime.stop();
69
+ });
70
+
71
+ it('tracks peer lag after sync with unreachable peer', async () => {
72
+ if (!(await canListenOnLocalhost())) return;
73
+
74
+ const root = mkdtempSync(join(tmpdir(), 'hive-repl-lag-unreach-'));
75
+ tempRoots.push(root);
76
+ const hiveDir = join(root, '.hive');
77
+ mkdirSync(hiveDir, { recursive: true });
78
+
79
+ const db = await createDatabase(join(hiveDir, 'hive.db'));
80
+
81
+ const { runtime } = await startRuntimeWithRetries(hiveDir, {
82
+ enabled: true,
83
+ node_id: 'node-lag-unreach',
84
+ listen_host: '127.0.0.1',
85
+ peers: [
86
+ { id: 'node-lag-unreach', url: 'http://127.0.0.1:1' },
87
+ { id: 'peer-ghost', url: 'http://127.0.0.1:19999' },
88
+ ],
89
+ heartbeat_interval_ms: 100,
90
+ election_timeout_min_ms: 150,
91
+ election_timeout_max_ms: 250,
92
+ sync_interval_ms: 200,
93
+ request_timeout_ms: 500,
94
+ story_similarity_threshold: 0.92,
95
+ });
96
+
97
+ await runtime.sync(db.db);
98
+
99
+ const lag = runtime.getReplicationLag();
100
+ expect(lag.node_id).toBe('node-lag-unreach');
101
+ expect(lag.peers).toHaveLength(1);
102
+ expect(lag.peers[0].peer_id).toBe('peer-ghost');
103
+ expect(lag.peers[0].reachable).toBe(false);
104
+ expect(lag.peers[0].last_sync_at).not.toBeNull();
105
+ expect(lag.peers[0].last_sync_duration_ms).toBeGreaterThanOrEqual(0);
106
+ expect(lag.last_sync_at).not.toBeNull();
107
+
108
+ await runtime.stop();
109
+ db.close();
110
+ });
111
+
112
+ it('serves replication-lag via HTTP endpoint', async () => {
113
+ if (!(await canListenOnLocalhost())) return;
114
+
115
+ const root = mkdtempSync(join(tmpdir(), 'hive-repl-lag-http-'));
116
+ tempRoots.push(root);
117
+ const hiveDir = join(root, '.hive');
118
+ mkdirSync(hiveDir, { recursive: true });
119
+
120
+ const { runtime, config } = await startRuntimeWithRetries(hiveDir, {
121
+ enabled: true,
122
+ node_id: 'node-lag-http',
123
+ listen_host: '127.0.0.1',
124
+ peers: [],
125
+ heartbeat_interval_ms: 100,
126
+ election_timeout_min_ms: 150,
127
+ election_timeout_max_ms: 250,
128
+ sync_interval_ms: 200,
129
+ request_timeout_ms: 500,
130
+ story_similarity_threshold: 0.92,
131
+ });
132
+
133
+ const url = `http://127.0.0.1:${config.listen_port}/cluster/v1/replication-lag`;
134
+ const response = await fetch(url);
135
+ expect(response.ok).toBe(true);
136
+
137
+ const body = (await response.json()) as {
138
+ node_id: string;
139
+ peers: unknown[];
140
+ last_sync_at: string | null;
141
+ };
142
+ expect(body.node_id).toBe('node-lag-http');
143
+ expect(body.peers).toEqual([]);
144
+ expect(body.last_sync_at).toBeNull();
145
+
146
+ await runtime.stop();
147
+ });
148
+
149
+ it('tracks lag for reachable peer with events', async () => {
150
+ if (!(await canListenOnLocalhost())) return;
151
+
152
+ const root = mkdtempSync(join(tmpdir(), 'hive-repl-lag-two-'));
153
+ tempRoots.push(root);
154
+ const hiveDirA = join(root, '.hive-a');
155
+ const hiveDirB = join(root, '.hive-b');
156
+ mkdirSync(hiveDirA, { recursive: true });
157
+ mkdirSync(hiveDirB, { recursive: true });
158
+
159
+ const dbA = await createDatabase(join(hiveDirA, 'hive.db'));
160
+ const dbB = await createDatabase(join(hiveDirB, 'hive.db'));
161
+
162
+ // Start node B first (the peer)
163
+ const { runtime: runtimeB, config: configB } = await startRuntimeWithRetries(hiveDirB, {
164
+ enabled: true,
165
+ node_id: 'node-b',
166
+ listen_host: '127.0.0.1',
167
+ peers: [],
168
+ heartbeat_interval_ms: 100,
169
+ election_timeout_min_ms: 150,
170
+ election_timeout_max_ms: 250,
171
+ sync_interval_ms: 200,
172
+ request_timeout_ms: 500,
173
+ story_similarity_threshold: 0.92,
174
+ });
175
+
176
+ // Insert data on node B and sync so it has events in cache
177
+ dbB.db.run(
178
+ `INSERT INTO stories (id, requirement_id, team_id, title, description, status, created_at, updated_at)
179
+ VALUES ('STORY-B1', NULL, NULL, 'Story from B', 'Test story', 'planned', ?, ?)`,
180
+ [new Date().toISOString(), new Date().toISOString()]
181
+ );
182
+ await runtimeB.sync(dbB.db);
183
+ dbB.save();
184
+
185
+ // Start node A with node B as a peer
186
+ const { runtime: runtimeA } = await startRuntimeWithRetries(hiveDirA, {
187
+ enabled: true,
188
+ node_id: 'node-a',
189
+ listen_host: '127.0.0.1',
190
+ peers: [
191
+ { id: 'node-a', url: 'http://127.0.0.1:1' },
192
+ { id: 'node-b', url: `http://127.0.0.1:${configB.listen_port}` },
193
+ ],
194
+ heartbeat_interval_ms: 100,
195
+ election_timeout_min_ms: 150,
196
+ election_timeout_max_ms: 250,
197
+ sync_interval_ms: 200,
198
+ request_timeout_ms: 500,
199
+ story_similarity_threshold: 0.92,
200
+ });
201
+
202
+ // Sync node A - it should pull events from B
203
+ await runtimeA.sync(dbA.db);
204
+
205
+ const lag = runtimeA.getReplicationLag();
206
+ expect(lag.node_id).toBe('node-a');
207
+ expect(lag.peers).toHaveLength(1);
208
+ expect(lag.peers[0].peer_id).toBe('node-b');
209
+ expect(lag.peers[0].reachable).toBe(true);
210
+ expect(lag.peers[0].events_behind).toBeGreaterThanOrEqual(0);
211
+ expect(lag.peers[0].last_sync_at).not.toBeNull();
212
+ expect(lag.peers[0].last_sync_duration_ms).toBeGreaterThanOrEqual(0);
213
+ expect(lag.last_sync_at).not.toBeNull();
214
+
215
+ await runtimeA.stop();
216
+ await runtimeB.stop();
217
+ dbA.close();
218
+ dbB.close();
219
+ });
220
+ });
221
+
222
+ async function getFreePort(): Promise<number> {
223
+ return new Promise((resolve, reject) => {
224
+ const server = createServer();
225
+ server.once('error', reject);
226
+ server.listen(0, '127.0.0.1', () => {
227
+ const address = server.address();
228
+ if (!address || typeof address === 'string') {
229
+ server.close(() => reject(new Error('Failed to allocate free port')));
230
+ return;
231
+ }
232
+
233
+ const port = address.port;
234
+ server.close(err => {
235
+ if (err) {
236
+ reject(err);
237
+ return;
238
+ }
239
+ resolve(port);
240
+ });
241
+ });
242
+ });
243
+ }
244
+
245
+ async function canListenOnLocalhost(): Promise<boolean> {
246
+ try {
247
+ await getFreePort();
248
+ return true;
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ async function startRuntimeWithRetries(
255
+ hiveDir: string,
256
+ baseConfig: Omit<ClusterConfig, 'listen_port' | 'public_url'>,
257
+ attempts = 5
258
+ ): Promise<{ runtime: ClusterRuntime; config: ClusterConfig }> {
259
+ let lastError: unknown;
260
+
261
+ for (let i = 0; i < attempts; i++) {
262
+ const port = await getFreePort();
263
+ const config: ClusterConfig = {
264
+ ...baseConfig,
265
+ listen_port: port,
266
+ public_url: `http://127.0.0.1:${port}`,
267
+ };
268
+
269
+ const runtime = new ClusterRuntime(config, { hiveDir });
270
+
271
+ try {
272
+ await runtime.start();
273
+ return { runtime, config };
274
+ } catch (error) {
275
+ lastError = error;
276
+ const err = error as NodeJS.ErrnoException;
277
+ if (err.code !== 'EADDRINUSE') {
278
+ throw error;
279
+ }
280
+ }
281
+ }
282
+
283
+ throw lastError instanceof Error ? lastError : new Error('Failed to start cluster runtime');
284
+ }
@@ -4,6 +4,7 @@
4
4
  export type {
5
5
  ClusterEvent,
6
6
  ClusterEventVersion,
7
+ RaftSnapshot,
7
8
  ReplicatedTable,
8
9
  ReplicationOp,
9
10
  VersionVector,
@@ -13,8 +14,13 @@ export type {
13
14
  export {
14
15
  ensureClusterTables,
15
16
  getAllClusterEvents,
17
+ getClusterEventCount,
16
18
  getDeltaEvents,
19
+ getEffectiveVersionVector,
20
+ getSnapshotVersionVector,
17
21
  getVersionVector,
22
+ pruneClusterEvents,
23
+ setSnapshotVersionVector,
18
24
  } from './events.js';
19
25
 
20
26
  // Re-export sync functions