@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,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A simple LRU (Least Recently Used) cache implementation.
|
|
3
|
+
*
|
|
4
|
+
* Uses Map's insertion order to track access recency. When the cache
|
|
5
|
+
* exceeds maxSize, the oldest (least recently used) entry is evicted.
|
|
6
|
+
*
|
|
7
|
+
* @class LRUCache
|
|
8
|
+
* @template K, V
|
|
9
|
+
*/
|
|
10
|
+
class LRUCache {
|
|
11
|
+
/**
|
|
12
|
+
* Creates an LRU cache with the specified maximum size.
|
|
13
|
+
*
|
|
14
|
+
* @param {number} maxSize - Maximum number of entries to cache
|
|
15
|
+
* @throws {Error} If maxSize is not a positive integer
|
|
16
|
+
*/
|
|
17
|
+
constructor(maxSize) {
|
|
18
|
+
if (!Number.isInteger(maxSize) || maxSize < 1) {
|
|
19
|
+
throw new Error('LRUCache maxSize must be a positive integer');
|
|
20
|
+
}
|
|
21
|
+
/** @type {number} */
|
|
22
|
+
this.maxSize = maxSize;
|
|
23
|
+
/** @type {Map<K, V>} */
|
|
24
|
+
this._cache = new Map();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Gets a value from the cache and marks it as recently used.
|
|
29
|
+
*
|
|
30
|
+
* @param {K} key - The key to look up
|
|
31
|
+
* @returns {V|undefined} The cached value, or undefined if not found
|
|
32
|
+
*/
|
|
33
|
+
get(key) {
|
|
34
|
+
if (!this._cache.has(key)) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
// Move to end (most recently used) by deleting and re-inserting
|
|
38
|
+
const value = this._cache.get(key);
|
|
39
|
+
this._cache.delete(key);
|
|
40
|
+
this._cache.set(key, value);
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Sets a value in the cache, evicting the oldest entry if at capacity.
|
|
46
|
+
*
|
|
47
|
+
* If the key already exists, it is updated and marked as recently used.
|
|
48
|
+
*
|
|
49
|
+
* @param {K} key - The key to set
|
|
50
|
+
* @param {V} value - The value to cache
|
|
51
|
+
* @returns {LRUCache} The cache instance for chaining
|
|
52
|
+
*/
|
|
53
|
+
set(key, value) {
|
|
54
|
+
// If key exists, delete it first so it moves to the end
|
|
55
|
+
if (this._cache.has(key)) {
|
|
56
|
+
this._cache.delete(key);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Add the new entry
|
|
60
|
+
this._cache.set(key, value);
|
|
61
|
+
|
|
62
|
+
// Evict oldest entry if over capacity
|
|
63
|
+
if (this._cache.size > this.maxSize) {
|
|
64
|
+
const oldestKey = this._cache.keys().next().value;
|
|
65
|
+
this._cache.delete(oldestKey);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Checks if a key exists in the cache.
|
|
73
|
+
*
|
|
74
|
+
* Note: This does NOT update the access order (use get() for that).
|
|
75
|
+
*
|
|
76
|
+
* @param {K} key - The key to check
|
|
77
|
+
* @returns {boolean} True if the key exists
|
|
78
|
+
*/
|
|
79
|
+
has(key) {
|
|
80
|
+
return this._cache.has(key);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Deletes an entry from the cache.
|
|
85
|
+
*
|
|
86
|
+
* @param {K} key - The key to delete
|
|
87
|
+
* @returns {boolean} True if the entry was deleted, false if it didn't exist
|
|
88
|
+
*/
|
|
89
|
+
delete(key) {
|
|
90
|
+
return this._cache.delete(key);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Clears all entries from the cache.
|
|
95
|
+
*
|
|
96
|
+
* @returns {void}
|
|
97
|
+
*/
|
|
98
|
+
clear() {
|
|
99
|
+
this._cache.clear();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Gets the current number of entries in the cache.
|
|
104
|
+
*
|
|
105
|
+
* @returns {number} The number of cached entries
|
|
106
|
+
*/
|
|
107
|
+
get size() {
|
|
108
|
+
return this._cache.size;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export default LRUCache;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MinHeap/PriorityQueue implementation optimized for Dijkstra's algorithm.
|
|
3
|
+
* Items with lowest priority are extracted first.
|
|
4
|
+
*
|
|
5
|
+
* @class MinHeap
|
|
6
|
+
*/
|
|
7
|
+
class MinHeap {
|
|
8
|
+
/**
|
|
9
|
+
* Creates an empty MinHeap.
|
|
10
|
+
*/
|
|
11
|
+
constructor() {
|
|
12
|
+
/** @type {Array<{item: *, priority: number}>} */
|
|
13
|
+
this.heap = [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Insert an item with given priority.
|
|
18
|
+
*
|
|
19
|
+
* @param {*} item - The item to insert
|
|
20
|
+
* @param {number} priority - Priority value (lower = higher priority)
|
|
21
|
+
* @returns {void}
|
|
22
|
+
*/
|
|
23
|
+
insert(item, priority) {
|
|
24
|
+
this.heap.push({ item, priority });
|
|
25
|
+
this._bubbleUp(this.heap.length - 1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract and return the item with minimum priority.
|
|
30
|
+
*
|
|
31
|
+
* @returns {*} The item with lowest priority, or undefined if empty
|
|
32
|
+
*/
|
|
33
|
+
extractMin() {
|
|
34
|
+
if (this.heap.length === 0) { return undefined; }
|
|
35
|
+
if (this.heap.length === 1) { return this.heap.pop().item; }
|
|
36
|
+
|
|
37
|
+
const min = this.heap[0];
|
|
38
|
+
this.heap[0] = this.heap.pop();
|
|
39
|
+
this._bubbleDown(0);
|
|
40
|
+
return min.item;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if the heap is empty.
|
|
45
|
+
*
|
|
46
|
+
* @returns {boolean} True if empty
|
|
47
|
+
*/
|
|
48
|
+
isEmpty() {
|
|
49
|
+
return this.heap.length === 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get the number of items in the heap.
|
|
54
|
+
*
|
|
55
|
+
* @returns {number} Number of items
|
|
56
|
+
*/
|
|
57
|
+
size() {
|
|
58
|
+
return this.heap.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Peek at the minimum priority without removing the item.
|
|
63
|
+
*
|
|
64
|
+
* @returns {number} The minimum priority value, or Infinity if empty
|
|
65
|
+
*/
|
|
66
|
+
peekPriority() {
|
|
67
|
+
return this.heap.length > 0 ? this.heap[0].priority : Infinity;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Restore heap property by bubbling up from index.
|
|
72
|
+
*
|
|
73
|
+
* @private
|
|
74
|
+
* @param {number} pos - Starting index
|
|
75
|
+
*/
|
|
76
|
+
_bubbleUp(pos) {
|
|
77
|
+
let current = pos;
|
|
78
|
+
while (current > 0) {
|
|
79
|
+
const parentIndex = Math.floor((current - 1) / 2);
|
|
80
|
+
if (this.heap[parentIndex].priority <= this.heap[current].priority) { break; }
|
|
81
|
+
[this.heap[parentIndex], this.heap[current]] = [this.heap[current], this.heap[parentIndex]];
|
|
82
|
+
current = parentIndex;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Restore heap property by bubbling down from index.
|
|
88
|
+
*
|
|
89
|
+
* @private
|
|
90
|
+
* @param {number} pos - Starting index
|
|
91
|
+
*/
|
|
92
|
+
_bubbleDown(pos) {
|
|
93
|
+
const {length} = this.heap;
|
|
94
|
+
let current = pos;
|
|
95
|
+
while (true) {
|
|
96
|
+
const leftChild = 2 * current + 1;
|
|
97
|
+
const rightChild = 2 * current + 2;
|
|
98
|
+
let smallest = current;
|
|
99
|
+
|
|
100
|
+
if (leftChild < length && this.heap[leftChild].priority < this.heap[smallest].priority) {
|
|
101
|
+
smallest = leftChild;
|
|
102
|
+
}
|
|
103
|
+
if (rightChild < length && this.heap[rightChild].priority < this.heap[smallest].priority) {
|
|
104
|
+
smallest = rightChild;
|
|
105
|
+
}
|
|
106
|
+
if (smallest === current) { break; }
|
|
107
|
+
|
|
108
|
+
[this.heap[current], this.heap[smallest]] = [this.heap[smallest], this.heap[current]];
|
|
109
|
+
current = smallest;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default MinHeap;
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ref layout constants and helpers for WARP (Write-Ahead Reference Protocol).
|
|
3
|
+
*
|
|
4
|
+
* Provides functions for building, parsing, and validating Git ref paths
|
|
5
|
+
* used by the WARP protocol. All refs live under the refs/warp/ namespace.
|
|
6
|
+
*
|
|
7
|
+
* Ref layout:
|
|
8
|
+
* - refs/warp/<graph>/writers/<writer_id>
|
|
9
|
+
* - refs/warp/<graph>/checkpoints/head
|
|
10
|
+
* - refs/warp/<graph>/coverage/head
|
|
11
|
+
*
|
|
12
|
+
* @module domain/utils/RefLayout
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// -----------------------------------------------------------------------------
|
|
16
|
+
// Constants
|
|
17
|
+
// -----------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The prefix for all warp refs.
|
|
21
|
+
* @type {string}
|
|
22
|
+
*/
|
|
23
|
+
export const REF_PREFIX = 'refs/warp';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Maximum length for a writer ID.
|
|
27
|
+
* @type {number}
|
|
28
|
+
*/
|
|
29
|
+
export const MAX_WRITER_ID_LENGTH = 64;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Regex pattern for valid writer IDs.
|
|
33
|
+
* ASCII ref-safe characters: [A-Za-z0-9._-], 1-64 chars
|
|
34
|
+
* @type {RegExp}
|
|
35
|
+
*/
|
|
36
|
+
const WRITER_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pattern to detect path traversal sequences.
|
|
40
|
+
* @type {RegExp}
|
|
41
|
+
*/
|
|
42
|
+
const PATH_TRAVERSAL_PATTERN = /\.\./;
|
|
43
|
+
|
|
44
|
+
// -----------------------------------------------------------------------------
|
|
45
|
+
// Validators
|
|
46
|
+
// -----------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Validates a graph name and throws if invalid.
|
|
50
|
+
*
|
|
51
|
+
* Graph names must not contain:
|
|
52
|
+
* - Path traversal sequences (`../`)
|
|
53
|
+
* - Semicolons (`;`)
|
|
54
|
+
* - Spaces
|
|
55
|
+
* - Null bytes (`\0`)
|
|
56
|
+
* - Empty strings
|
|
57
|
+
*
|
|
58
|
+
* @param {string} name - The graph name to validate
|
|
59
|
+
* @throws {Error} If the graph name is invalid
|
|
60
|
+
* @returns {void}
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* validateGraphName('events'); // OK
|
|
64
|
+
* validateGraphName('../etc'); // throws
|
|
65
|
+
* validateGraphName('my graph'); // throws
|
|
66
|
+
*/
|
|
67
|
+
export function validateGraphName(name) {
|
|
68
|
+
if (typeof name !== 'string') {
|
|
69
|
+
throw new Error(`Invalid graph name: expected string, got ${typeof name}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (name.length === 0) {
|
|
73
|
+
throw new Error('Invalid graph name: cannot be empty');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (PATH_TRAVERSAL_PATTERN.test(name)) {
|
|
77
|
+
throw new Error(`Invalid graph name: contains path traversal sequence '..': ${name}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (name.includes(';')) {
|
|
81
|
+
throw new Error(`Invalid graph name: contains semicolon: ${name}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (name.includes(' ')) {
|
|
85
|
+
throw new Error(`Invalid graph name: contains space: ${name}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (name.includes('\0')) {
|
|
89
|
+
throw new Error(`Invalid graph name: contains null byte: ${name}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Validates a writer ID and throws if invalid.
|
|
95
|
+
*
|
|
96
|
+
* Writer IDs must:
|
|
97
|
+
* - Be ASCII ref-safe: only [A-Za-z0-9._-]
|
|
98
|
+
* - Be 1-64 characters long
|
|
99
|
+
* - Not contain `/`, `..`, whitespace, or NUL
|
|
100
|
+
*
|
|
101
|
+
* @param {string} id - The writer ID to validate
|
|
102
|
+
* @throws {Error} If the writer ID is invalid
|
|
103
|
+
* @returns {void}
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* validateWriterId('node-1'); // OK
|
|
107
|
+
* validateWriterId('a/b'); // throws (contains /)
|
|
108
|
+
* validateWriterId('x'.repeat(65)); // throws (too long)
|
|
109
|
+
*/
|
|
110
|
+
export function validateWriterId(id) {
|
|
111
|
+
if (typeof id !== 'string') {
|
|
112
|
+
throw new Error(`Invalid writer ID: expected string, got ${typeof id}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (id.length === 0) {
|
|
116
|
+
throw new Error('Invalid writer ID: cannot be empty');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (id.length > MAX_WRITER_ID_LENGTH) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Invalid writer ID: exceeds maximum length of ${MAX_WRITER_ID_LENGTH} characters: ${id.length}`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Check for path traversal before pattern check for clearer error message
|
|
126
|
+
if (PATH_TRAVERSAL_PATTERN.test(id)) {
|
|
127
|
+
throw new Error(`Invalid writer ID: contains path traversal sequence '..': ${id}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check for forward slash before pattern check for clearer error message
|
|
131
|
+
if (id.includes('/')) {
|
|
132
|
+
throw new Error(`Invalid writer ID: contains forward slash: ${id}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Check for null byte
|
|
136
|
+
if (id.includes('\0')) {
|
|
137
|
+
throw new Error(`Invalid writer ID: contains null byte: ${id}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check for whitespace (space, tab, newline, etc.)
|
|
141
|
+
if (/\s/.test(id)) {
|
|
142
|
+
throw new Error(`Invalid writer ID: contains whitespace: ${id}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Check overall pattern for ref-safe characters
|
|
146
|
+
if (!WRITER_ID_PATTERN.test(id)) {
|
|
147
|
+
throw new Error(`Invalid writer ID: contains invalid characters (only [A-Za-z0-9._-] allowed): ${id}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// -----------------------------------------------------------------------------
|
|
152
|
+
// Builders
|
|
153
|
+
// -----------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Builds a writer ref path for the given graph and writer ID.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} graphName - The name of the graph
|
|
159
|
+
* @param {string} writerId - The writer's unique identifier
|
|
160
|
+
* @returns {string} The full ref path
|
|
161
|
+
* @throws {Error} If graphName or writerId is invalid
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* buildWriterRef('events', 'node-1');
|
|
165
|
+
* // => 'refs/warp/events/writers/node-1'
|
|
166
|
+
*/
|
|
167
|
+
export function buildWriterRef(graphName, writerId) {
|
|
168
|
+
validateGraphName(graphName);
|
|
169
|
+
validateWriterId(writerId);
|
|
170
|
+
return `${REF_PREFIX}/${graphName}/writers/${writerId}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Builds the checkpoint head ref path for the given graph.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} graphName - The name of the graph
|
|
177
|
+
* @returns {string} The full ref path
|
|
178
|
+
* @throws {Error} If graphName is invalid
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* buildCheckpointRef('events');
|
|
182
|
+
* // => 'refs/warp/events/checkpoints/head'
|
|
183
|
+
*/
|
|
184
|
+
export function buildCheckpointRef(graphName) {
|
|
185
|
+
validateGraphName(graphName);
|
|
186
|
+
return `${REF_PREFIX}/${graphName}/checkpoints/head`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Builds the coverage head ref path for the given graph.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} graphName - The name of the graph
|
|
193
|
+
* @returns {string} The full ref path
|
|
194
|
+
* @throws {Error} If graphName is invalid
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* buildCoverageRef('events');
|
|
198
|
+
* // => 'refs/warp/events/coverage/head'
|
|
199
|
+
*/
|
|
200
|
+
export function buildCoverageRef(graphName) {
|
|
201
|
+
validateGraphName(graphName);
|
|
202
|
+
return `${REF_PREFIX}/${graphName}/coverage/head`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Builds the writers prefix path for the given graph.
|
|
207
|
+
* Useful for listing all writer refs under a graph.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} graphName - The name of the graph
|
|
210
|
+
* @returns {string} The writers prefix path
|
|
211
|
+
* @throws {Error} If graphName is invalid
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* buildWritersPrefix('events');
|
|
215
|
+
* // => 'refs/warp/events/writers/'
|
|
216
|
+
*/
|
|
217
|
+
export function buildWritersPrefix(graphName) {
|
|
218
|
+
validateGraphName(graphName);
|
|
219
|
+
return `${REF_PREFIX}/${graphName}/writers/`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// -----------------------------------------------------------------------------
|
|
223
|
+
// Parsers
|
|
224
|
+
// -----------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Parses and extracts the writer ID from a writer ref path.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} refPath - The full ref path
|
|
230
|
+
* @returns {string|null} The writer ID, or null if the path is not a valid writer ref
|
|
231
|
+
*
|
|
232
|
+
* @example
|
|
233
|
+
* parseWriterIdFromRef('refs/warp/events/writers/alice');
|
|
234
|
+
* // => 'alice'
|
|
235
|
+
*
|
|
236
|
+
* parseWriterIdFromRef('refs/heads/main');
|
|
237
|
+
* // => null
|
|
238
|
+
*/
|
|
239
|
+
export function parseWriterIdFromRef(refPath) {
|
|
240
|
+
if (typeof refPath !== 'string') {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Match pattern: refs/warp/<graph>/writers/<writerId>
|
|
245
|
+
const prefix = `${REF_PREFIX}/`;
|
|
246
|
+
if (!refPath.startsWith(prefix)) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const rest = refPath.slice(prefix.length);
|
|
251
|
+
const parts = rest.split('/');
|
|
252
|
+
|
|
253
|
+
// We expect: <graph>/writers/<writerId>
|
|
254
|
+
// So parts should be: [graphName, 'writers', writerId]
|
|
255
|
+
if (parts.length < 3) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Find the 'writers' segment
|
|
260
|
+
const writersIndex = parts.indexOf('writers');
|
|
261
|
+
if (writersIndex === -1 || writersIndex === 0) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// The writer ID is everything after 'writers'
|
|
266
|
+
// (should be exactly one segment for valid writer IDs)
|
|
267
|
+
if (writersIndex !== parts.length - 2) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const writerId = parts[parts.length - 1];
|
|
272
|
+
|
|
273
|
+
// Validate the extracted writer ID
|
|
274
|
+
try {
|
|
275
|
+
validateWriterId(writerId);
|
|
276
|
+
return writerId;
|
|
277
|
+
} catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WriterId - CRDT-safe writer identity generation and resolution.
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities for generating stable, globally unique writer IDs
|
|
5
|
+
* that are safe for use in Git refs and CRDT version vectors.
|
|
6
|
+
*
|
|
7
|
+
* @module domain/utils/WriterId
|
|
8
|
+
* @see WARP WriterId Spec v1
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { validateWriterId } from './RefLayout.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Error class for WriterId operations.
|
|
15
|
+
*/
|
|
16
|
+
export class WriterIdError extends Error {
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} code - Error code (e.g., 'CSPRNG_UNAVAILABLE')
|
|
19
|
+
* @param {string} message - Human-readable error message
|
|
20
|
+
* @param {Error} [cause] - Original error that caused this error
|
|
21
|
+
*/
|
|
22
|
+
constructor(code, message, cause) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'WriterIdError';
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.cause = cause;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Crockford base32 alphabet (lowercase), excluding i,l,o,u
|
|
31
|
+
const CROCKFORD32 = '0123456789abcdefghjkmnpqrstvwxyz';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Regex for canonical writer ID format.
|
|
35
|
+
* - Prefix: w_
|
|
36
|
+
* - Body: 26 chars Crockford Base32 (lowercase)
|
|
37
|
+
* - Total length: 28 chars
|
|
38
|
+
*/
|
|
39
|
+
const CANONICAL_RE = /^w_[0-9a-hjkmnp-tv-z]{26}$/;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validates that a writer ID is in canonical format.
|
|
43
|
+
*
|
|
44
|
+
* Canonical format:
|
|
45
|
+
* - Prefix: `w_`
|
|
46
|
+
* - Body: 26 chars Crockford Base32 (lowercase)
|
|
47
|
+
* - Total length: 28 chars
|
|
48
|
+
*
|
|
49
|
+
* @param {string} id - The writer ID to validate
|
|
50
|
+
* @returns {void}
|
|
51
|
+
* @throws {WriterIdError} If the ID is not canonical
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* validateWriterIdCanonical('w_0123456789abcdefghjkmnpqrs'); // OK
|
|
55
|
+
* validateWriterIdCanonical('alice'); // throws INVALID_CANONICAL
|
|
56
|
+
*/
|
|
57
|
+
export function validateWriterIdCanonical(id) {
|
|
58
|
+
if (typeof id !== 'string') {
|
|
59
|
+
throw new WriterIdError('INVALID_TYPE', 'writerId must be a string');
|
|
60
|
+
}
|
|
61
|
+
if (!CANONICAL_RE.test(id)) {
|
|
62
|
+
throw new WriterIdError('INVALID_CANONICAL', `writerId is not canonical: ${id}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Default random bytes generator using Web Crypto API.
|
|
68
|
+
*
|
|
69
|
+
* @param {number} n - Number of bytes to generate
|
|
70
|
+
* @returns {Uint8Array} Random bytes
|
|
71
|
+
* @throws {WriterIdError} If no secure random generator is available
|
|
72
|
+
* @private
|
|
73
|
+
*/
|
|
74
|
+
function defaultRandomBytes(n) {
|
|
75
|
+
if (typeof globalThis?.crypto?.getRandomValues === 'function') {
|
|
76
|
+
const out = new Uint8Array(n);
|
|
77
|
+
globalThis.crypto.getRandomValues(out);
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
throw new WriterIdError('CSPRNG_UNAVAILABLE', 'No secure random generator available');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Encodes bytes as Crockford Base32 (lowercase).
|
|
85
|
+
*
|
|
86
|
+
* @param {Uint8Array} bytes - Bytes to encode
|
|
87
|
+
* @returns {string} Base32-encoded string
|
|
88
|
+
* @private
|
|
89
|
+
*/
|
|
90
|
+
function crockfordBase32(bytes) {
|
|
91
|
+
let bits = 0;
|
|
92
|
+
let value = 0;
|
|
93
|
+
let out = '';
|
|
94
|
+
|
|
95
|
+
for (const b of bytes) {
|
|
96
|
+
value = (value << 8) | b;
|
|
97
|
+
bits += 8;
|
|
98
|
+
while (bits >= 5) {
|
|
99
|
+
const idx = (value >>> (bits - 5)) & 31;
|
|
100
|
+
out += CROCKFORD32[idx];
|
|
101
|
+
bits -= 5;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (bits > 0) {
|
|
106
|
+
const idx = (value << (5 - bits)) & 31;
|
|
107
|
+
out += CROCKFORD32[idx];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Generates a new canonical writer ID.
|
|
115
|
+
*
|
|
116
|
+
* Uses 128 bits of entropy (16 bytes) encoded as Crockford Base32.
|
|
117
|
+
* The result is prefixed with `w_` for a total length of 28 characters.
|
|
118
|
+
*
|
|
119
|
+
* @param {Object} [options]
|
|
120
|
+
* @param {(n: number) => Uint8Array} [options.randomBytes] - Custom RNG for testing
|
|
121
|
+
* @returns {string} A canonical writer ID (e.g., 'w_0123456789abcdefghjkmnpqrs')
|
|
122
|
+
* @throws {WriterIdError} If RNG is unavailable or returns wrong shape
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* const id = generateWriterId();
|
|
126
|
+
* // => 'w_abc123...' (26 random chars after prefix)
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* // With custom RNG for deterministic testing
|
|
130
|
+
* const id = generateWriterId({ randomBytes: mySeededRng });
|
|
131
|
+
*/
|
|
132
|
+
export function generateWriterId({ randomBytes } = {}) {
|
|
133
|
+
const rb = randomBytes ?? defaultRandomBytes;
|
|
134
|
+
const bytes = rb(16); // 128-bit
|
|
135
|
+
|
|
136
|
+
if (!(bytes instanceof Uint8Array) || bytes.length !== 16) {
|
|
137
|
+
throw new WriterIdError('CSPRNG_UNAVAILABLE', 'randomBytes() must return Uint8Array(16)');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return `w_${crockfordBase32(bytes).toLowerCase()}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolves a writer ID with repo-local persistence.
|
|
145
|
+
*
|
|
146
|
+
* Resolution order:
|
|
147
|
+
* 1. If `explicitWriterId` is provided, validate (ref-safe) and return it
|
|
148
|
+
* 2. Load from git config key `warp.writerId.<graphName>`
|
|
149
|
+
* 3. If missing or invalid, generate new canonical ID, persist, and return
|
|
150
|
+
*
|
|
151
|
+
* @param {Object} args
|
|
152
|
+
* @param {string} args.graphName - The graph name
|
|
153
|
+
* @param {string|undefined} args.explicitWriterId - Optional explicit writer ID
|
|
154
|
+
* @param {(key: string) => Promise<string|null>} args.configGet - Function to read git config
|
|
155
|
+
* @param {(key: string, value: string) => Promise<void>} args.configSet - Function to write git config
|
|
156
|
+
* @returns {Promise<string>} The resolved writer ID
|
|
157
|
+
* @throws {WriterIdError} If config operations fail
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* const writerId = await resolveWriterId({
|
|
161
|
+
* graphName: 'events',
|
|
162
|
+
* explicitWriterId: undefined,
|
|
163
|
+
* configGet: async (key) => git.config.get(key),
|
|
164
|
+
* configSet: async (key, val) => git.config.set(key, val),
|
|
165
|
+
* });
|
|
166
|
+
*/
|
|
167
|
+
export async function resolveWriterId({ graphName, explicitWriterId, configGet, configSet }) {
|
|
168
|
+
const key = `warp.writerId.${graphName}`;
|
|
169
|
+
|
|
170
|
+
// 1) Explicit wins
|
|
171
|
+
if (explicitWriterId !== null && explicitWriterId !== undefined) {
|
|
172
|
+
validateWriterId(explicitWriterId); // ref-safe validation
|
|
173
|
+
return explicitWriterId;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 2) Load from config
|
|
177
|
+
let existing;
|
|
178
|
+
try {
|
|
179
|
+
existing = await configGet(key);
|
|
180
|
+
} catch (e) {
|
|
181
|
+
throw new WriterIdError('CONFIG_READ_FAILED', `Failed to read git config key ${key}`, e);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (existing) {
|
|
185
|
+
try {
|
|
186
|
+
validateWriterId(existing);
|
|
187
|
+
return existing;
|
|
188
|
+
} catch {
|
|
189
|
+
// Invalid format in config, fall through to regenerate
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 3) Generate & persist
|
|
194
|
+
const fresh = generateWriterId();
|
|
195
|
+
validateWriterId(fresh); // Should always pass
|
|
196
|
+
validateWriterIdCanonical(fresh); // Guaranteed canonical
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
await configSet(key, fresh);
|
|
200
|
+
} catch (e) {
|
|
201
|
+
throw new WriterIdError('CONFIG_WRITE_FAILED', `Failed to persist writerId to git config key ${key}`, e);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return fresh;
|
|
205
|
+
}
|