@git-stunts/git-warp 12.1.0 → 12.2.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/README.md +8 -4
- package/bin/cli/commands/trust.js +37 -1
- package/bin/cli/infrastructure.js +14 -1
- package/bin/cli/schemas.js +4 -4
- package/bin/warp-graph.js +9 -2
- package/index.d.ts +18 -2
- package/package.json +1 -1
- package/src/domain/WarpGraph.js +4 -1
- package/src/domain/crdt/Dot.js +5 -0
- package/src/domain/crdt/LWW.js +3 -1
- package/src/domain/crdt/ORSet.js +63 -27
- package/src/domain/crdt/VersionVector.js +12 -0
- package/src/domain/errors/PatchError.js +27 -0
- package/src/domain/errors/StorageError.js +8 -0
- package/src/domain/errors/SyncError.js +1 -0
- package/src/domain/errors/TrustError.js +2 -0
- package/src/domain/errors/WriterError.js +5 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AuditVerifierService.js +32 -2
- package/src/domain/services/BitmapIndexBuilder.js +14 -9
- package/src/domain/services/CheckpointService.js +12 -8
- package/src/domain/services/Frontier.js +18 -0
- package/src/domain/services/GCPolicy.js +25 -4
- package/src/domain/services/GraphTraversal.js +11 -50
- package/src/domain/services/HttpSyncServer.js +18 -29
- package/src/domain/services/IncrementalIndexUpdater.js +179 -36
- package/src/domain/services/JoinReducer.js +164 -31
- package/src/domain/services/MaterializedViewService.js +13 -2
- package/src/domain/services/PatchBuilderV2.js +210 -145
- package/src/domain/services/QueryBuilder.js +67 -30
- package/src/domain/services/SyncController.js +62 -18
- package/src/domain/services/SyncPayloadSchema.js +236 -0
- package/src/domain/services/SyncProtocol.js +102 -40
- package/src/domain/services/SyncTrustGate.js +146 -0
- package/src/domain/services/TranslationCost.js +2 -2
- package/src/domain/trust/TrustRecordService.js +161 -34
- package/src/domain/utils/CachedValue.js +34 -5
- package/src/domain/utils/EventId.js +4 -1
- package/src/domain/utils/LRUCache.js +3 -1
- package/src/domain/utils/RefLayout.js +4 -0
- package/src/domain/utils/canonicalStringify.js +48 -18
- package/src/domain/utils/matchGlob.js +7 -0
- package/src/domain/warp/PatchSession.js +30 -24
- package/src/domain/warp/Writer.js +12 -5
- package/src/domain/warp/_wiredMethods.d.ts +1 -1
- package/src/domain/warp/checkpoint.methods.js +102 -16
- package/src/domain/warp/materialize.methods.js +47 -5
- package/src/domain/warp/materializeAdvanced.methods.js +52 -10
- package/src/domain/warp/patch.methods.js +24 -8
- package/src/domain/warp/query.methods.js +4 -4
- package/src/domain/warp/subscribe.methods.js +11 -19
- package/src/infrastructure/adapters/GitGraphAdapter.js +57 -54
- package/src/infrastructure/codecs/CborCodec.js +2 -0
- package/src/domain/utils/fnv1a.js +0 -20
|
@@ -22,15 +22,40 @@ import BitmapNeighborProvider from '../services/BitmapNeighborProvider.js';
|
|
|
22
22
|
|
|
23
23
|
/** @typedef {import('../types/WarpPersistence.js').CorePersistence} CorePersistence */
|
|
24
24
|
/** @typedef {import('../services/JoinReducer.js').WarpStateV5} WarpStateV5 */
|
|
25
|
+
/** @typedef {import('../types/TickReceipt.js').TickReceipt} TickReceipt */
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* @typedef {{ outgoing: Map<string, Array<{neighborId: string, label: string}>>, incoming: Map<string, Array<{neighborId: string, label: string}>> }} AdjacencyMap
|
|
28
|
-
* @typedef {{ state: WarpStateV5, stateHash: string, adjacency: AdjacencyMap }} MaterializedResult
|
|
29
|
+
* @typedef {{ state: WarpStateV5, stateHash: string|null, adjacency: AdjacencyMap }} MaterializedResult
|
|
29
30
|
*/
|
|
30
31
|
|
|
31
32
|
import { buildWriterRef } from '../utils/RefLayout.js';
|
|
32
33
|
import { decodePatchMessage, detectMessageKind } from '../services/WarpMessageCodec.js';
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Creates a shallow-frozen public view of materialized state.
|
|
37
|
+
*
|
|
38
|
+
* @param {WarpStateV5} state
|
|
39
|
+
* @returns {WarpStateV5}
|
|
40
|
+
*/
|
|
41
|
+
function freezePublicState(state) {
|
|
42
|
+
return Object.freeze({ ...state });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Creates a shallow-frozen public materialization result with receipts.
|
|
47
|
+
*
|
|
48
|
+
* @param {WarpStateV5} state
|
|
49
|
+
* @param {TickReceipt[]} receipts
|
|
50
|
+
* @returns {{state: WarpStateV5, receipts: TickReceipt[]}}
|
|
51
|
+
*/
|
|
52
|
+
function freezePublicStateWithReceipts(state, receipts) {
|
|
53
|
+
return Object.freeze({
|
|
54
|
+
state: freezePublicState(state),
|
|
55
|
+
receipts,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
34
59
|
/**
|
|
35
60
|
* Resolves the effective ceiling from options and instance state.
|
|
36
61
|
*
|
|
@@ -107,11 +132,23 @@ export function _buildAdjacency(state) {
|
|
|
107
132
|
*
|
|
108
133
|
* @this {import('../WarpGraph.js').default}
|
|
109
134
|
* @param {import('../services/JoinReducer.js').WarpStateV5} state
|
|
110
|
-
* @param {import('../types/PatchDiff.js').PatchDiff} [
|
|
135
|
+
* @param {import('../types/PatchDiff.js').PatchDiff|{diff?: import('../types/PatchDiff.js').PatchDiff|null}} [optionsOrDiff]
|
|
136
|
+
* Either a PatchDiff (legacy positional form) or options object.
|
|
111
137
|
* @returns {Promise<MaterializedResult>}
|
|
112
138
|
* @private
|
|
113
139
|
*/
|
|
114
|
-
export async function _setMaterializedState(state,
|
|
140
|
+
export async function _setMaterializedState(state, optionsOrDiff) {
|
|
141
|
+
/** @type {import('../types/PatchDiff.js').PatchDiff|undefined} */
|
|
142
|
+
let diff;
|
|
143
|
+
if (
|
|
144
|
+
optionsOrDiff &&
|
|
145
|
+
typeof optionsOrDiff === 'object' &&
|
|
146
|
+
Object.prototype.hasOwnProperty.call(optionsOrDiff, 'diff')
|
|
147
|
+
) {
|
|
148
|
+
diff = /** @type {{diff?: import('../types/PatchDiff.js').PatchDiff|null}} */ (optionsOrDiff).diff ?? undefined;
|
|
149
|
+
} else {
|
|
150
|
+
diff = /** @type {import('../types/PatchDiff.js').PatchDiff|undefined} */ (optionsOrDiff ?? undefined);
|
|
151
|
+
}
|
|
115
152
|
this._cachedState = state;
|
|
116
153
|
this._stateDirty = false;
|
|
117
154
|
this._versionVector = vvClone(state.observedFrontier);
|
|
@@ -167,6 +204,7 @@ export function _buildView(state, stateHash, diff) {
|
|
|
167
204
|
this._propertyReader = result.propertyReader;
|
|
168
205
|
this._cachedViewHash = stateHash;
|
|
169
206
|
this._cachedIndexTree = result.tree;
|
|
207
|
+
this._indexDegraded = false;
|
|
170
208
|
|
|
171
209
|
const provider = new BitmapNeighborProvider({ logicalIndex: result.logicalIndex });
|
|
172
210
|
if (this._materializedGraph) {
|
|
@@ -176,6 +214,7 @@ export function _buildView(state, stateHash, diff) {
|
|
|
176
214
|
this._logger?.warn('[warp] index build failed, falling back to linear scan', {
|
|
177
215
|
error: /** @type {Error} */ (err).message,
|
|
178
216
|
});
|
|
217
|
+
this._indexDegraded = true;
|
|
179
218
|
this._logicalIndex = null;
|
|
180
219
|
this._propertyReader = null;
|
|
181
220
|
this._cachedIndexTree = null;
|
|
@@ -220,7 +259,7 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
|
|
|
220
259
|
cf.size === frontier.size &&
|
|
221
260
|
[...frontier].every(([w, sha]) => cf.get(w) === sha)
|
|
222
261
|
) {
|
|
223
|
-
return this._cachedState;
|
|
262
|
+
return freezePublicState(this._cachedState);
|
|
224
263
|
}
|
|
225
264
|
|
|
226
265
|
const writerIds = [...frontier.keys()];
|
|
@@ -234,9 +273,9 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
|
|
|
234
273
|
this._cachedFrontier = frontier;
|
|
235
274
|
this._logTiming('materialize', t0, { metrics: '0 patches (ceiling)' });
|
|
236
275
|
if (collectReceipts) {
|
|
237
|
-
return
|
|
276
|
+
return freezePublicStateWithReceipts(state, []);
|
|
238
277
|
}
|
|
239
|
-
return state;
|
|
278
|
+
return freezePublicState(state);
|
|
240
279
|
}
|
|
241
280
|
|
|
242
281
|
// Persistent cache check — skip when collectReceipts is requested
|
|
@@ -257,7 +296,7 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
|
|
|
257
296
|
await this._restoreIndexFromCache(cached.indexTreeOid);
|
|
258
297
|
}
|
|
259
298
|
this._logTiming('materialize', t0, { metrics: `cache hit (ceiling=${ceiling})` });
|
|
260
|
-
return state;
|
|
299
|
+
return freezePublicState(state);
|
|
261
300
|
} catch {
|
|
262
301
|
// Corrupted payload — self-heal by removing the bad entry
|
|
263
302
|
try { await this._seekCache.delete(cacheKey); } catch { /* best-effort */ }
|
|
@@ -320,9 +359,12 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
|
|
|
320
359
|
this._logTiming('materialize', t0, { metrics: `${allPatches.length} patches (ceiling=${ceiling})` });
|
|
321
360
|
|
|
322
361
|
if (collectReceipts) {
|
|
323
|
-
return
|
|
362
|
+
return freezePublicStateWithReceipts(
|
|
363
|
+
state,
|
|
364
|
+
/** @type {TickReceipt[]} */ (receipts),
|
|
365
|
+
);
|
|
324
366
|
}
|
|
325
|
-
return state;
|
|
367
|
+
return freezePublicState(state);
|
|
326
368
|
}
|
|
327
369
|
|
|
328
370
|
/**
|
|
@@ -457,7 +499,7 @@ export async function materializeAt(checkpointSha) {
|
|
|
457
499
|
codec: this._codec,
|
|
458
500
|
});
|
|
459
501
|
await this._setMaterializedState(state);
|
|
460
|
-
return state;
|
|
502
|
+
return freezePublicState(state);
|
|
461
503
|
}
|
|
462
504
|
|
|
463
505
|
/**
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
import { QueryError, E_NO_STATE_MSG, E_STALE_STATE_MSG } from './_internal.js';
|
|
12
12
|
import { PatchBuilderV2 } from '../services/PatchBuilderV2.js';
|
|
13
|
-
import { joinStates,
|
|
13
|
+
import { joinStates, applyWithDiff, applyWithReceipt } from '../services/JoinReducer.js';
|
|
14
14
|
import { orsetElements } from '../crdt/ORSet.js';
|
|
15
|
-
import { vvIncrement } from '../crdt/VersionVector.js';
|
|
15
|
+
import { vvIncrement, vvClone } from '../crdt/VersionVector.js';
|
|
16
16
|
import { buildWriterRef, buildWritersPrefix, parseWriterIdFromRef } from '../utils/RefLayout.js';
|
|
17
17
|
import { decodePatchMessage, detectMessageKind } from '../services/WarpMessageCodec.js';
|
|
18
18
|
import { Writer } from './Writer.js';
|
|
@@ -220,15 +220,16 @@ export async function _onPatchCommitted(writerId, { patch: committed, sha } = {}
|
|
|
220
220
|
// Only when the cache is clean — applying a patch to stale state would be incorrect
|
|
221
221
|
if (this._cachedState && !this._stateDirty && committed && sha) {
|
|
222
222
|
let tickReceipt = null;
|
|
223
|
+
/** @type {import('../types/PatchDiff.js').PatchDiff|null} */
|
|
224
|
+
let diff = null;
|
|
223
225
|
if (this._auditService) {
|
|
224
|
-
const result =
|
|
225
|
-
joinPatch(this._cachedState, /** @type {Parameters<typeof joinPatch>[1]} */ (committed), sha, true)
|
|
226
|
-
);
|
|
226
|
+
const result = applyWithReceipt(this._cachedState, committed, sha);
|
|
227
227
|
tickReceipt = result.receipt;
|
|
228
228
|
} else {
|
|
229
|
-
|
|
229
|
+
const result = applyWithDiff(this._cachedState, committed, sha);
|
|
230
|
+
diff = result.diff;
|
|
230
231
|
}
|
|
231
|
-
await this._setMaterializedState(this._cachedState);
|
|
232
|
+
await this._setMaterializedState(this._cachedState, { diff });
|
|
232
233
|
// Update provenance index with new patch
|
|
233
234
|
if (this._provenanceIndex) {
|
|
234
235
|
this._provenanceIndex.addPatch(sha, /** @type {string[]|undefined} */ (committed.reads), /** @type {string[]|undefined} */ (committed.writes));
|
|
@@ -247,6 +248,7 @@ export async function _onPatchCommitted(writerId, { patch: committed, sha } = {}
|
|
|
247
248
|
}
|
|
248
249
|
} else {
|
|
249
250
|
this._stateDirty = true;
|
|
251
|
+
this._cachedViewHash = null;
|
|
250
252
|
if (this._auditService) {
|
|
251
253
|
this._auditSkipCount++;
|
|
252
254
|
this._logger?.warn('[warp:audit]', {
|
|
@@ -527,8 +529,22 @@ export function join(otherState) {
|
|
|
527
529
|
!this._frontierEquals(this._cachedState.observedFrontier, mergedState.observedFrontier),
|
|
528
530
|
};
|
|
529
531
|
|
|
530
|
-
//
|
|
532
|
+
// Install merged state as canonical (B108 — cache coherence fix)
|
|
531
533
|
this._cachedState = mergedState;
|
|
534
|
+
this._versionVector = vvClone(mergedState.observedFrontier);
|
|
535
|
+
|
|
536
|
+
// Build adjacency synchronously (crypto hash deferred to next _buildView)
|
|
537
|
+
const adjacency = this._buildAdjacency(mergedState);
|
|
538
|
+
this._materializedGraph = { state: mergedState, stateHash: null, adjacency };
|
|
539
|
+
|
|
540
|
+
// Clear index caches — queries degrade to linear scan until next _buildView
|
|
541
|
+
this._logicalIndex = null;
|
|
542
|
+
this._propertyReader = null;
|
|
543
|
+
this._cachedViewHash = null;
|
|
544
|
+
this._cachedIndexTree = null;
|
|
545
|
+
|
|
546
|
+
// State IS fresh — don't force rematerialization
|
|
547
|
+
this._stateDirty = false;
|
|
532
548
|
|
|
533
549
|
return { state: mergedState, receipt };
|
|
534
550
|
}
|
|
@@ -319,9 +319,9 @@ export function query() {
|
|
|
319
319
|
*/
|
|
320
320
|
export async function observer(name, config) {
|
|
321
321
|
/** @param {unknown} m */
|
|
322
|
-
const isValidMatch = (m) => typeof m === 'string' || (Array.isArray(m) && m.every(/** @param {unknown} i */ i => typeof i === 'string'));
|
|
322
|
+
const isValidMatch = (m) => typeof m === 'string' || (Array.isArray(m) && m.length > 0 && m.every(/** @param {unknown} i */ i => typeof i === 'string'));
|
|
323
323
|
if (!config || !isValidMatch(config.match)) {
|
|
324
|
-
throw new Error('observer config.match must be a string or array of strings');
|
|
324
|
+
throw new Error('observer config.match must be a non-empty string or non-empty array of strings');
|
|
325
325
|
}
|
|
326
326
|
await this._ensureFreshState();
|
|
327
327
|
return new ObserverView({ name, config, graph: this });
|
|
@@ -332,11 +332,11 @@ export async function observer(name, config) {
|
|
|
332
332
|
*
|
|
333
333
|
* @this {import('../WarpGraph.js').default}
|
|
334
334
|
* @param {Object} configA - Observer configuration for A
|
|
335
|
-
* @param {string} configA.match - Glob pattern for visible nodes
|
|
335
|
+
* @param {string|string[]} configA.match - Glob pattern(s) for visible nodes
|
|
336
336
|
* @param {string[]} [configA.expose] - Property keys to include
|
|
337
337
|
* @param {string[]} [configA.redact] - Property keys to exclude
|
|
338
338
|
* @param {Object} configB - Observer configuration for B
|
|
339
|
-
* @param {string} configB.match - Glob pattern for visible nodes
|
|
339
|
+
* @param {string|string[]} configB.match - Glob pattern(s) for visible nodes
|
|
340
340
|
* @param {string[]} [configB.expose] - Property keys to include
|
|
341
341
|
* @param {string[]} [configB.redact] - Property keys to exclude
|
|
342
342
|
* @returns {Promise<{cost: number, breakdown: {nodeLoss: number, edgeLoss: number, propLoss: number}}>}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { diffStates, isEmptyDiff } from '../services/StateDiff.js';
|
|
9
|
+
import { matchGlob } from '../utils/matchGlob.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Subscribes to graph changes.
|
|
@@ -101,13 +102,13 @@ export function subscribe({ onChange, onError, replay = false }) {
|
|
|
101
102
|
* be at least 1000ms.
|
|
102
103
|
*
|
|
103
104
|
* @this {import('../WarpGraph.js').default}
|
|
104
|
-
* @param {string} pattern - Glob pattern (e.g., 'user:*', 'order:123', '*')
|
|
105
|
+
* @param {string|string[]} pattern - Glob pattern(s) (e.g., 'user:*', 'order:123', '*')
|
|
105
106
|
* @param {Object} options - Watch options
|
|
106
107
|
* @param {(diff: import('../services/StateDiff.js').StateDiffResult) => void} options.onChange - Called with filtered diff when matching changes occur
|
|
107
108
|
* @param {(error: Error) => void} [options.onError] - Called if onChange throws an error
|
|
108
109
|
* @param {number} [options.poll] - Poll interval in ms (min 1000); checks frontier and auto-materializes
|
|
109
110
|
* @returns {{unsubscribe: () => void}} Subscription handle
|
|
110
|
-
* @throws {Error} If pattern is not a string
|
|
111
|
+
* @throws {Error} If pattern is not a string or array of strings
|
|
111
112
|
* @throws {Error} If onChange is not a function
|
|
112
113
|
* @throws {Error} If poll is provided but less than 1000
|
|
113
114
|
*
|
|
@@ -130,31 +131,22 @@ export function subscribe({ onChange, onError, replay = false }) {
|
|
|
130
131
|
* unsubscribe();
|
|
131
132
|
*/
|
|
132
133
|
export function watch(pattern, { onChange, onError, poll }) {
|
|
133
|
-
|
|
134
|
-
|
|
134
|
+
const isValidPattern = (/** @type {string|string[]} */ p) => typeof p === 'string' || (Array.isArray(p) && p.length > 0 && p.every(i => typeof i === 'string'));
|
|
135
|
+
if (!isValidPattern(pattern)) {
|
|
136
|
+
throw new Error('pattern must be a non-empty string or non-empty array of strings');
|
|
135
137
|
}
|
|
136
138
|
if (typeof onChange !== 'function') {
|
|
137
139
|
throw new Error('onChange must be a function');
|
|
138
140
|
}
|
|
139
141
|
if (poll !== undefined) {
|
|
140
|
-
if (typeof poll !== 'number' || poll < 1000) {
|
|
141
|
-
throw new Error('poll must be a number >= 1000');
|
|
142
|
+
if (typeof poll !== 'number' || !Number.isFinite(poll) || poll < 1000) {
|
|
143
|
+
throw new Error('poll must be a finite number >= 1000');
|
|
142
144
|
}
|
|
143
145
|
}
|
|
144
146
|
|
|
145
|
-
// Pattern matching
|
|
146
|
-
// Pre-compile pattern matcher once for performance
|
|
147
|
+
// Pattern matching logic
|
|
147
148
|
/** @type {(nodeId: string) => boolean} */
|
|
148
|
-
|
|
149
|
-
if (pattern === '*') {
|
|
150
|
-
matchesPattern = () => true;
|
|
151
|
-
} else if (pattern.includes('*')) {
|
|
152
|
-
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
153
|
-
const regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
|
|
154
|
-
matchesPattern = (/** @type {string} */ nodeId) => regex.test(nodeId);
|
|
155
|
-
} else {
|
|
156
|
-
matchesPattern = (/** @type {string} */ nodeId) => nodeId === pattern;
|
|
157
|
-
}
|
|
149
|
+
const matchesPattern = (nodeId) => matchGlob(pattern, nodeId);
|
|
158
150
|
|
|
159
151
|
// Filtered onChange that only passes matching changes
|
|
160
152
|
const filteredOnChange = (/** @type {import('../services/StateDiff.js').StateDiffResult} */ diff) => {
|
|
@@ -194,7 +186,7 @@ export function watch(pattern, { onChange, onError, poll }) {
|
|
|
194
186
|
/** @type {ReturnType<typeof setInterval>|null} */
|
|
195
187
|
let pollIntervalId = null;
|
|
196
188
|
let pollInFlight = false;
|
|
197
|
-
if (poll) {
|
|
189
|
+
if (poll !== undefined) {
|
|
198
190
|
pollIntervalId = setInterval(() => {
|
|
199
191
|
if (pollInFlight) {
|
|
200
192
|
return;
|
|
@@ -135,29 +135,6 @@ function isDanglingObjectError(err) {
|
|
|
135
135
|
);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
/**
|
|
139
|
-
* Checks whether a Git ref exists without resolving it.
|
|
140
|
-
* @param {function(Object): Promise<string>} execute - The git command executor function
|
|
141
|
-
* @param {string} ref - The ref to check (e.g., 'refs/warp/events/writers/alice')
|
|
142
|
-
* @returns {Promise<boolean>} True if the ref exists, false otherwise
|
|
143
|
-
* @throws {Error} If the git command fails for reasons other than a missing ref
|
|
144
|
-
*/
|
|
145
|
-
async function refExists(execute, ref) {
|
|
146
|
-
try {
|
|
147
|
-
await execute({ args: ['show-ref', '--verify', '--quiet', ref] });
|
|
148
|
-
return true;
|
|
149
|
-
} catch (err) {
|
|
150
|
-
const gitErr = /** @type {GitError} */ (err);
|
|
151
|
-
if (getExitCode(gitErr) === 1) {
|
|
152
|
-
return false;
|
|
153
|
-
}
|
|
154
|
-
if (isDanglingObjectError(gitErr)) {
|
|
155
|
-
return false;
|
|
156
|
-
}
|
|
157
|
-
throw err;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
138
|
/**
|
|
162
139
|
* Concrete implementation of {@link GraphPersistencePort} using Git plumbing commands.
|
|
163
140
|
*
|
|
@@ -262,26 +239,37 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
262
239
|
}
|
|
263
240
|
|
|
264
241
|
/**
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
* @param {
|
|
268
|
-
* @
|
|
269
|
-
* @
|
|
270
|
-
* @returns {Promise<string>} The SHA of the created commit
|
|
271
|
-
* @throws {Error} If any parent OID is invalid
|
|
242
|
+
* Shared helper for commit creation. Validates parents, builds args, and
|
|
243
|
+
* executes `git commit-tree` with retry.
|
|
244
|
+
* @param {{ tree: string, parents: string[], message: string, sign: boolean }} opts
|
|
245
|
+
* @returns {Promise<string>} The created commit SHA
|
|
246
|
+
* @private
|
|
272
247
|
*/
|
|
273
|
-
async
|
|
248
|
+
async _createCommit({ tree, parents, message, sign }) {
|
|
274
249
|
for (const p of parents) {
|
|
275
250
|
this._validateOid(p);
|
|
276
251
|
}
|
|
277
252
|
const parentArgs = parents.flatMap(p => ['-p', p]);
|
|
278
253
|
const signArgs = sign ? ['-S'] : [];
|
|
279
|
-
const args = ['commit-tree',
|
|
254
|
+
const args = ['commit-tree', tree, ...parentArgs, ...signArgs, '-m', message];
|
|
280
255
|
|
|
281
256
|
const oid = await this._executeWithRetry({ args });
|
|
282
257
|
return oid.trim();
|
|
283
258
|
}
|
|
284
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Creates a commit pointing to the empty tree.
|
|
262
|
+
* @param {Object} options
|
|
263
|
+
* @param {string} options.message - The commit message (typically CBOR-encoded patch data)
|
|
264
|
+
* @param {string[]} [options.parents=[]] - Parent commit SHAs
|
|
265
|
+
* @param {boolean} [options.sign=false] - Whether to GPG-sign the commit
|
|
266
|
+
* @returns {Promise<string>} The SHA of the created commit
|
|
267
|
+
* @throws {Error} If any parent OID is invalid
|
|
268
|
+
*/
|
|
269
|
+
async commitNode({ message, parents = [], sign = false }) {
|
|
270
|
+
return await this._createCommit({ tree: this.emptyTree, parents, message, sign });
|
|
271
|
+
}
|
|
272
|
+
|
|
285
273
|
/**
|
|
286
274
|
* Creates a commit pointing to a custom tree (not the empty tree).
|
|
287
275
|
* Used for WARP patch commits that have attachment trees.
|
|
@@ -294,15 +282,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
294
282
|
*/
|
|
295
283
|
async commitNodeWithTree({ treeOid, parents = [], message, sign = false }) {
|
|
296
284
|
this._validateOid(treeOid);
|
|
297
|
-
|
|
298
|
-
this._validateOid(p);
|
|
299
|
-
}
|
|
300
|
-
const parentArgs = parents.flatMap(p => ['-p', p]);
|
|
301
|
-
const signArgs = sign ? ['-S'] : [];
|
|
302
|
-
const args = ['commit-tree', treeOid, ...parentArgs, ...signArgs, '-m', message];
|
|
303
|
-
|
|
304
|
-
const oid = await this._executeWithRetry({ args });
|
|
305
|
-
return oid.trim();
|
|
285
|
+
return await this._createCommit({ tree: treeOid, parents, message, sign });
|
|
306
286
|
}
|
|
307
287
|
|
|
308
288
|
/**
|
|
@@ -402,8 +382,13 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
402
382
|
// -z flag ensures NUL-terminated output and ignores i18n.logOutputEncoding config
|
|
403
383
|
const args = ['log', '-z', `-${limit}`];
|
|
404
384
|
if (format) {
|
|
405
|
-
// Strip NUL bytes from
|
|
406
|
-
//
|
|
385
|
+
// Strip NUL (\x00) bytes from the caller-supplied format string.
|
|
386
|
+
// Why: Git's -z flag uses NUL as the record terminator in its output.
|
|
387
|
+
// If a format string contains literal NUL bytes (e.g. from %x00 expansion
|
|
388
|
+
// or caller-constructed strings), they corrupt the NUL-delimited output
|
|
389
|
+
// stream, causing downstream parsers to split records at the wrong
|
|
390
|
+
// boundaries. Additionally, Node.js child_process rejects argv entries
|
|
391
|
+
// that contain null bytes, so passing them through would throw.
|
|
407
392
|
// eslint-disable-next-line no-control-regex
|
|
408
393
|
const cleanFormat = format.replace(/\x00/g, '');
|
|
409
394
|
args.push(`--format=${cleanFormat}`);
|
|
@@ -415,6 +400,8 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
415
400
|
/**
|
|
416
401
|
* Validates that a ref is safe to use in git commands.
|
|
417
402
|
* Delegates to shared validation in adapterValidation.js.
|
|
403
|
+
*
|
|
404
|
+
* Instance method for port interface conformance and test mockability.
|
|
418
405
|
* @param {string} ref - The ref to validate
|
|
419
406
|
* @throws {Error} If ref contains invalid characters, is too long, or starts with -/--
|
|
420
407
|
* @private
|
|
@@ -451,7 +438,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
451
438
|
|
|
452
439
|
/**
|
|
453
440
|
* Reads a tree and returns a map of path to content.
|
|
454
|
-
*
|
|
441
|
+
* Reads blobs in batches of 16 to balance concurrency against fd/process limits.
|
|
455
442
|
* @param {string} treeOid - The tree OID to read
|
|
456
443
|
* @returns {Promise<Record<string, Buffer>>} Map of file path to blob content
|
|
457
444
|
*/
|
|
@@ -459,9 +446,16 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
459
446
|
const oids = await this.readTreeOids(treeOid);
|
|
460
447
|
/** @type {Record<string, Buffer>} */
|
|
461
448
|
const files = {};
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
449
|
+
const entries = Object.entries(oids);
|
|
450
|
+
const BATCH_SIZE = 16;
|
|
451
|
+
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
|
|
452
|
+
const batch = entries.slice(i, i + BATCH_SIZE);
|
|
453
|
+
const results = await Promise.all(
|
|
454
|
+
batch.map(([, oid]) => this.readBlob(oid))
|
|
455
|
+
);
|
|
456
|
+
for (let j = 0; j < batch.length; j++) {
|
|
457
|
+
files[batch[j][0]] = results[j];
|
|
458
|
+
}
|
|
465
459
|
}
|
|
466
460
|
return files;
|
|
467
461
|
}
|
|
@@ -539,20 +533,21 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
539
533
|
*/
|
|
540
534
|
async readRef(ref) {
|
|
541
535
|
this._validateRef(ref);
|
|
542
|
-
const exists = await refExists(this._executeWithRetry.bind(this), ref);
|
|
543
|
-
if (!exists) {
|
|
544
|
-
return null;
|
|
545
|
-
}
|
|
546
536
|
try {
|
|
537
|
+
// --verify ensures exactly one revision is resolved; --quiet suppresses
|
|
538
|
+
// error messages and makes exit code 1 (not 128) the indicator for
|
|
539
|
+
// "ref does not exist", simplifying downstream handling.
|
|
547
540
|
const oid = await this._executeWithRetry({
|
|
548
|
-
args: ['rev-parse', ref]
|
|
541
|
+
args: ['rev-parse', '--verify', '--quiet', ref]
|
|
549
542
|
});
|
|
550
543
|
return oid.trim();
|
|
551
544
|
} catch (err) {
|
|
552
545
|
const gitErr = /** @type {GitError} */ (err);
|
|
546
|
+
// Exit code 1: ref does not exist (normal with --verify --quiet)
|
|
553
547
|
if (getExitCode(gitErr) === 1) {
|
|
554
548
|
return null;
|
|
555
549
|
}
|
|
550
|
+
// Exit code 128 with dangling-object stderr: ref exists but target is missing
|
|
556
551
|
if (isDanglingObjectError(gitErr)) {
|
|
557
552
|
return null;
|
|
558
553
|
}
|
|
@@ -575,8 +570,8 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
575
570
|
async compareAndSwapRef(ref, newOid, expectedOid) {
|
|
576
571
|
this._validateRef(ref);
|
|
577
572
|
this._validateOid(newOid);
|
|
578
|
-
// null means "ref must not exist" → use zero OID
|
|
579
|
-
const oldArg = expectedOid || '0'.repeat(
|
|
573
|
+
// null means "ref must not exist" → use zero OID (always 40 chars for SHA-1)
|
|
574
|
+
const oldArg = expectedOid || '0'.repeat(40);
|
|
580
575
|
if (expectedOid) {
|
|
581
576
|
this._validateOid(expectedOid);
|
|
582
577
|
}
|
|
@@ -602,6 +597,10 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
602
597
|
/**
|
|
603
598
|
* Validates that an OID is safe to use in git commands.
|
|
604
599
|
* Delegates to shared validation in adapterValidation.js.
|
|
600
|
+
*
|
|
601
|
+
* Exists as a method (rather than inlining the import) so tests can
|
|
602
|
+
* spy/stub validation independently and so future adapters sharing
|
|
603
|
+
* the same port interface can override validation rules.
|
|
605
604
|
* @param {string} oid - The OID to validate
|
|
606
605
|
* @throws {Error} If OID is invalid
|
|
607
606
|
* @private
|
|
@@ -613,6 +612,8 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
613
612
|
/**
|
|
614
613
|
* Validates that a limit is a safe positive integer.
|
|
615
614
|
* Delegates to shared validation in adapterValidation.js.
|
|
615
|
+
*
|
|
616
|
+
* Instance method for port interface conformance and test mockability.
|
|
616
617
|
* @param {number} limit - The limit to validate
|
|
617
618
|
* @throws {Error} If limit is invalid
|
|
618
619
|
* @private
|
|
@@ -759,6 +760,8 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
759
760
|
/**
|
|
760
761
|
* Validates that a config key is safe to use in git commands.
|
|
761
762
|
* Delegates to shared validation in adapterValidation.js.
|
|
763
|
+
*
|
|
764
|
+
* Instance method for port interface conformance and test mockability.
|
|
762
765
|
* @param {string} key - The config key to validate
|
|
763
766
|
* @throws {Error} If key is invalid
|
|
764
767
|
* @private
|
|
@@ -96,6 +96,8 @@ function isPlainObject(value) {
|
|
|
96
96
|
function sortPlainObject(obj) {
|
|
97
97
|
/** @type {Record<string, unknown>} */
|
|
98
98
|
const sorted = {};
|
|
99
|
+
// Key sort ensures deterministic CBOR encoding regardless of insertion order.
|
|
100
|
+
// Required for content-addressed storage where byte-identical encoding is critical.
|
|
99
101
|
const keys = Object.keys(obj).sort();
|
|
100
102
|
for (const key of keys) {
|
|
101
103
|
sorted[key] = sortKeys(obj[key]);
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* FNV-1a 32-bit hash function.
|
|
3
|
-
*
|
|
4
|
-
* Used for shard key computation when the input is not a hex SHA.
|
|
5
|
-
* Uses Math.imul for correct 32-bit multiplication semantics.
|
|
6
|
-
*
|
|
7
|
-
* @note Callers with non-ASCII node IDs should normalize to NFC before
|
|
8
|
-
* hashing to ensure consistent shard placement.
|
|
9
|
-
*
|
|
10
|
-
* @param {string} str - Input string
|
|
11
|
-
* @returns {number} Unsigned 32-bit FNV-1a hash
|
|
12
|
-
*/
|
|
13
|
-
export default function fnv1a(str) {
|
|
14
|
-
let hash = 0x811c9dc5; // FNV offset basis
|
|
15
|
-
for (let i = 0; i < str.length; i++) {
|
|
16
|
-
hash ^= str.charCodeAt(i);
|
|
17
|
-
hash = Math.imul(hash, 0x01000193); // FNV prime
|
|
18
|
-
}
|
|
19
|
-
return hash >>> 0; // Ensure unsigned
|
|
20
|
-
}
|