@git-stunts/git-warp 10.1.1

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 (143) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +16 -0
  3. package/README.md +480 -0
  4. package/SECURITY.md +30 -0
  5. package/bin/git-warp +24 -0
  6. package/bin/warp-graph.js +1574 -0
  7. package/index.d.ts +2366 -0
  8. package/index.js +180 -0
  9. package/package.json +129 -0
  10. package/scripts/install-git-warp.sh +258 -0
  11. package/scripts/uninstall-git-warp.sh +139 -0
  12. package/src/domain/WarpGraph.js +3157 -0
  13. package/src/domain/crdt/Dot.js +160 -0
  14. package/src/domain/crdt/LWW.js +154 -0
  15. package/src/domain/crdt/ORSet.js +371 -0
  16. package/src/domain/crdt/VersionVector.js +222 -0
  17. package/src/domain/entities/GraphNode.js +60 -0
  18. package/src/domain/errors/EmptyMessageError.js +47 -0
  19. package/src/domain/errors/ForkError.js +30 -0
  20. package/src/domain/errors/IndexError.js +23 -0
  21. package/src/domain/errors/OperationAbortedError.js +22 -0
  22. package/src/domain/errors/QueryError.js +39 -0
  23. package/src/domain/errors/SchemaUnsupportedError.js +17 -0
  24. package/src/domain/errors/ShardCorruptionError.js +56 -0
  25. package/src/domain/errors/ShardLoadError.js +57 -0
  26. package/src/domain/errors/ShardValidationError.js +61 -0
  27. package/src/domain/errors/StorageError.js +57 -0
  28. package/src/domain/errors/SyncError.js +30 -0
  29. package/src/domain/errors/TraversalError.js +23 -0
  30. package/src/domain/errors/WarpError.js +31 -0
  31. package/src/domain/errors/WormholeError.js +28 -0
  32. package/src/domain/errors/WriterError.js +39 -0
  33. package/src/domain/errors/index.js +21 -0
  34. package/src/domain/services/AnchorMessageCodec.js +99 -0
  35. package/src/domain/services/BitmapIndexBuilder.js +225 -0
  36. package/src/domain/services/BitmapIndexReader.js +435 -0
  37. package/src/domain/services/BoundaryTransitionRecord.js +463 -0
  38. package/src/domain/services/CheckpointMessageCodec.js +147 -0
  39. package/src/domain/services/CheckpointSerializerV5.js +281 -0
  40. package/src/domain/services/CheckpointService.js +384 -0
  41. package/src/domain/services/CommitDagTraversalService.js +156 -0
  42. package/src/domain/services/DagPathFinding.js +712 -0
  43. package/src/domain/services/DagTopology.js +239 -0
  44. package/src/domain/services/DagTraversal.js +245 -0
  45. package/src/domain/services/Frontier.js +108 -0
  46. package/src/domain/services/GCMetrics.js +101 -0
  47. package/src/domain/services/GCPolicy.js +122 -0
  48. package/src/domain/services/GitLogParser.js +205 -0
  49. package/src/domain/services/HealthCheckService.js +246 -0
  50. package/src/domain/services/HookInstaller.js +326 -0
  51. package/src/domain/services/HttpSyncServer.js +262 -0
  52. package/src/domain/services/IndexRebuildService.js +426 -0
  53. package/src/domain/services/IndexStalenessChecker.js +103 -0
  54. package/src/domain/services/JoinReducer.js +582 -0
  55. package/src/domain/services/KeyCodec.js +113 -0
  56. package/src/domain/services/LegacyAnchorDetector.js +67 -0
  57. package/src/domain/services/LogicalTraversal.js +351 -0
  58. package/src/domain/services/MessageCodecInternal.js +132 -0
  59. package/src/domain/services/MessageSchemaDetector.js +145 -0
  60. package/src/domain/services/MigrationService.js +55 -0
  61. package/src/domain/services/ObserverView.js +265 -0
  62. package/src/domain/services/PatchBuilderV2.js +669 -0
  63. package/src/domain/services/PatchMessageCodec.js +140 -0
  64. package/src/domain/services/ProvenanceIndex.js +337 -0
  65. package/src/domain/services/ProvenancePayload.js +242 -0
  66. package/src/domain/services/QueryBuilder.js +835 -0
  67. package/src/domain/services/StateDiff.js +300 -0
  68. package/src/domain/services/StateSerializerV5.js +156 -0
  69. package/src/domain/services/StreamingBitmapIndexBuilder.js +709 -0
  70. package/src/domain/services/SyncProtocol.js +593 -0
  71. package/src/domain/services/TemporalQuery.js +201 -0
  72. package/src/domain/services/TranslationCost.js +221 -0
  73. package/src/domain/services/TraversalService.js +8 -0
  74. package/src/domain/services/WarpMessageCodec.js +29 -0
  75. package/src/domain/services/WarpStateIndexBuilder.js +127 -0
  76. package/src/domain/services/WormholeService.js +353 -0
  77. package/src/domain/types/TickReceipt.js +285 -0
  78. package/src/domain/types/WarpTypes.js +209 -0
  79. package/src/domain/types/WarpTypesV2.js +200 -0
  80. package/src/domain/utils/CachedValue.js +140 -0
  81. package/src/domain/utils/EventId.js +89 -0
  82. package/src/domain/utils/LRUCache.js +112 -0
  83. package/src/domain/utils/MinHeap.js +114 -0
  84. package/src/domain/utils/RefLayout.js +280 -0
  85. package/src/domain/utils/WriterId.js +205 -0
  86. package/src/domain/utils/cancellation.js +33 -0
  87. package/src/domain/utils/canonicalStringify.js +42 -0
  88. package/src/domain/utils/defaultClock.js +20 -0
  89. package/src/domain/utils/defaultCodec.js +51 -0
  90. package/src/domain/utils/nullLogger.js +21 -0
  91. package/src/domain/utils/roaring.js +181 -0
  92. package/src/domain/utils/shardVersion.js +9 -0
  93. package/src/domain/warp/PatchSession.js +217 -0
  94. package/src/domain/warp/Writer.js +181 -0
  95. package/src/hooks/post-merge.sh +60 -0
  96. package/src/infrastructure/adapters/BunHttpAdapter.js +225 -0
  97. package/src/infrastructure/adapters/ClockAdapter.js +57 -0
  98. package/src/infrastructure/adapters/ConsoleLogger.js +150 -0
  99. package/src/infrastructure/adapters/DenoHttpAdapter.js +230 -0
  100. package/src/infrastructure/adapters/GitGraphAdapter.js +787 -0
  101. package/src/infrastructure/adapters/GlobalClockAdapter.js +5 -0
  102. package/src/infrastructure/adapters/NoOpLogger.js +62 -0
  103. package/src/infrastructure/adapters/NodeCryptoAdapter.js +32 -0
  104. package/src/infrastructure/adapters/NodeHttpAdapter.js +98 -0
  105. package/src/infrastructure/adapters/PerformanceClockAdapter.js +5 -0
  106. package/src/infrastructure/adapters/WebCryptoAdapter.js +121 -0
  107. package/src/infrastructure/codecs/CborCodec.js +384 -0
  108. package/src/ports/BlobPort.js +30 -0
  109. package/src/ports/ClockPort.js +25 -0
  110. package/src/ports/CodecPort.js +25 -0
  111. package/src/ports/CommitPort.js +114 -0
  112. package/src/ports/ConfigPort.js +31 -0
  113. package/src/ports/CryptoPort.js +38 -0
  114. package/src/ports/GraphPersistencePort.js +57 -0
  115. package/src/ports/HttpServerPort.js +25 -0
  116. package/src/ports/IndexStoragePort.js +39 -0
  117. package/src/ports/LoggerPort.js +68 -0
  118. package/src/ports/RefPort.js +51 -0
  119. package/src/ports/TreePort.js +51 -0
  120. package/src/visualization/index.js +26 -0
  121. package/src/visualization/layouts/converters.js +75 -0
  122. package/src/visualization/layouts/elkAdapter.js +86 -0
  123. package/src/visualization/layouts/elkLayout.js +95 -0
  124. package/src/visualization/layouts/index.js +29 -0
  125. package/src/visualization/renderers/ascii/box.js +16 -0
  126. package/src/visualization/renderers/ascii/check.js +271 -0
  127. package/src/visualization/renderers/ascii/colors.js +13 -0
  128. package/src/visualization/renderers/ascii/formatters.js +73 -0
  129. package/src/visualization/renderers/ascii/graph.js +344 -0
  130. package/src/visualization/renderers/ascii/history.js +335 -0
  131. package/src/visualization/renderers/ascii/index.js +14 -0
  132. package/src/visualization/renderers/ascii/info.js +245 -0
  133. package/src/visualization/renderers/ascii/materialize.js +255 -0
  134. package/src/visualization/renderers/ascii/path.js +240 -0
  135. package/src/visualization/renderers/ascii/progress.js +32 -0
  136. package/src/visualization/renderers/ascii/symbols.js +33 -0
  137. package/src/visualization/renderers/ascii/table.js +19 -0
  138. package/src/visualization/renderers/browser/index.js +1 -0
  139. package/src/visualization/renderers/svg/index.js +159 -0
  140. package/src/visualization/utils/ansi.js +14 -0
  141. package/src/visualization/utils/time.js +40 -0
  142. package/src/visualization/utils/truncate.js +40 -0
  143. package/src/visualization/utils/unicode.js +52 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * GCPolicy - Garbage collection policy for WARP V5.
