@git-stunts/git-warp 12.2.0 → 12.3.0
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 +9 -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/presenters/text.js +10 -3
- 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 +33 -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/AuditReceiptService.js +2 -1
- package/src/domain/services/AuditVerifierService.js +33 -2
- package/src/domain/services/BitmapIndexBuilder.js +14 -9
- package/src/domain/services/BoundaryTransitionRecord.js +1 -0
- package/src/domain/services/CheckpointMessageCodec.js +5 -0
- package/src/domain/services/CheckpointService.js +29 -2
- 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 +311 -75
- package/src/domain/services/KeyCodec.js +48 -0
- package/src/domain/services/MaterializedViewService.js +14 -3
- package/src/domain/services/MessageSchemaDetector.js +35 -5
- package/src/domain/services/OpNormalizer.js +79 -0
- package/src/domain/services/PatchBuilderV2.js +240 -160
- package/src/domain/services/QueryBuilder.js +4 -0
- package/src/domain/services/SyncAuthService.js +3 -0
- package/src/domain/services/SyncController.js +12 -31
- package/src/domain/services/SyncProtocol.js +76 -32
- package/src/domain/services/WarpMessageCodec.js +2 -0
- package/src/domain/trust/TrustCrypto.js +8 -5
- package/src/domain/trust/TrustRecordService.js +50 -36
- package/src/domain/types/TickReceipt.js +6 -4
- package/src/domain/types/WarpTypesV2.js +77 -5
- 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/defaultClock.js +1 -0
- package/src/domain/utils/matchGlob.js +7 -0
- package/src/domain/warp/PatchSession.js +30 -24
- package/src/domain/warp/Writer.js +12 -1
- package/src/domain/warp/_wiredMethods.d.ts +1 -1
- package/src/domain/warp/checkpoint.methods.js +36 -7
- package/src/domain/warp/fork.methods.js +1 -1
- 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 +21 -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
|
@@ -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
|
}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import defaultCodec from '../utils/defaultCodec.js';
|
|
18
|
+
import nullLogger from '../utils/nullLogger.js';
|
|
18
19
|
import { validateWriterId, buildWriterRef } from '../utils/RefLayout.js';
|
|
19
20
|
import { PatchSession } from './PatchSession.js';
|
|
20
21
|
import { PatchBuilderV2 } from '../services/PatchBuilderV2.js';
|
|
@@ -44,8 +45,9 @@ export class Writer {
|
|
|
44
45
|
* @param {(result: {patch: Object, sha: string}) => void | Promise<void>} [options.onCommitSuccess] - Callback invoked after successful commit with { patch, sha }
|
|
45
46
|
* @param {'reject'|'cascade'|'warn'} [options.onDeleteWithData='warn'] - Policy when deleting a node with attached data
|
|
46
47
|
* @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for CBOR serialization (defaults to domain-local codec)
|
|
48
|
+
* @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger port
|
|
47
49
|
*/
|
|
48
|
-
constructor({ persistence, graphName, writerId, versionVector, getCurrentState, onCommitSuccess, onDeleteWithData = 'warn', codec }) {
|
|
50
|
+
constructor({ persistence, graphName, writerId, versionVector, getCurrentState, onCommitSuccess, onDeleteWithData = 'warn', codec, logger }) {
|
|
49
51
|
validateWriterId(writerId);
|
|
50
52
|
|
|
51
53
|
/** @type {import('../../ports/GraphPersistencePort.js').default & import('../../ports/RefPort.js').default & import('../../ports/CommitPort.js').default} */
|
|
@@ -72,6 +74,9 @@ export class Writer {
|
|
|
72
74
|
/** @type {import('../../ports/CodecPort.js').default|undefined} */
|
|
73
75
|
this._codec = codec || defaultCodec;
|
|
74
76
|
|
|
77
|
+
/** @type {import('../../ports/LoggerPort.js').default} */
|
|
78
|
+
this._logger = logger || nullLogger;
|
|
79
|
+
|
|
75
80
|
/** @type {boolean} */
|
|
76
81
|
this._commitInProgress = false;
|
|
77
82
|
}
|
|
@@ -151,6 +156,7 @@ export class Writer {
|
|
|
151
156
|
onCommitSuccess: this._onCommitSuccess,
|
|
152
157
|
onDeleteWithData: this._onDeleteWithData,
|
|
153
158
|
codec: this._codec,
|
|
159
|
+
logger: this._logger,
|
|
154
160
|
});
|
|
155
161
|
|
|
156
162
|
// Return PatchSession wrapping the builder
|
|
@@ -186,6 +192,11 @@ export class Writer {
|
|
|
186
192
|
'commitPatch() is not reentrant. Use beginPatch() for nested or concurrent patches.',
|
|
187
193
|
);
|
|
188
194
|
}
|
|
195
|
+
// The `_commitInProgress` flag prevents concurrent commits from the same
|
|
196
|
+
// Writer instance. The finally block unconditionally resets it to ensure
|
|
197
|
+
// the writer remains usable after a failed commit. Error classification
|
|
198
|
+
// (CAS failure vs corruption vs I/O) is handled by the caller via the
|
|
199
|
+
// thrown error type.
|
|
189
200
|
this._commitInProgress = true;
|
|
190
201
|
try {
|
|
191
202
|
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>;
|
|
@@ -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)',
|
|
@@ -104,7 +104,7 @@ export async function fork({ from, at, forkName, forkWriterId }) {
|
|
|
104
104
|
|
|
105
105
|
// 4. Generate or validate fork name (add random suffix to prevent collisions)
|
|
106
106
|
const resolvedForkName =
|
|
107
|
-
forkName ?? `${this._graphName}-fork-${
|
|
107
|
+
forkName ?? `${this._graphName}-fork-${Math.random().toString(36).slice(2, 10).padEnd(8, '0')}`;
|
|
108
108
|
try {
|
|
109
109
|
validateGraphName(resolvedForkName);
|
|
110
110
|
} catch (err) {
|
|
@@ -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
|
/**
|