@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
|
@@ -54,6 +54,12 @@ class CachedValue {
|
|
|
54
54
|
/** @type {T|null} */
|
|
55
55
|
this._value = null;
|
|
56
56
|
|
|
57
|
+
/** @type {Promise<T>|null} */
|
|
58
|
+
this._inflight = null;
|
|
59
|
+
|
|
60
|
+
/** @type {number} */
|
|
61
|
+
this._generation = 0;
|
|
62
|
+
|
|
57
63
|
/** @type {number} */
|
|
58
64
|
this._cachedAt = 0;
|
|
59
65
|
|
|
@@ -71,12 +77,33 @@ class CachedValue {
|
|
|
71
77
|
return /** @type {T} */ (this._value);
|
|
72
78
|
}
|
|
73
79
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this._cachedAtIso = this._clock.timestamp();
|
|
80
|
+
if (this._inflight) {
|
|
81
|
+
return await this._inflight;
|
|
82
|
+
}
|
|
78
83
|
|
|
79
|
-
|
|
84
|
+
const generation = this._generation;
|
|
85
|
+
|
|
86
|
+
this._inflight = Promise.resolve(this._compute()).then(
|
|
87
|
+
(value) => {
|
|
88
|
+
// Ignore stale in-flight completion if cache was invalidated mid-flight.
|
|
89
|
+
if (generation !== this._generation) {
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
this._value = value;
|
|
93
|
+
this._cachedAt = this._clock.now();
|
|
94
|
+
this._cachedAtIso = this._clock.timestamp();
|
|
95
|
+
this._inflight = null;
|
|
96
|
+
return value;
|
|
97
|
+
},
|
|
98
|
+
(err) => {
|
|
99
|
+
if (generation === this._generation) {
|
|
100
|
+
this._inflight = null;
|
|
101
|
+
}
|
|
102
|
+
throw err;
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
return await this._inflight;
|
|
80
107
|
}
|
|
81
108
|
|
|
82
109
|
/**
|
|
@@ -99,7 +126,9 @@ class CachedValue {
|
|
|
99
126
|
* Invalidates the cached value, forcing recomputation on next get().
|
|
100
127
|
*/
|
|
101
128
|
invalidate() {
|
|
129
|
+
this._generation += 1;
|
|
102
130
|
this._value = null;
|
|
131
|
+
this._inflight = null;
|
|
103
132
|
this._cachedAt = 0;
|
|
104
133
|
this._cachedAtIso = null;
|
|
105
134
|
}
|
|
@@ -49,6 +49,9 @@ export function createEventId(lamport, writerId, patchSha, opIndex) {
|
|
|
49
49
|
* Compares two EventIds lexicographically.
|
|
50
50
|
* Order: lamport -> writerId -> patchSha -> opIndex
|
|
51
51
|
*
|
|
52
|
+
* SHA tiebreaker uses lexicographic string comparison. This is arbitrary but
|
|
53
|
+
* deterministic — the specific order doesn't matter as long as all writers agree.
|
|
54
|
+
*
|
|
52
55
|
* @param {EventId} a
|
|
53
56
|
* @param {EventId} b
|
|
54
57
|
* @returns {number} -1 if a < b, 0 if equal, 1 if a > b
|
|
@@ -64,7 +67,7 @@ export function compareEventIds(a, b) {
|
|
|
64
67
|
return a.writerId < b.writerId ? -1 : 1;
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
// 3. Compare patchSha as string
|
|
70
|
+
// 3. Compare patchSha as string (lexicographic — arbitrary but deterministic)
|
|
68
71
|
if (a.patchSha !== b.patchSha) {
|
|
69
72
|
return a.patchSha < b.patchSha ? -1 : 1;
|
|
70
73
|
}
|
|
@@ -34,7 +34,9 @@ class LRUCache {
|
|
|
34
34
|
if (!this._cache.has(key)) {
|
|
35
35
|
return undefined;
|
|
36
36
|
}
|
|
37
|
-
//
|
|
37
|
+
// Delete-reinsert maintains insertion order in the underlying Map, which
|
|
38
|
+
// serves as the LRU eviction order. This is O(1) amortized in V8's Map
|
|
39
|
+
// implementation despite appearing wasteful (2x Map ops per get).
|
|
38
40
|
const value = /** @type {V} */ (this._cache.get(key));
|
|
39
41
|
this._cache.delete(key);
|
|
40
42
|
this._cache.set(key, value);
|
|
@@ -376,6 +376,10 @@ export function buildTrustRecordRef(graphName) {
|
|
|
376
376
|
/**
|
|
377
377
|
* Parses and extracts the writer ID from a writer ref path.
|
|
378
378
|
*
|
|
379
|
+
* Returns null for any non-writer ref, including malformed refs. Callers that
|
|
380
|
+
* need to distinguish "not a writer ref" from "malformed ref" should validate
|
|
381
|
+
* the ref format separately before calling this method.
|
|
382
|
+
*
|
|
379
383
|
* @param {string} refPath - The full ref path
|
|
380
384
|
* @returns {string|null} The writer ID, or null if the path is not a valid writer ref
|
|
381
385
|
*
|
|
@@ -7,10 +7,24 @@
|
|
|
7
7
|
* - Array elements that are undefined/function/symbol become "null"
|
|
8
8
|
* - Object properties with undefined/function/symbol values are omitted
|
|
9
9
|
*
|
|
10
|
+
* Throws TypeError on circular references rather than stack-overflowing.
|
|
11
|
+
*
|
|
10
12
|
* @param {unknown} value - Any JSON-serializable value
|
|
11
13
|
* @returns {string} Canonical JSON string with sorted keys
|
|
12
14
|
*/
|
|
13
15
|
export function canonicalStringify(value) {
|
|
16
|
+
return _canonicalStringify(value, new WeakSet());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Internal recursive helper with cycle detection.
|
|
21
|
+
*
|
|
22
|
+
* @param {unknown} value - Any JSON-serializable value
|
|
23
|
+
* @param {WeakSet<object>} seen - Set of already-visited objects for cycle detection
|
|
24
|
+
* @returns {string} Canonical JSON string with sorted keys
|
|
25
|
+
* @private
|
|
26
|
+
*/
|
|
27
|
+
function _canonicalStringify(value, seen) {
|
|
14
28
|
if (value === undefined) {
|
|
15
29
|
return 'null';
|
|
16
30
|
}
|
|
@@ -18,26 +32,42 @@ export function canonicalStringify(value) {
|
|
|
18
32
|
return 'null';
|
|
19
33
|
}
|
|
20
34
|
if (Array.isArray(value)) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
if (seen.has(value)) {
|
|
36
|
+
throw new TypeError('Circular reference detected in canonicalStringify');
|
|
37
|
+
}
|
|
38
|
+
seen.add(value);
|
|
39
|
+
try {
|
|
40
|
+
// Map elements: undefined/function/symbol -> "null", others recurse
|
|
41
|
+
const elements = value.map(el => {
|
|
42
|
+
if (el === undefined || typeof el === 'function' || typeof el === 'symbol') {
|
|
43
|
+
return 'null';
|
|
44
|
+
}
|
|
45
|
+
return _canonicalStringify(el, seen);
|
|
46
|
+
});
|
|
47
|
+
return `[${elements.join(',')}]`;
|
|
48
|
+
} finally {
|
|
49
|
+
seen.delete(value);
|
|
50
|
+
}
|
|
29
51
|
}
|
|
30
52
|
if (typeof value === 'object') {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
.
|
|
39
|
-
|
|
40
|
-
|
|
53
|
+
if (seen.has(value)) {
|
|
54
|
+
throw new TypeError('Circular reference detected in canonicalStringify');
|
|
55
|
+
}
|
|
56
|
+
seen.add(value);
|
|
57
|
+
try {
|
|
58
|
+
const obj = /** @type {Record<string, unknown>} */ (value);
|
|
59
|
+
// Filter out keys with undefined/function/symbol values, then sort
|
|
60
|
+
const keys = Object.keys(obj)
|
|
61
|
+
.filter(k => {
|
|
62
|
+
const v = obj[k];
|
|
63
|
+
return v !== undefined && typeof v !== 'function' && typeof v !== 'symbol';
|
|
64
|
+
})
|
|
65
|
+
.sort();
|
|
66
|
+
const pairs = keys.map(k => `${JSON.stringify(k)}:${_canonicalStringify(obj[k], seen)}`);
|
|
67
|
+
return `{${pairs.join(',')}}`;
|
|
68
|
+
} finally {
|
|
69
|
+
seen.delete(value);
|
|
70
|
+
}
|
|
41
71
|
}
|
|
42
72
|
return JSON.stringify(value);
|
|
43
73
|
}
|
|
@@ -46,6 +46,13 @@ export function matchGlob(pattern, str) {
|
|
|
46
46
|
if (!regex) {
|
|
47
47
|
regex = new RegExp(`^${escapeRegex(pattern).replace(/\\\*/g, '.*')}$`);
|
|
48
48
|
globRegexCache.set(pattern, regex);
|
|
49
|
+
// Prevent unbounded cache growth. 1000 entries is generous for typical
|
|
50
|
+
// usage; a full clear is simpler and cheaper than LRU for a regex cache.
|
|
51
|
+
// Evict after insert so the just-compiled regex survives the clear.
|
|
52
|
+
if (globRegexCache.size >= 1000) {
|
|
53
|
+
globRegexCache.clear();
|
|
54
|
+
globRegexCache.set(pattern, regex);
|
|
55
|
+
}
|
|
49
56
|
}
|
|
50
57
|
return regex.test(str);
|
|
51
58
|
}
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* @see WARP Writer Spec v1
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { buildWriterRef } from '../utils/RefLayout.js';
|
|
13
12
|
import WriterError from '../errors/WriterError.js';
|
|
13
|
+
import { buildWriterRef } from '../utils/RefLayout.js';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Fluent patch session for building and committing graph mutations.
|
|
@@ -60,7 +60,7 @@ export class PatchSession {
|
|
|
60
60
|
*
|
|
61
61
|
* @param {string} nodeId - The node ID to add
|
|
62
62
|
* @returns {this} This session for chaining
|
|
63
|
-
* @throws {
|
|
63
|
+
* @throws {WriterError} SESSION_COMMITTED if already committed
|
|
64
64
|
*/
|
|
65
65
|
addNode(nodeId) {
|
|
66
66
|
this._ensureNotCommitted();
|
|
@@ -75,7 +75,7 @@ export class PatchSession {
|
|
|
75
75
|
*
|
|
76
76
|
* @param {string} nodeId - The node ID to remove
|
|
77
77
|
* @returns {this} This session for chaining
|
|
78
|
-
* @throws {
|
|
78
|
+
* @throws {WriterError} SESSION_COMMITTED if already committed
|
|
79
79
|
*/
|
|
80
80
|
removeNode(nodeId) {
|
|
81
81
|
this._ensureNotCommitted();
|
|
@@ -90,7 +90,7 @@ export class PatchSession {
|
|
|
90
90
|
* @param {string} to - Target node ID
|
|
91
91
|
* @param {string} label - Edge label/type
|
|
92
92
|
* @returns {this} This session for chaining
|
|
93
|
-
* @throws {
|
|
93
|
+
* @throws {WriterError} SESSION_COMMITTED if already committed
|
|
94
94
|
*/
|
|
95
95
|
addEdge(from, to, label) {
|
|
96
96
|
this._ensureNotCommitted();
|
|
@@ -107,7 +107,7 @@ export class PatchSession {
|
|
|
107
107
|
* @param {string} to - Target node ID
|
|
108
108
|
* @param {string} label - Edge label/type
|
|
109
109
|
* @returns {this} This session for chaining
|
|
110
|
-
* @throws {
|
|
110
|
+
* @throws {WriterError} SESSION_COMMITTED if already committed
|
|
111
111
|
*/
|
|
112
112
|
removeEdge(from, to, label) {
|
|
113
113
|
this._ensureNotCommitted();
|
|
@@ -122,7 +122,7 @@ export class PatchSession {
|
|
|
122
122
|
* @param {string} key - Property key
|
|
123
123
|
* @param {unknown} value - Property value (must be JSON-serializable)
|
|
124
124
|
* @returns {this} This session for chaining
|
|
125
|
-
* @throws {
|
|
125
|
+
* @throws {WriterError} SESSION_COMMITTED if already committed
|
|
126
126
|
*/
|
|
127
127
|
setProperty(nodeId, key, value) {
|
|
128
128
|
this._ensureNotCommitted();
|
|
@@ -139,7 +139,7 @@ export class PatchSession {
|
|
|
139
139
|
* @param {string} key - Property key
|
|
140
140
|
* @param {unknown} value - Property value (must be JSON-serializable)
|
|
141
141
|
* @returns {this} This session for chaining
|
|
142
|
-
* @throws {
|
|
142
|
+
* @throws {WriterError} SESSION_COMMITTED if already committed
|
|
143
143
|
*/
|
|
144
144
|
// eslint-disable-next-line max-params -- direct delegate matching PatchBuilderV2 signature
|
|
145
145
|
setEdgeProperty(from, to, label, key, value) {
|
|
@@ -154,7 +154,7 @@ export class PatchSession {
|
|
|
154
154
|
* @param {string} nodeId - The node ID to attach content to
|
|
155
155
|
* @param {Buffer|string} content - The content to attach
|
|
156
156
|
* @returns {Promise<this>} This session for chaining
|
|
157
|
-
* @throws {
|
|
157
|
+
* @throws {WriterError} SESSION_COMMITTED if already committed
|
|
158
158
|
*/
|
|
159
159
|
async attachContent(nodeId, content) {
|
|
160
160
|
this._ensureNotCommitted();
|
|
@@ -170,7 +170,7 @@ export class PatchSession {
|
|
|
170
170
|
* @param {string} label - Edge label/type
|
|
171
171
|
* @param {Buffer|string} content - The content to attach
|
|
172
172
|
* @returns {Promise<this>} This session for chaining
|
|
173
|
-
* @throws {
|
|
173
|
+
* @throws {WriterError} SESSION_COMMITTED if already committed
|
|
174
174
|
*/
|
|
175
175
|
// eslint-disable-next-line max-params -- direct delegate matching PatchBuilderV2 signature
|
|
176
176
|
async attachEdgeContent(from, to, label, content) {
|
|
@@ -199,6 +199,7 @@ export class PatchSession {
|
|
|
199
199
|
* @example
|
|
200
200
|
* const sha = await patch.commit();
|
|
201
201
|
*/
|
|
202
|
+
// eslint-disable-next-line complexity -- maps multiple commit-failure modes into stable WriterError codes
|
|
202
203
|
async commit() {
|
|
203
204
|
this._ensureNotCommitted();
|
|
204
205
|
|
|
@@ -207,19 +208,6 @@ export class PatchSession {
|
|
|
207
208
|
throw new WriterError('EMPTY_PATCH', 'Cannot commit empty patch: no operations added');
|
|
208
209
|
}
|
|
209
210
|
|
|
210
|
-
const writerRef = buildWriterRef(this._graphName, this._writerId);
|
|
211
|
-
|
|
212
|
-
// Pre-commit CAS check: verify ref hasn't moved
|
|
213
|
-
const currentHead = await this._persistence.readRef(writerRef);
|
|
214
|
-
if (currentHead !== this._expectedOldHead) {
|
|
215
|
-
throw new WriterError(
|
|
216
|
-
'WRITER_REF_ADVANCED',
|
|
217
|
-
`Writer ref ${writerRef} has advanced since beginPatch(). ` +
|
|
218
|
-
`Expected ${this._expectedOldHead || '(none)'}, found ${currentHead || '(none)'}. ` +
|
|
219
|
-
`Call beginPatch() again to retry.`
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
211
|
try {
|
|
224
212
|
// Delegate to PatchBuilderV2.commit() which handles the git operations
|
|
225
213
|
const sha = await this._builder.commit();
|
|
@@ -228,6 +216,21 @@ export class PatchSession {
|
|
|
228
216
|
} catch (err) {
|
|
229
217
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
230
218
|
const cause = err instanceof Error ? err : undefined;
|
|
219
|
+
const casError = /** @type {{code?: unknown, expectedSha?: unknown, actualSha?: unknown}|null} */ (
|
|
220
|
+
(err && typeof err === 'object') ? err : null
|
|
221
|
+
);
|
|
222
|
+
if (casError?.code === 'WRITER_CAS_CONFLICT') {
|
|
223
|
+
const writerRef = buildWriterRef(this._graphName, this._writerId);
|
|
224
|
+
const expectedSha = typeof casError.expectedSha === 'string' ? casError.expectedSha : this._expectedOldHead;
|
|
225
|
+
const actualSha = typeof casError.actualSha === 'string' ? casError.actualSha : null;
|
|
226
|
+
throw new WriterError(
|
|
227
|
+
'WRITER_REF_ADVANCED',
|
|
228
|
+
`Writer ref ${writerRef} has advanced since beginPatch(). ` +
|
|
229
|
+
`Expected ${expectedSha || '(none)'}, found ${actualSha || '(none)'}. ` +
|
|
230
|
+
'Call beginPatch() again to retry.',
|
|
231
|
+
cause
|
|
232
|
+
);
|
|
233
|
+
}
|
|
231
234
|
if (errMsg.includes('Concurrent commit detected') ||
|
|
232
235
|
errMsg.includes('has advanced')) {
|
|
233
236
|
throw new WriterError('WRITER_REF_ADVANCED', errMsg, cause);
|
|
@@ -246,12 +249,15 @@ export class PatchSession {
|
|
|
246
249
|
|
|
247
250
|
/**
|
|
248
251
|
* Ensures the session hasn't been committed yet.
|
|
249
|
-
* @throws {
|
|
252
|
+
* @throws {WriterError} SESSION_COMMITTED if already committed
|
|
250
253
|
* @private
|
|
251
254
|
*/
|
|
252
255
|
_ensureNotCommitted() {
|
|
253
256
|
if (this._committed) {
|
|
254
|
-
throw new
|
|
257
|
+
throw new WriterError(
|
|
258
|
+
'SESSION_COMMITTED',
|
|
259
|
+
'PatchSession already committed. Call beginPatch() to create a new session.',
|
|
260
|
+
);
|
|
255
261
|
}
|
|
256
262
|
}
|
|
257
263
|
}
|
|
@@ -128,12 +128,14 @@ export class Writer {
|
|
|
128
128
|
const commitMessage = await this._persistence.showNode(expectedOldHead);
|
|
129
129
|
const kind = detectMessageKind(commitMessage);
|
|
130
130
|
if (kind === 'patch') {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
131
|
+
const patchInfo = decodePatchMessage(commitMessage);
|
|
132
|
+
if (typeof patchInfo.lamport !== 'number' || !Number.isFinite(patchInfo.lamport) || patchInfo.lamport < 1) {
|
|
133
|
+
throw new WriterError(
|
|
134
|
+
'E_LAMPORT_CORRUPT',
|
|
135
|
+
`Malformed Lamport timestamp in commit ${expectedOldHead}: ${JSON.stringify(patchInfo.lamport)}`,
|
|
136
|
+
);
|
|
136
137
|
}
|
|
138
|
+
lamport = patchInfo.lamport + 1;
|
|
137
139
|
}
|
|
138
140
|
}
|
|
139
141
|
|
|
@@ -184,6 +186,11 @@ export class Writer {
|
|
|
184
186
|
'commitPatch() is not reentrant. Use beginPatch() for nested or concurrent patches.',
|
|
185
187
|
);
|
|
186
188
|
}
|
|
189
|
+
// The `_commitInProgress` flag prevents concurrent commits from the same
|
|
190
|
+
// Writer instance. The finally block unconditionally resets it to ensure
|
|
191
|
+
// the writer remains usable after a failed commit. Error classification
|
|
192
|
+
// (CAS failure vs corruption vs I/O) is handled by the caller via the
|
|
193
|
+
// thrown error type.
|
|
187
194
|
this._commitInProgress = true;
|
|
188
195
|
try {
|
|
189
196
|
const patch = await this.beginPatch();
|
|
@@ -249,7 +249,7 @@ declare module '../WarpGraph.js' {
|
|
|
249
249
|
_resolveCeiling(options?: { ceiling?: number | null }): number | null;
|
|
250
250
|
_buildAdjacency(state: WarpStateV5): { outgoing: Map<string, Array<{ neighborId: string; label: string }>>; incoming: Map<string, Array<{ neighborId: string; label: string }>> };
|
|
251
251
|
_buildView(state: WarpStateV5, stateHash: string, diff?: import('../types/PatchDiff.js').PatchDiff): void;
|
|
252
|
-
_setMaterializedState(state: WarpStateV5, diff?: import('../types/PatchDiff.js').PatchDiff): Promise<{ state: WarpStateV5; stateHash: string; adjacency: unknown }>;
|
|
252
|
+
_setMaterializedState(state: WarpStateV5, optionsOrDiff?: import('../types/PatchDiff.js').PatchDiff | { diff?: import('../types/PatchDiff.js').PatchDiff | null }): Promise<{ state: WarpStateV5; stateHash: string; adjacency: unknown }>;
|
|
253
253
|
_materializeWithCeiling(ceiling: number, collectReceipts: boolean, t0: number): Promise<WarpStateV5 | { state: WarpStateV5; receipts: TickReceipt[] }>;
|
|
254
254
|
_persistSeekCacheEntry(cacheKey: string, buf: Buffer, state: WarpStateV5): Promise<void>;
|
|
255
255
|
_restoreIndexFromCache(indexTreeOid: string): Promise<void>;
|
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
|
|
10
10
|
import { QueryError, E_NO_STATE_MSG } from './_internal.js';
|
|
11
11
|
import { buildWriterRef, buildCheckpointRef, buildCoverageRef } from '../utils/RefLayout.js';
|
|
12
|
-
import { createFrontier, updateFrontier } from '../services/Frontier.js';
|
|
12
|
+
import { createFrontier, updateFrontier, frontierFingerprint } from '../services/Frontier.js';
|
|
13
13
|
import { loadCheckpoint, create as createCheckpointCommit } from '../services/CheckpointService.js';
|
|
14
14
|
import { decodePatchMessage, detectMessageKind, encodeAnchorMessage } from '../services/WarpMessageCodec.js';
|
|
15
15
|
import { shouldRunGC, executeGC } from '../services/GCPolicy.js';
|
|
16
16
|
import { collectGCMetrics } from '../services/GCMetrics.js';
|
|
17
17
|
import { computeAppliedVV } from '../services/CheckpointSerializerV5.js';
|
|
18
|
+
import { cloneStateV5 } from '../services/JoinReducer.js';
|
|
18
19
|
|
|
19
20
|
/** @typedef {import('../types/WarpPersistence.js').CorePersistence} CorePersistence */
|
|
20
21
|
|
|
@@ -166,8 +167,28 @@ export async function _loadLatestCheckpoint() {
|
|
|
166
167
|
|
|
167
168
|
try {
|
|
168
169
|
return await loadCheckpoint(this._persistence, checkpointSha, { codec: this._codec });
|
|
169
|
-
} catch {
|
|
170
|
-
|
|
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;
|
|
171
192
|
}
|
|
172
193
|
}
|
|
173
194
|
|
|
@@ -187,9 +208,11 @@ export async function _loadPatchesSince(checkpoint) {
|
|
|
187
208
|
const checkpointSha = checkpoint.frontier?.get(writerId) || null;
|
|
188
209
|
const patches = await this._loadWriterPatches(writerId, checkpointSha);
|
|
189
210
|
|
|
190
|
-
// Validate
|
|
191
|
-
|
|
192
|
-
|
|
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);
|
|
193
216
|
}
|
|
194
217
|
|
|
195
218
|
for (const p of patches) {
|
|
@@ -227,10 +250,16 @@ export async function _validateMigrationBoundary() {
|
|
|
227
250
|
}
|
|
228
251
|
|
|
229
252
|
/**
|
|
230
|
-
* 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.
|
|
231
260
|
*
|
|
232
261
|
* @this {import('../WarpGraph.js').default}
|
|
233
|
-
* @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)
|
|
234
263
|
* @private
|
|
235
264
|
*/
|
|
236
265
|
export async function _hasSchema1Patches() {
|
|
@@ -267,6 +296,12 @@ export async function _hasSchema1Patches() {
|
|
|
267
296
|
* Post-materialize GC check. Warn by default; execute only when enabled.
|
|
268
297
|
* GC failure never breaks materialize.
|
|
269
298
|
*
|
|
299
|
+
* Uses clone-then-swap pattern for snapshot isolation (B63):
|
|
300
|
+
* 1. Snapshot frontier fingerprint before GC
|
|
301
|
+
* 2. Clone state, run executeGC on clone
|
|
302
|
+
* 3. Compare frontier after GC — if changed, discard clone + mark dirty
|
|
303
|
+
* 4. If unchanged, swap compacted clone into _cachedState
|
|
304
|
+
*
|
|
270
305
|
* @this {import('../WarpGraph.js').default}
|
|
271
306
|
* @param {import('../services/JoinReducer.js').WarpStateV5} state
|
|
272
307
|
* @private
|
|
@@ -287,8 +322,36 @@ export function _maybeRunGC(state) {
|
|
|
287
322
|
}
|
|
288
323
|
|
|
289
324
|
if (/** @type {import('../services/GCPolicy.js').GCPolicy} */ (this._gcPolicy).enabled) {
|
|
290
|
-
|
|
291
|
-
const
|
|
325
|
+
// Snapshot frontier before GC
|
|
326
|
+
const preGcFingerprint = this._lastFrontier
|
|
327
|
+
? frontierFingerprint(this._lastFrontier)
|
|
328
|
+
: null;
|
|
329
|
+
|
|
330
|
+
// Clone state so executeGC doesn't mutate live state
|
|
331
|
+
const clonedState = cloneStateV5(state);
|
|
332
|
+
const appliedVV = computeAppliedVV(clonedState);
|
|
333
|
+
const result = executeGC(clonedState, appliedVV);
|
|
334
|
+
|
|
335
|
+
// Check if frontier changed during GC (concurrent write)
|
|
336
|
+
const postGcFingerprint = this._lastFrontier
|
|
337
|
+
? frontierFingerprint(this._lastFrontier)
|
|
338
|
+
: null;
|
|
339
|
+
|
|
340
|
+
if (preGcFingerprint !== postGcFingerprint) {
|
|
341
|
+
// Frontier changed — discard compacted state, mark dirty
|
|
342
|
+
this._stateDirty = true;
|
|
343
|
+
this._cachedViewHash = null;
|
|
344
|
+
if (this._logger) {
|
|
345
|
+
this._logger.warn(
|
|
346
|
+
'Auto-GC discarded: frontier changed during compaction (concurrent write)',
|
|
347
|
+
{ reasons, preGcFingerprint, postGcFingerprint },
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Frontier unchanged — swap in compacted state
|
|
354
|
+
this._cachedState = clonedState;
|
|
292
355
|
this._lastGCTime = this._clock.now();
|
|
293
356
|
this._patchesSinceGC = 0;
|
|
294
357
|
if (this._logger) {
|
|
@@ -348,11 +411,17 @@ export function maybeRunGC() {
|
|
|
348
411
|
* Explicitly runs GC on the cached state.
|
|
349
412
|
* Compacts tombstoned dots that are covered by the appliedVV.
|
|
350
413
|
*
|
|
414
|
+
* Uses clone-then-swap pattern for snapshot isolation (B63):
|
|
415
|
+
* clones state, runs executeGC on clone, verifies frontier unchanged,
|
|
416
|
+
* then swaps in compacted clone. If frontier changed during GC,
|
|
417
|
+
* throws E_GC_STALE so the caller can retry after re-materializing.
|
|
418
|
+
*
|
|
351
419
|
* **Requires a cached state.**
|
|
352
420
|
*
|
|
353
421
|
* @this {import('../WarpGraph.js').default}
|
|
354
422
|
* @returns {{nodesCompacted: number, edgesCompacted: number, tombstonesRemoved: number, durationMs: number}}
|
|
355
423
|
* @throws {QueryError} If no cached state exists (code: `E_NO_STATE`)
|
|
424
|
+
* @throws {QueryError} If frontier changed during GC (code: `E_GC_STALE`)
|
|
356
425
|
*
|
|
357
426
|
* @example
|
|
358
427
|
* await graph.materialize();
|
|
@@ -368,13 +437,30 @@ export function runGC() {
|
|
|
368
437
|
});
|
|
369
438
|
}
|
|
370
439
|
|
|
371
|
-
//
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
440
|
+
// Snapshot frontier before GC
|
|
441
|
+
const preGcFingerprint = this._lastFrontier
|
|
442
|
+
? frontierFingerprint(this._lastFrontier)
|
|
443
|
+
: null;
|
|
444
|
+
|
|
445
|
+
// Clone state so executeGC doesn't mutate live state until verified
|
|
446
|
+
const clonedState = cloneStateV5(this._cachedState);
|
|
447
|
+
const appliedVV = computeAppliedVV(clonedState);
|
|
448
|
+
const result = executeGC(clonedState, appliedVV);
|
|
449
|
+
|
|
450
|
+
// Verify frontier unchanged (concurrent write detection)
|
|
451
|
+
const postGcFingerprint = this._lastFrontier
|
|
452
|
+
? frontierFingerprint(this._lastFrontier)
|
|
453
|
+
: null;
|
|
454
|
+
|
|
455
|
+
if (preGcFingerprint !== postGcFingerprint) {
|
|
456
|
+
throw new QueryError(
|
|
457
|
+
'GC aborted: frontier changed during compaction (concurrent write detected)',
|
|
458
|
+
{ code: 'E_GC_STALE' },
|
|
459
|
+
);
|
|
460
|
+
}
|
|
376
461
|
|
|
377
|
-
//
|
|
462
|
+
// Frontier unchanged — swap in compacted state
|
|
463
|
+
this._cachedState = clonedState;
|
|
378
464
|
this._lastGCTime = this._clock.now();
|
|
379
465
|
this._patchesSinceGC = 0;
|
|
380
466
|
|
|
@@ -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;
|
|
@@ -242,7 +274,17 @@ export async function materialize(options) {
|
|
|
242
274
|
* @private
|
|
243
275
|
*/
|
|
244
276
|
export async function _materializeGraph() {
|
|
245
|
-
|
|
277
|
+
if (!this._stateDirty && this._materializedGraph) {
|
|
278
|
+
return this._materializedGraph;
|
|
279
|
+
}
|
|
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
|
+
}
|
|
246
288
|
if (!this._materializedGraph || this._materializedGraph.state !== state) {
|
|
247
289
|
await this._setMaterializedState(/** @type {import('../services/JoinReducer.js').WarpStateV5} */ (state));
|
|
248
290
|
}
|