3
+ */
4
+
5
+ import { orsetCompact } from '../crdt/ORSet.js';
6
+ import { collectGCMetrics } from './GCMetrics.js';
7
+
8
+ /**
9
+ * @typedef {Object} GCPolicy
10
+ * @property {boolean} enabled - Whether automatic GC is enabled (default: false)
11
+ * @property {number} tombstoneRatioThreshold - Ratio of tombstones that triggers GC (0.0-1.0)
12
+ * @property {number} entryCountThreshold - Total entries that triggers GC
13
+ * @property {number} minPatchesSinceCompaction - Minimum patches between GCs
14
+ * @property {number} maxTimeSinceCompaction - Maximum time (ms) between GCs
15
+ * @property {boolean} compactOnCheckpoint - Whether to auto-compact on checkpoint
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} GCShouldRunResult
20
+ * @property {boolean} shouldRun - Whether GC should run
21
+ * @property {string[]} reasons - Reasons for running (or not)
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} GCExecuteResult
26
+ * @property {number} nodesCompacted - Number of node entries compacted
27
+ * @property {number} edgesCompacted - Number of edge entries compacted
28
+ * @property {number} tombstonesRemoved - Total tombstones removed
29
+ * @property {number} durationMs - Time taken in milliseconds
30
+ */
31
+
32
+ /**
33
+ * @typedef {Object} GCInputMetrics
34
+ * @property {number} tombstoneRatio - Current tombstone ratio
35
+ * @property {number} totalEntries - Total entries in state
36
+ * @property {number} patchesSinceCompaction - Patches applied since last GC
37
+ * @property {number} timeSinceCompaction - Time (ms) since last GC
38
+ */
39
+
40
+ /** @type {Readonly<GCPolicy>} */
41
+ export const DEFAULT_GC_POLICY = Object.freeze({
42
+ enabled: false, // Must opt-in to automatic GC
43
+ tombstoneRatioThreshold: 0.3, // 30% tombstones triggers GC
44
+ entryCountThreshold: 50000, // 50K entries triggers GC
45
+ minPatchesSinceCompaction: 1000, // Min patches between GCs
46
+ maxTimeSinceCompaction: 86400000, // 24 hours max between GCs
47
+ compactOnCheckpoint: true, // Auto-compact on checkpoint
48
+ });
49
+
50
+ /**
51
+ * Determines if GC should run based on metrics and policy.
52
+ * @param {GCInputMetrics} metrics
53
+ * @param {GCPolicy} policy
54
+ * @returns {GCShouldRunResult}
55
+ */
56
+ export function shouldRunGC(metrics, policy) {
57
+ const reasons = [];
58
+
59
+ // Check tombstone ratio threshold
60
+ if (metrics.tombstoneRatio > policy.tombstoneRatioThreshold) {
61
+ reasons.push(
62
+ `Tombstone ratio ${(metrics.tombstoneRatio * 100).toFixed(1)}% exceeds threshold ${(policy.tombstoneRatioThreshold * 100).toFixed(1)}%`
63
+ );
64
+ }
65
+
66
+ // Check entry count threshold
67
+ if (metrics.totalEntries > policy.entryCountThreshold) {
68
+ reasons.push(
69
+ `Entry count ${metrics.totalEntries} exceeds threshold ${policy.entryCountThreshold}`
70
+ );
71
+ }
72
+
73
+ // Check patches since compaction
74
+ if (metrics.patchesSinceCompaction > policy.minPatchesSinceCompaction) {
75
+ reasons.push(
76
+ `Patches since compaction ${metrics.patchesSinceCompaction} exceeds minimum ${policy.minPatchesSinceCompaction}`
77
+ );
78
+ }
79
+
80
+ // Check time since compaction
81
+ if (metrics.timeSinceCompaction > policy.maxTimeSinceCompaction) {
82
+ reasons.push(
83
+ `Time since compaction ${metrics.timeSinceCompaction}ms exceeds maximum ${policy.maxTimeSinceCompaction}ms`
84
+ );
85
+ }
86
+
87
+ return {
88
+ shouldRun: reasons.length > 0,
89
+ reasons,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Executes GC on state. Only compacts tombstoned dots <= appliedVV.
95
+ * Mutates state in place.
96
+ *
97
+ * @param {import('./JoinReducer.js').WarpStateV5} state - State to compact (mutated!)
98
+ * @param {import('../crdt/VersionVector.js').VersionVector} appliedVV - Version vector cutoff
99
+ * @returns {GCExecuteResult}
100
+ */
101
+ export function executeGC(state, appliedVV) {
102
+ const startTime = performance.now();
103
+
104
+ // Collect metrics before compaction
105
+ const beforeMetrics = collectGCMetrics(state);
106
+
107
+ // Compact both ORSets
108
+ orsetCompact(state.nodeAlive, appliedVV);
109
+ orsetCompact(state.edgeAlive, appliedVV);
110
+
111
+ // Collect metrics after compaction
112
+ const afterMetrics = collectGCMetrics(state);
113
+
114
+ const endTime = performance.now();
115
+
116
+ return {
117
+ nodesCompacted: beforeMetrics.nodeEntries - afterMetrics.nodeEntries,
118
+ edgesCompacted: beforeMetrics.edgeEntries - afterMetrics.edgeEntries,
119
+ tombstonesRemoved: beforeMetrics.totalTombstones - afterMetrics.totalTombstones,
120
+ durationMs: endTime - startTime,
121
+ };
122
+ }
@@ -0,0 +1,205 @@
1
+ import GraphNode from '../entities/GraphNode.js';
2
+ import { checkAborted } from '../utils/cancellation.js';
3
+
4
+ /**
5
+ * NUL byte (0x00) - Delimits commit records in git log output.
6
+ *
7
+ * Git commit messages cannot contain NUL bytes - git rejects them at commit time.
8
+ * This makes NUL a perfectly safe delimiter that cannot appear in any field,
9
+ * eliminating the possibility of message injection attacks.
10
+ *
11
+ * The git log format produces records in this exact structure:
12
+ *
13
+ * ```
14
+ * <SHA>\n
15
+ * <author>\n
16
+ * <date>\n
17
+ * <parents (space-separated)>\n
18
+ * <message body (may contain newlines)>\x00
19
+ * ```
20
+ *
21
+ * Fields within each record are separated by newlines. The first 4 lines are
22
+ * fixed fields (SHA, author, date, parents), and all remaining content up to
23
+ * the NUL terminator is the message body.
24
+ *
25
+ * @see https://git-scm.com/docs/git-log (see -z option documentation)
26
+ * @const {string}
27
+ */
28
+ export const RECORD_SEPARATOR = '\x00';
29
+
30
+ /**
31
+ * Parser for git log output streams.
32
+ *
33
+ * Handles UTF-8 decoding, record splitting, and node instantiation as separate
34
+ * concerns. Designed as an injectable dependency for WarpGraph to enable
35
+ * testing and alternative implementations.
36
+ *
37
+ * **Binary-First Processing**: The parser works directly with binary data for
38
+ * performance. Buffer.indexOf(0) is faster than string indexOf('\0') because:
39
+ * - No UTF-8 decoding overhead during scanning
40
+ * - Native C++ implementation in Node.js Buffer
41
+ * - Byte-level comparison vs character-level
42
+ *
43
+ * UTF-8 decoding only happens once per complete record, not during scanning.
44
+ * This is especially beneficial for large commit histories where most of the
45
+ * data is being scanned to find record boundaries.
46
+ *
47
+ * **Log Format Contract**: Each record is NUL-terminated and contains fields
48
+ * separated by newlines:
49
+ * 1. SHA (40 hex chars)
50
+ * 2. Author name
51
+ * 3. Date string
52
+ * 4. Parent SHAs (space-separated, empty string for root commits)
53
+ * 5. Message body (may span multiple lines, everything until NUL terminator)
54
+ *
55
+ * **Why NUL delimiter?** Git commit messages cannot contain NUL bytes - git
56
+ * rejects them at commit time. This makes NUL a perfectly safe record delimiter
57
+ * that eliminates parsing ambiguity, unlike 0x1E which can theoretically appear
58
+ * in message content.
59
+ *
60
+ * @example
61
+ * // Basic usage
62
+ * const parser = new GitLogParser();
63
+ * for await (const node of parser.parse(stream)) {
64
+ * console.log(node.sha, node.message);
65
+ * }
66
+ *
67
+ * @example
68
+ * // Inject into WarpGraph for testing
69
+ * const mockParser = { parse: async function*() { yield mockNode; } };
70
+ * const graph = new WarpGraph({ persistence, parser: mockParser });
71
+ */
72
+ export default class GitLogParser {
73
+ /**
74
+ * Parses a stream of git log output and yields GraphNode instances.
75
+ *
76
+ * **Binary-first processing for performance**:
77
+ * - Accepts Buffer, Uint8Array, or string chunks
78
+ * - Finds NUL bytes (0x00) directly in binary using Buffer.indexOf(0)
79
+ * - Buffer.indexOf(0) is faster than string indexOf('\0') - native C++ vs JS
80
+ * - UTF-8 decoding only happens for complete records, not during scanning
81
+ *
82
+ * Handles:
83
+ * - UTF-8 sequences split across chunk boundaries (via binary accumulation)
84
+ * - Records terminated by NUL bytes (0x00)
85
+ * - Streaming without loading entire history into memory
86
+ * - Backwards compatibility with string chunks
87
+ * - Cancellation via AbortSignal
88
+ *
89
+ * @param {AsyncIterable<Buffer|Uint8Array|string>} stream - The git log output stream.
90
+ * May yield Buffer, Uint8Array, or string chunks.
91
+ * @param {Object} [options] - Parse options
92
+ * @param {AbortSignal} [options.signal] - Optional abort signal for cancellation
93
+ * @yields {GraphNode} Parsed graph nodes. Invalid records are silently skipped.
94
+ * @throws {OperationAbortedError} If signal is aborted during parsing
95
+ *
96
+ * @example
97
+ * const stream = persistence.logNodesStream({ ref: 'main', limit: 100, format });
98
+ * for await (const node of parser.parse(stream)) {
99
+ * console.log(node.sha);
100
+ * }
101
+ *
102
+ * @example
103
+ * // With cancellation support
104
+ * const controller = new AbortController();
105
+ * for await (const node of parser.parse(stream, { signal: controller.signal })) {
106
+ * console.log(node.sha);
107
+ * }
108
+ */
109
+ async *parse(stream, { signal } = {}) {
110
+ let buffer = Buffer.alloc(0); // Binary buffer accumulator
111
+
112
+ for await (const chunk of stream) {
113
+ checkAborted(signal, 'GitLogParser.parse');
114
+
115
+ // Convert string chunks to Buffer, keep Buffer chunks as-is
116
+ const chunkBuffer =
117
+ typeof chunk === 'string'
118
+ ? Buffer.from(chunk, 'utf-8')
119
+ : Buffer.isBuffer(chunk)
120
+ ? chunk
121
+ : Buffer.from(chunk); // Uint8Array
122
+
123
+ // Append to accumulator
124
+ buffer = Buffer.concat([buffer, chunkBuffer]);
125
+
126
+ // Find NUL bytes (0x00) in binary - faster than string indexOf
127
+ let nullIndex;
128
+ while ((nullIndex = buffer.indexOf(0)) !== -1) {
129
+ checkAborted(signal, 'GitLogParser.parse');
130
+
131
+ // Extract record bytes and decode to string
132
+ const recordBytes = buffer.subarray(0, nullIndex);
133
+ buffer = buffer.subarray(nullIndex + 1);
134
+
135
+ // Only decode UTF-8 for complete records
136
+ const block = recordBytes.toString('utf-8');
137
+ const node = this.parseNode(block);
138
+ if (node) {
139
+ yield node;
140
+ }
141
+ }
142
+ }
143
+
144
+ // Process any remaining data (final record without trailing NUL)
145
+ if (buffer.length > 0) {
146
+ const block = buffer.toString('utf-8');
147
+ if (block) {
148
+ const node = this.parseNode(block);
149
+ if (node) {
150
+ yield node;
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Parses a single record block into a GraphNode.
158
+ *
159
+ * Expected format (fields separated by newlines):
160
+ * - Line 0: SHA (required, non-empty)
161
+ * - Line 1: Author name
162
+ * - Line 2: Date string
163
+ * - Line 3: Parent SHAs (space-separated, may be empty for root commits)
164
+ * - Lines 4+: Message body (preserved exactly, not trimmed)
165
+ *
166
+ * The block should not include the trailing NUL terminator - that is stripped
167
+ * by the parse() method before calling parseNode().
168
+ *
169
+ * @param {string} block - Raw block text (without trailing NUL terminator)
170
+ * @returns {GraphNode|null} Parsed node, or null if the block is malformed
171
+ * or has an empty message (GraphNode requires non-empty message)
172
+ *
173
+ * @example
174
+ * const block = 'abc123\nAuthor\n2026-01-28\nparent1 parent2\nCommit message';
175
+ * const node = parser.parseNode(block);
176
+ * // node.sha === 'abc123'
177
+ * // node.parents === ['parent1', 'parent2']
178
+ */
179
+ parseNode(block) {
180
+ const lines = block.split('\n');
181
+ // Need at least 4 lines: SHA, author, date, parents
182
+ // Message (lines 4+) may be empty
183
+ if (lines.length < 4) {
184
+ return null;
185
+ }
186
+
187
+ const sha = lines[0];
188
+ if (!sha) {
189
+ return null;
190
+ }
191
+
192
+ const author = lines[1];
193
+ const date = lines[2];
194
+ const parents = lines[3] ? lines[3].split(' ').filter(Boolean) : [];
195
+ // Preserve message exactly as-is (may be empty, may have leading/trailing whitespace)
196
+ const message = lines.slice(4).join('\n');
197
+
198
+ // GraphNode requires non-empty message, return null for empty
199
+ if (!message) {
200
+ return null;
201
+ }
202
+
203
+ return new GraphNode({ sha, author, date, message, parents });
204
+ }
205
+ }
@@ -0,0 +1,246 @@
1
+ import nullLogger from '../utils/nullLogger.js';
2
+ import CachedValue from '../utils/CachedValue.js';
3
+
4
+ /**
5
+ * Default TTL for health check cache in milliseconds.
6
+ * @const {number}
7
+ */
8
+ const DEFAULT_CACHE_TTL_MS = 5000;
9
+
10
+ /**
11
+ * Health status constants.
12
+ * @readonly
13
+ * @enum {string}
14
+ */
15
+ export const HealthStatus = {
16
+ HEALTHY: 'healthy',
17
+ DEGRADED: 'degraded',
18
+ UNHEALTHY: 'unhealthy',
19
+ };
20
+
21
+ /**
22
+ * Service for performing health checks on the graph system.
23
+ *
24
+ * Follows hexagonal architecture by depending on ports, not adapters.
25
+ * Provides K8s-style probes (liveness vs readiness) and detailed component health.
26
+ *
27
+ * @example
28
+ * const healthService = new HealthCheckService({
29
+ * persistence,
30
+ * clock,
31
+ * cacheTtlMs: 10000,
32
+ * logger,
33
+ * });
34
+ *
35
+ * // K8s liveness probe - am I running?
36
+ * const alive = await healthService.isAlive();
37
+ *
38
+ * // K8s readiness probe - can I serve requests?
39
+ * const ready = await healthService.isReady();
40
+ *
41
+ * // Detailed health breakdown
42
+ * const health = await healthService.getHealth();
43
+ * console.log(health.status); // 'healthy' | 'degraded' | 'unhealthy'
44
+ */
45
+ export default class HealthCheckService {
46
+ /**
47
+ * Creates a HealthCheckService instance.
48
+ * @param {Object} options
49
+ * @param {import('../../ports/GraphPersistencePort.js').default} options.persistence - Persistence port for repository checks
50
+ * @param {import('../../ports/ClockPort.js').default} options.clock - Clock port for timing operations
51
+ * @param {number} [options.cacheTtlMs=5000] - How long to cache health results in milliseconds
52
+ * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger for structured logging
53
+ */
54
+ constructor({ persistence, clock, cacheTtlMs = DEFAULT_CACHE_TTL_MS, logger = nullLogger }) {
55
+ this._persistence = persistence;
56
+ this._clock = clock;
57
+ this._logger = logger;
58
+
59
+ /** @type {import('./BitmapIndexReader.js').default|null} */
60
+ this._indexReader = null;
61
+
62
+ // Health check cache
63
+ this._healthCache = new CachedValue({
64
+ clock,
65
+ ttlMs: cacheTtlMs,
66
+ compute: () => this._computeHealth(),
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Sets the index reader for index health checks.
72
+ * Call this when an index is loaded.
73
+ *
74
+ * @param {import('./BitmapIndexReader.js').default|null} reader - The index reader, or null to clear
75
+ * @returns {void}
76
+ */
77
+ setIndexReader(reader) {
78
+ this._indexReader = reader;
79
+ this._healthCache.invalidate();
80
+ }
81
+
82
+ /**
83
+ * K8s-style liveness probe: Is the service running?
84
+ *
85
+ * Returns true if the repository is accessible.
86
+ * A failed liveness check typically triggers a container restart.
87
+ *
88
+ * @returns {Promise<boolean>}
89
+ */
90
+ async isAlive() {
91
+ const health = await this.getHealth();
92
+ // Alive if repository is reachable (even if degraded)
93
+ return health.components.repository.status !== HealthStatus.UNHEALTHY;
94
+ }
95
+
96
+ /**
97
+ * K8s-style readiness probe: Can the service serve requests?
98
+ *
99
+ * Returns true if all critical components are healthy.
100
+ * A failed readiness check removes the pod from load balancer.
101
+ *
102
+ * @returns {Promise<boolean>}
103
+ */
104
+ async isReady() {
105
+ const health = await this.getHealth();
106
+ return health.status === HealthStatus.HEALTHY;
107
+ }
108
+
109
+ /**
110
+ * Gets detailed health information for all components.
111
+ *
112
+ * Results are cached for the configured TTL to prevent
113
+ * excessive health check calls under load.
114
+ *
115
+ * @returns {Promise<HealthResult>}
116
+ *
117
+ * @typedef {Object} HealthResult
118
+ * @property {'healthy'|'degraded'|'unhealthy'} status - Overall health status
119
+ * @property {Object} components - Component health breakdown
120
+ * @property {RepositoryHealth} components.repository - Repository health
121
+ * @property {IndexHealth} components.index - Index health
122
+ * @property {string} [cachedAt] - ISO timestamp if result is cached
123
+ *
124
+ * @typedef {Object} RepositoryHealth
125
+ * @property {'healthy'|'unhealthy'} status - Repository status
126
+ * @property {number} latencyMs - Ping latency in milliseconds
127
+ *
128
+ * @typedef {Object} IndexHealth
129
+ * @property {'healthy'|'degraded'|'unhealthy'} status - Index status
130
+ * @property {boolean} loaded - Whether an index is loaded
131
+ * @property {number} [shardCount] - Number of shards (if loaded)
132
+ */
133
+ async getHealth() {
134
+ const { value, cachedAt, fromCache } = await this._healthCache.getWithMetadata();
135
+
136
+ if (cachedAt) {
137
+ return { ...value, cachedAt };
138
+ }
139
+
140
+ // Log only for fresh computations
141
+ if (!fromCache) {
142
+ this._logger.debug('Health check completed', {
143
+ operation: 'getHealth',
144
+ status: value.status,
145
+ repositoryStatus: value.components.repository.status,
146
+ indexStatus: value.components.index.status,
147
+ });
148
+ }
149
+
150
+ return value;
151
+ }
152
+
153
+ /**
154
+ * Computes health by checking all components.
155
+ * This is called by CachedValue when the cache is stale.
156
+ * @returns {Promise<Object>}
157
+ * @private
158
+ */
159
+ async _computeHealth() {
160
+ // Check repository health
161
+ const repositoryHealth = await this._checkRepository();
162
+
163
+ // Check index health
164
+ const indexHealth = this._checkIndex();
165
+
166
+ // Determine overall status
167
+ const status = this._computeOverallStatus(repositoryHealth, indexHealth);
168
+
169
+ return {
170
+ status,
171
+ components: {
172
+ repository: repositoryHealth,
173
+ index: indexHealth,
174
+ },
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Checks repository health by pinging the persistence layer.
180
+ * @returns {Promise<RepositoryHealth>}
181
+ * @private
182
+ */
183
+ async _checkRepository() {
184
+ try {
185
+ const pingResult = await this._persistence.ping();
186
+ return {
187
+ status: pingResult.ok ? HealthStatus.HEALTHY : HealthStatus.UNHEALTHY,
188
+ latencyMs: Math.round(pingResult.latencyMs * 100) / 100, // Round to 2 decimal places
189
+ };
190
+ } catch (err) {
191
+ this._logger.warn('Repository ping failed', {
192
+ operation: 'checkRepository',
193
+ error: err.message,
194
+ });
195
+ return {
196
+ status: HealthStatus.UNHEALTHY,
197
+ latencyMs: 0,
198
+ };
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Checks index health based on loaded state and shard count.
204
+ * @returns {IndexHealth}
205
+ * @private
206
+ */
207
+ _checkIndex() {
208
+ if (!this._indexReader) {
209
+ return {
210
+ status: HealthStatus.DEGRADED,
211
+ loaded: false,
212
+ };
213
+ }
214
+
215
+ // Index is loaded - count shards
216
+ const shardCount = this._indexReader.shardOids?.size ?? 0;
217
+
218
+ return {
219
+ status: HealthStatus.HEALTHY,
220
+ loaded: true,
221
+ shardCount,
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Computes overall health status from component health.
227
+ * @param {RepositoryHealth} repositoryHealth
228
+ * @param {IndexHealth} indexHealth
229
+ * @returns {'healthy'|'degraded'|'unhealthy'}
230
+ * @private
231
+ */
232
+ _computeOverallStatus(repositoryHealth, indexHealth) {
233
+ // If repository is unhealthy, overall is unhealthy
234
+ if (repositoryHealth.status === HealthStatus.UNHEALTHY) {
235
+ return HealthStatus.UNHEALTHY;
236
+ }
237
+
238
+ // If index is degraded (not loaded), overall is degraded
239
+ if (indexHealth.status === HealthStatus.DEGRADED) {
240
+ return HealthStatus.DEGRADED;
241
+ }
242
+
243
+ // All components healthy
244
+ return HealthStatus.HEALTHY;
245
+ }
246
+ }