@git-stunts/git-warp 12.2.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 -6
- 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 +4 -1
- package/index.d.ts +17 -1
- package/package.json +1 -1
- package/src/domain/WarpGraph.js +1 -1
- package/src/domain/crdt/Dot.js +5 -0
- package/src/domain/crdt/LWW.js +3 -1
- package/src/domain/crdt/ORSet.js +30 -23
- 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/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 +10 -1
- package/src/domain/services/GCPolicy.js +25 -4
- package/src/domain/services/GraphTraversal.js +3 -1
- package/src/domain/services/IncrementalIndexUpdater.js +179 -36
- package/src/domain/services/JoinReducer.js +141 -31
- package/src/domain/services/MaterializedViewService.js +13 -2
- package/src/domain/services/PatchBuilderV2.js +181 -142
- package/src/domain/services/QueryBuilder.js +4 -0
- package/src/domain/services/SyncController.js +12 -31
- package/src/domain/services/SyncProtocol.js +75 -32
- package/src/domain/trust/TrustRecordService.js +50 -36
- 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 +5 -0
- package/src/domain/warp/_wiredMethods.d.ts +1 -1
- package/src/domain/warp/checkpoint.methods.js +36 -7
- package/src/domain/warp/materialize.methods.js +44 -5
- package/src/domain/warp/materializeAdvanced.methods.js +50 -10
- package/src/domain/warp/patch.methods.js +19 -11
- package/src/infrastructure/adapters/GitGraphAdapter.js +55 -52
- package/src/infrastructure/codecs/CborCodec.js +2 -0
- package/src/domain/utils/fnv1a.js +0 -20
|
@@ -167,8 +167,28 @@ export async function _loadLatestCheckpoint() {
|
|
|
167
167
|
|
|
168
168
|
try {
|
|
169
169
|
return await loadCheckpoint(this._persistence, checkpointSha, { codec: this._codec });
|
|
170
|
-
} catch {
|
|
171
|
-
|
|
170
|
+
} catch (err) {
|
|
171
|
+
// "Not found" conditions (missing tree entries, missing blobs) are expected
|
|
172
|
+
// when a checkpoint ref exists but the objects have been pruned or are
|
|
173
|
+
// unreachable. In that case, fall back to full replay by returning null.
|
|
174
|
+
// Decode/corruption errors (e.g., CBOR parse failure, schema mismatch)
|
|
175
|
+
// should propagate so callers see the real problem.
|
|
176
|
+
// These string-contains checks match specific error messages from the
|
|
177
|
+
// persistence layer and codec:
|
|
178
|
+
// "missing" — git cat-file on pruned/unreachable objects
|
|
179
|
+
// "not found" — readTree entry lookup failures
|
|
180
|
+
// "ENOENT" — filesystem-level missing path (bare repo edge case)
|
|
181
|
+
// "non-empty string" — readRef/getNodeInfo called with empty/null SHA
|
|
182
|
+
const msg = err instanceof Error ? err.message : '';
|
|
183
|
+
if (
|
|
184
|
+
msg.includes('missing') ||
|
|
185
|
+
msg.includes('not found') ||
|
|
186
|
+
msg.includes('ENOENT') ||
|
|
187
|
+
msg.includes('non-empty string')
|
|
188
|
+
) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
throw err;
|
|
172
192
|
}
|
|
173
193
|
}
|
|
174
194
|
|
|
@@ -188,9 +208,11 @@ export async function _loadPatchesSince(checkpoint) {
|
|
|
188
208
|
const checkpointSha = checkpoint.frontier?.get(writerId) || null;
|
|
189
209
|
const patches = await this._loadWriterPatches(writerId, checkpointSha);
|
|
190
210
|
|
|
191
|
-
// Validate
|
|
192
|
-
|
|
193
|
-
|
|
211
|
+
// Validate ancestry once at the writer tip; chain-order patches are then
|
|
212
|
+
// transitively valid between checkpointSha and tipSha.
|
|
213
|
+
if (patches.length > 0) {
|
|
214
|
+
const tipSha = patches[patches.length - 1].sha;
|
|
215
|
+
await this._validatePatchAgainstCheckpoint(writerId, tipSha, checkpoint);
|
|
194
216
|
}
|
|
195
217
|
|
|
196
218
|
for (const p of patches) {
|
|
@@ -228,10 +250,16 @@ export async function _validateMigrationBoundary() {
|
|
|
228
250
|
}
|
|
229
251
|
|
|
230
252
|
/**
|
|
231
|
-
* Checks
|
|
253
|
+
* Checks whether any writer tip contains a schema:1 patch.
|
|
254
|
+
*
|
|
255
|
+
* **Heuristic only** — inspects the most recent patch per writer (the tip),
|
|
256
|
+
* not the full history chain. Older schema:1 patches buried deeper in a
|
|
257
|
+
* writer's chain will NOT be detected. This is acceptable because migration
|
|
258
|
+
* typically writes a new tip, so a schema:2+ tip implies the writer has
|
|
259
|
+
* been migrated.
|
|
232
260
|
*
|
|
233
261
|
* @this {import('../WarpGraph.js').default}
|
|
234
|
-
* @returns {Promise<boolean>} True if schema:1
|
|
262
|
+
* @returns {Promise<boolean>} True if any writer tip is schema:1 (or omits `schema`, treated as legacy v1)
|
|
235
263
|
* @private
|
|
236
264
|
*/
|
|
237
265
|
export async function _hasSchema1Patches() {
|
|
@@ -312,6 +340,7 @@ export function _maybeRunGC(state) {
|
|
|
312
340
|
if (preGcFingerprint !== postGcFingerprint) {
|
|
313
341
|
// Frontier changed — discard compacted state, mark dirty
|
|
314
342
|
this._stateDirty = true;
|
|
343
|
+
this._cachedViewHash = null;
|
|
315
344
|
if (this._logger) {
|
|
316
345
|
this._logger.warn(
|
|
317
346
|
'Auto-GC discarded: frontier changed during compaction (concurrent write)',
|
|
@@ -51,6 +51,30 @@ function scanPatchesForMaxLamport(graph, patches) {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Creates a shallow-frozen public view of materialized state.
|
|
56
|
+
*
|
|
57
|
+
* @param {import('../services/JoinReducer.js').WarpStateV5} state
|
|
58
|
+
* @returns {import('../services/JoinReducer.js').WarpStateV5}
|
|
59
|
+
*/
|
|
60
|
+
function freezePublicState(state) {
|
|
61
|
+
return Object.freeze({ ...state });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Creates a shallow-frozen public result for receipt-enabled materialization.
|
|
66
|
+
*
|
|
67
|
+
* @param {import('../services/JoinReducer.js').WarpStateV5} state
|
|
68
|
+
* @param {import('../types/TickReceipt.js').TickReceipt[]} receipts
|
|
69
|
+
* @returns {{state: import('../services/JoinReducer.js').WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}}
|
|
70
|
+
*/
|
|
71
|
+
function freezePublicStateWithReceipts(state, receipts) {
|
|
72
|
+
return Object.freeze({
|
|
73
|
+
state: freezePublicState(state),
|
|
74
|
+
receipts,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
|
|
55
79
|
/**
|
|
56
80
|
* Materializes the current graph state.
|
|
@@ -90,7 +114,12 @@ export async function materialize(options) {
|
|
|
90
114
|
try {
|
|
91
115
|
// When ceiling is active, delegate to ceiling-aware path (with its own cache)
|
|
92
116
|
if (ceiling !== null) {
|
|
93
|
-
|
|
117
|
+
const result = await this._materializeWithCeiling(ceiling, !!collectReceipts, t0);
|
|
118
|
+
if (collectReceipts) {
|
|
119
|
+
const withReceipts = /** @type {{state: import('../services/JoinReducer.js').WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}} */ (result);
|
|
120
|
+
return freezePublicStateWithReceipts(withReceipts.state, withReceipts.receipts);
|
|
121
|
+
}
|
|
122
|
+
return freezePublicState(/** @type {import('../services/JoinReducer.js').WarpStateV5} */ (result));
|
|
94
123
|
}
|
|
95
124
|
|
|
96
125
|
// Check for checkpoint
|
|
@@ -190,7 +219,7 @@ export async function materialize(options) {
|
|
|
190
219
|
}
|
|
191
220
|
}
|
|
192
221
|
|
|
193
|
-
await this._setMaterializedState(state, diff);
|
|
222
|
+
await this._setMaterializedState(state, { diff });
|
|
194
223
|
this._provenanceDegraded = false;
|
|
195
224
|
this._cachedCeiling = null;
|
|
196
225
|
this._cachedFrontier = null;
|
|
@@ -225,9 +254,12 @@ export async function materialize(options) {
|
|
|
225
254
|
this._logTiming('materialize', t0, { metrics: `${patchCount} patches` });
|
|
226
255
|
|
|
227
256
|
if (collectReceipts) {
|
|
228
|
-
return
|
|
257
|
+
return freezePublicStateWithReceipts(
|
|
258
|
+
/** @type {import('../services/JoinReducer.js').WarpStateV5} */ (state),
|
|
259
|
+
/** @type {import('../types/TickReceipt.js').TickReceipt[]} */ (receipts),
|
|
260
|
+
);
|
|
229
261
|
}
|
|
230
|
-
return state;
|
|
262
|
+
return freezePublicState(/** @type {import('../services/JoinReducer.js').WarpStateV5} */ (state));
|
|
231
263
|
} catch (err) {
|
|
232
264
|
this._logTiming('materialize', t0, { error: /** @type {Error} */ (err) });
|
|
233
265
|
throw err;
|
|
@@ -245,7 +277,14 @@ export async function _materializeGraph() {
|
|
|
245
277
|
if (!this._stateDirty && this._materializedGraph) {
|
|
246
278
|
return this._materializedGraph;
|
|
247
279
|
}
|
|
248
|
-
const
|
|
280
|
+
const materialized = await this.materialize();
|
|
281
|
+
const state = this._stateDirty
|
|
282
|
+
? /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (materialized)
|
|
283
|
+
: (this._cachedState
|
|
284
|
+
|| /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (materialized));
|
|
285
|
+
if (!state) {
|
|
286
|
+
return /** @type {object} */ (this._materializedGraph);
|
|
287
|
+
}
|
|
249
288
|
if (!this._materializedGraph || this._materializedGraph.state !== state) {
|
|
250
289
|
await this._setMaterializedState(/** @type {import('../services/JoinReducer.js').WarpStateV5} */ (state));
|
|
251
290
|
}
|
|
@@ -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);
|
|
@@ -222,7 +259,7 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
|
|
|
222
259
|
cf.size === frontier.size &&
|
|
223
260
|
[...frontier].every(([w, sha]) => cf.get(w) === sha)
|
|
224
261
|
) {
|
|
225
|
-
return this._cachedState;
|
|
262
|
+
return freezePublicState(this._cachedState);
|
|
226
263
|
}
|
|
227
264
|
|
|
228
265
|
const writerIds = [...frontier.keys()];
|
|
@@ -236,9 +273,9 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
|
|
|
236
273
|
this._cachedFrontier = frontier;
|
|
237
274
|
this._logTiming('materialize', t0, { metrics: '0 patches (ceiling)' });
|
|
238
275
|
if (collectReceipts) {
|
|
239
|
-
return
|
|
276
|
+
return freezePublicStateWithReceipts(state, []);
|
|
240
277
|
}
|
|
241
|
-
return state;
|
|
278
|
+
return freezePublicState(state);
|
|
242
279
|
}
|
|
243
280
|
|
|
244
281
|
// Persistent cache check — skip when collectReceipts is requested
|
|
@@ -259,7 +296,7 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
|
|
|
259
296
|
await this._restoreIndexFromCache(cached.indexTreeOid);
|
|
260
297
|
}
|
|
261
298
|
this._logTiming('materialize', t0, { metrics: `cache hit (ceiling=${ceiling})` });
|
|
262
|
-
return state;
|
|
299
|
+
return freezePublicState(state);
|
|
263
300
|
} catch {
|
|
264
301
|
// Corrupted payload — self-heal by removing the bad entry
|
|
265
302
|
try { await this._seekCache.delete(cacheKey); } catch { /* best-effort */ }
|
|
@@ -322,9 +359,12 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
|
|
|
322
359
|
this._logTiming('materialize', t0, { metrics: `${allPatches.length} patches (ceiling=${ceiling})` });
|
|
323
360
|
|
|
324
361
|
if (collectReceipts) {
|
|
325
|
-
return
|
|
362
|
+
return freezePublicStateWithReceipts(
|
|
363
|
+
state,
|
|
364
|
+
/** @type {TickReceipt[]} */ (receipts),
|
|
365
|
+
);
|
|
326
366
|
}
|
|
327
|
-
return state;
|
|
367
|
+
return freezePublicState(state);
|
|
328
368
|
}
|
|
329
369
|
|
|
330
370
|
/**
|
|
@@ -459,7 +499,7 @@ export async function materializeAt(checkpointSha) {
|
|
|
459
499
|
codec: this._codec,
|
|
460
500
|
});
|
|
461
501
|
await this._setMaterializedState(state);
|
|
462
|
-
return state;
|
|
502
|
+
return freezePublicState(state);
|
|
463
503
|
}
|
|
464
504
|
|
|
465
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,16 +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);
|
|
532
535
|
|
|
533
|
-
//
|
|
534
|
-
|
|
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
|
|
535
541
|
this._logicalIndex = null;
|
|
536
542
|
this._propertyReader = null;
|
|
537
543
|
this._cachedViewHash = null;
|
|
538
544
|
this._cachedIndexTree = null;
|
|
539
|
-
|
|
545
|
+
|
|
546
|
+
// State IS fresh — don't force rematerialization
|
|
547
|
+
this._stateDirty = false;
|
|
540
548
|
|
|
541
549
|
return { state: mergedState, receipt };
|
|
542
550
|
}
|
|
@@ -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
|
}
|
|
@@ -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
|
-
}
|