@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.
- package/LICENSE +201 -0
- package/NOTICE +16 -0
- package/README.md +480 -0
- package/SECURITY.md +30 -0
- package/bin/git-warp +24 -0
- package/bin/warp-graph.js +1574 -0
- package/index.d.ts +2366 -0
- package/index.js +180 -0
- package/package.json +129 -0
- package/scripts/install-git-warp.sh +258 -0
- package/scripts/uninstall-git-warp.sh +139 -0
- package/src/domain/WarpGraph.js +3157 -0
- package/src/domain/crdt/Dot.js +160 -0
- package/src/domain/crdt/LWW.js +154 -0
- package/src/domain/crdt/ORSet.js +371 -0
- package/src/domain/crdt/VersionVector.js +222 -0
- package/src/domain/entities/GraphNode.js +60 -0
- package/src/domain/errors/EmptyMessageError.js +47 -0
- package/src/domain/errors/ForkError.js +30 -0
- package/src/domain/errors/IndexError.js +23 -0
- package/src/domain/errors/OperationAbortedError.js +22 -0
- package/src/domain/errors/QueryError.js +39 -0
- package/src/domain/errors/SchemaUnsupportedError.js +17 -0
- package/src/domain/errors/ShardCorruptionError.js +56 -0
- package/src/domain/errors/ShardLoadError.js +57 -0
- package/src/domain/errors/ShardValidationError.js +61 -0
- package/src/domain/errors/StorageError.js +57 -0
- package/src/domain/errors/SyncError.js +30 -0
- package/src/domain/errors/TraversalError.js +23 -0
- package/src/domain/errors/WarpError.js +31 -0
- package/src/domain/errors/WormholeError.js +28 -0
- package/src/domain/errors/WriterError.js +39 -0
- package/src/domain/errors/index.js +21 -0
- package/src/domain/services/AnchorMessageCodec.js +99 -0
- package/src/domain/services/BitmapIndexBuilder.js +225 -0
- package/src/domain/services/BitmapIndexReader.js +435 -0
- package/src/domain/services/BoundaryTransitionRecord.js +463 -0
- package/src/domain/services/CheckpointMessageCodec.js +147 -0
- package/src/domain/services/CheckpointSerializerV5.js +281 -0
- package/src/domain/services/CheckpointService.js +384 -0
- package/src/domain/services/CommitDagTraversalService.js +156 -0
- package/src/domain/services/DagPathFinding.js +712 -0
- package/src/domain/services/DagTopology.js +239 -0
- package/src/domain/services/DagTraversal.js +245 -0
- package/src/domain/services/Frontier.js +108 -0
- package/src/domain/services/GCMetrics.js +101 -0
- package/src/domain/services/GCPolicy.js +122 -0
- package/src/domain/services/GitLogParser.js +205 -0
- package/src/domain/services/HealthCheckService.js +246 -0
- package/src/domain/services/HookInstaller.js +326 -0
- package/src/domain/services/HttpSyncServer.js +262 -0
- package/src/domain/services/IndexRebuildService.js +426 -0
- package/src/domain/services/IndexStalenessChecker.js +103 -0
- package/src/domain/services/JoinReducer.js +582 -0
- package/src/domain/services/KeyCodec.js +113 -0
- package/src/domain/services/LegacyAnchorDetector.js +67 -0
- package/src/domain/services/LogicalTraversal.js +351 -0
- package/src/domain/services/MessageCodecInternal.js +132 -0
- package/src/domain/services/MessageSchemaDetector.js +145 -0
- package/src/domain/services/MigrationService.js +55 -0
- package/src/domain/services/ObserverView.js +265 -0
- package/src/domain/services/PatchBuilderV2.js +669 -0
- package/src/domain/services/PatchMessageCodec.js +140 -0
- package/src/domain/services/ProvenanceIndex.js +337 -0
- package/src/domain/services/ProvenancePayload.js +242 -0
- package/src/domain/services/QueryBuilder.js +835 -0
- package/src/domain/services/StateDiff.js +300 -0
- package/src/domain/services/StateSerializerV5.js +156 -0
- package/src/domain/services/StreamingBitmapIndexBuilder.js +709 -0
- package/src/domain/services/SyncProtocol.js +593 -0
- package/src/domain/services/TemporalQuery.js +201 -0
- package/src/domain/services/TranslationCost.js +221 -0
- package/src/domain/services/TraversalService.js +8 -0
- package/src/domain/services/WarpMessageCodec.js +29 -0
- package/src/domain/services/WarpStateIndexBuilder.js +127 -0
- package/src/domain/services/WormholeService.js +353 -0
- package/src/domain/types/TickReceipt.js +285 -0
- package/src/domain/types/WarpTypes.js +209 -0
- package/src/domain/types/WarpTypesV2.js +200 -0
- package/src/domain/utils/CachedValue.js +140 -0
- package/src/domain/utils/EventId.js +89 -0
- package/src/domain/utils/LRUCache.js +112 -0
- package/src/domain/utils/MinHeap.js +114 -0
- package/src/domain/utils/RefLayout.js +280 -0
- package/src/domain/utils/WriterId.js +205 -0
- package/src/domain/utils/cancellation.js +33 -0
- package/src/domain/utils/canonicalStringify.js +42 -0
- package/src/domain/utils/defaultClock.js +20 -0
- package/src/domain/utils/defaultCodec.js +51 -0
- package/src/domain/utils/nullLogger.js +21 -0
- package/src/domain/utils/roaring.js +181 -0
- package/src/domain/utils/shardVersion.js +9 -0
- package/src/domain/warp/PatchSession.js +217 -0
- package/src/domain/warp/Writer.js +181 -0
- package/src/hooks/post-merge.sh +60 -0
- package/src/infrastructure/adapters/BunHttpAdapter.js +225 -0
- package/src/infrastructure/adapters/ClockAdapter.js +57 -0
- package/src/infrastructure/adapters/ConsoleLogger.js +150 -0
- package/src/infrastructure/adapters/DenoHttpAdapter.js +230 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +787 -0
- package/src/infrastructure/adapters/GlobalClockAdapter.js +5 -0
- package/src/infrastructure/adapters/NoOpLogger.js +62 -0
- package/src/infrastructure/adapters/NodeCryptoAdapter.js +32 -0
- package/src/infrastructure/adapters/NodeHttpAdapter.js +98 -0
- package/src/infrastructure/adapters/PerformanceClockAdapter.js +5 -0
- package/src/infrastructure/adapters/WebCryptoAdapter.js +121 -0
- package/src/infrastructure/codecs/CborCodec.js +384 -0
- package/src/ports/BlobPort.js +30 -0
- package/src/ports/ClockPort.js +25 -0
- package/src/ports/CodecPort.js +25 -0
- package/src/ports/CommitPort.js +114 -0
- package/src/ports/ConfigPort.js +31 -0
- package/src/ports/CryptoPort.js +38 -0
- package/src/ports/GraphPersistencePort.js +57 -0
- package/src/ports/HttpServerPort.js +25 -0
- package/src/ports/IndexStoragePort.js +39 -0
- package/src/ports/LoggerPort.js +68 -0
- package/src/ports/RefPort.js +51 -0
- package/src/ports/TreePort.js +51 -0
- package/src/visualization/index.js +26 -0
- package/src/visualization/layouts/converters.js +75 -0
- package/src/visualization/layouts/elkAdapter.js +86 -0
- package/src/visualization/layouts/elkLayout.js +95 -0
- package/src/visualization/layouts/index.js +29 -0
- package/src/visualization/renderers/ascii/box.js +16 -0
- package/src/visualization/renderers/ascii/check.js +271 -0
- package/src/visualization/renderers/ascii/colors.js +13 -0
- package/src/visualization/renderers/ascii/formatters.js +73 -0
- package/src/visualization/renderers/ascii/graph.js +344 -0
- package/src/visualization/renderers/ascii/history.js +335 -0
- package/src/visualization/renderers/ascii/index.js +14 -0
- package/src/visualization/renderers/ascii/info.js +245 -0
- package/src/visualization/renderers/ascii/materialize.js +255 -0
- package/src/visualization/renderers/ascii/path.js +240 -0
- package/src/visualization/renderers/ascii/progress.js +32 -0
- package/src/visualization/renderers/ascii/symbols.js +33 -0
- package/src/visualization/renderers/ascii/table.js +19 -0
- package/src/visualization/renderers/browser/index.js +1 -0
- package/src/visualization/renderers/svg/index.js +159 -0
- package/src/visualization/utils/ansi.js +14 -0
- package/src/visualization/utils/time.js +40 -0
- package/src/visualization/utils/truncate.js +40 -0
- 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
|
+
}
|