@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,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
+ }