@git-stunts/git-warp 10.7.0 → 11.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 +53 -32
- package/SECURITY.md +64 -0
- package/bin/cli/commands/check.js +168 -0
- package/bin/cli/commands/doctor/checks.js +422 -0
- package/bin/cli/commands/doctor/codes.js +46 -0
- package/bin/cli/commands/doctor/index.js +239 -0
- package/bin/cli/commands/doctor/types.js +89 -0
- package/bin/cli/commands/history.js +73 -0
- package/bin/cli/commands/info.js +139 -0
- package/bin/cli/commands/install-hooks.js +128 -0
- package/bin/cli/commands/materialize.js +99 -0
- package/bin/cli/commands/path.js +88 -0
- package/bin/cli/commands/query.js +194 -0
- package/bin/cli/commands/registry.js +28 -0
- package/bin/cli/commands/seek.js +592 -0
- package/bin/cli/commands/trust.js +154 -0
- package/bin/cli/commands/verify-audit.js +113 -0
- package/bin/cli/commands/view.js +45 -0
- package/bin/cli/infrastructure.js +336 -0
- package/bin/cli/schemas.js +177 -0
- package/bin/cli/shared.js +244 -0
- package/bin/cli/types.js +85 -0
- package/bin/presenters/index.js +214 -0
- package/bin/presenters/json.js +66 -0
- package/bin/presenters/text.js +543 -0
- package/bin/warp-graph.js +19 -2824
- package/index.d.ts +32 -2
- package/index.js +2 -0
- package/package.json +9 -7
- package/src/domain/WarpGraph.js +106 -3252
- package/src/domain/errors/QueryError.js +2 -2
- package/src/domain/errors/TrustError.js +29 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AuditMessageCodec.js +137 -0
- package/src/domain/services/AuditReceiptService.js +471 -0
- package/src/domain/services/AuditVerifierService.js +693 -0
- package/src/domain/services/HttpSyncServer.js +36 -22
- package/src/domain/services/MessageCodecInternal.js +3 -0
- package/src/domain/services/MessageSchemaDetector.js +2 -2
- package/src/domain/services/SyncAuthService.js +69 -3
- package/src/domain/services/WarpMessageCodec.js +4 -1
- package/src/domain/trust/TrustCanonical.js +42 -0
- package/src/domain/trust/TrustCrypto.js +111 -0
- package/src/domain/trust/TrustEvaluator.js +180 -0
- package/src/domain/trust/TrustRecordService.js +274 -0
- package/src/domain/trust/TrustStateBuilder.js +209 -0
- package/src/domain/trust/canonical.js +68 -0
- package/src/domain/trust/reasonCodes.js +64 -0
- package/src/domain/trust/schemas.js +160 -0
- package/src/domain/trust/verdict.js +42 -0
- package/src/domain/types/git-cas.d.ts +20 -0
- package/src/domain/utils/RefLayout.js +59 -0
- package/src/domain/warp/PatchSession.js +18 -0
- package/src/domain/warp/Writer.js +18 -3
- package/src/domain/warp/_internal.js +26 -0
- package/src/domain/warp/_wire.js +58 -0
- package/src/domain/warp/_wiredMethods.d.ts +100 -0
- package/src/domain/warp/checkpoint.methods.js +397 -0
- package/src/domain/warp/fork.methods.js +323 -0
- package/src/domain/warp/materialize.methods.js +188 -0
- package/src/domain/warp/materializeAdvanced.methods.js +339 -0
- package/src/domain/warp/patch.methods.js +529 -0
- package/src/domain/warp/provenance.methods.js +284 -0
- package/src/domain/warp/query.methods.js +279 -0
- package/src/domain/warp/subscribe.methods.js +272 -0
- package/src/domain/warp/sync.methods.js +549 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +67 -1
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
- package/src/ports/CommitPort.js +10 -0
- package/src/ports/RefPort.js +17 -0
- package/src/hooks/post-merge.sh +0 -60
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patch/writer methods for WarpGraph — state mutation, writer lifecycle,
|
|
3
|
+
* discovery, and CRDT join.
|
|
4
|
+
*
|
|
5
|
+
* Every function uses `this` bound to a WarpGraph instance at runtime
|
|
6
|
+
* via wireWarpMethods().
|
|
7
|
+
*
|
|
8
|
+
* @module domain/warp/patch.methods
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { QueryError, E_NO_STATE_MSG, E_STALE_STATE_MSG } from './_internal.js';
|
|
12
|
+
import { PatchBuilderV2 } from '../services/PatchBuilderV2.js';
|
|
13
|
+
import { joinStates, join as joinPatch } from '../services/JoinReducer.js';
|
|
14
|
+
import { orsetElements } from '../crdt/ORSet.js';
|
|
15
|
+
import { vvIncrement } from '../crdt/VersionVector.js';
|
|
16
|
+
import { buildWriterRef, buildWritersPrefix, parseWriterIdFromRef } from '../utils/RefLayout.js';
|
|
17
|
+
import { decodePatchMessage, detectMessageKind } from '../services/WarpMessageCodec.js';
|
|
18
|
+
import { Writer } from './Writer.js';
|
|
19
|
+
import { generateWriterId, resolveWriterId } from '../utils/WriterId.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates a new PatchBuilderV2 for this graph.
|
|
23
|
+
*
|
|
24
|
+
* @this {import('../WarpGraph.js').default}
|
|
25
|
+
* @returns {Promise<PatchBuilderV2>} A new patch builder
|
|
26
|
+
*/
|
|
27
|
+
export async function createPatch() {
|
|
28
|
+
const { lamport, parentSha } = await this._nextLamport();
|
|
29
|
+
return new PatchBuilderV2({
|
|
30
|
+
persistence: this._persistence,
|
|
31
|
+
graphName: this._graphName,
|
|
32
|
+
writerId: this._writerId,
|
|
33
|
+
lamport,
|
|
34
|
+
versionVector: this._versionVector,
|
|
35
|
+
getCurrentState: () => this._cachedState,
|
|
36
|
+
expectedParentSha: parentSha,
|
|
37
|
+
onDeleteWithData: this._onDeleteWithData,
|
|
38
|
+
onCommitSuccess: (/** @type {{patch?: import('../types/WarpTypesV2.js').PatchV2, sha?: string}} */ opts) => this._onPatchCommitted(this._writerId, opts),
|
|
39
|
+
codec: this._codec,
|
|
40
|
+
logger: this._logger || undefined,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Convenience wrapper: creates a patch, runs the callback, and commits.
|
|
46
|
+
*
|
|
47
|
+
* The callback receives a `PatchBuilderV2` and may be synchronous or
|
|
48
|
+
* asynchronous. The commit happens only after the callback resolves
|
|
49
|
+
* successfully. If the callback throws or rejects, no commit is attempted
|
|
50
|
+
* and the error propagates untouched.
|
|
51
|
+
*
|
|
52
|
+
* Not reentrant: calling `graph.patch()` inside a callback throws.
|
|
53
|
+
* Use `createPatch()` directly for advanced multi-patch workflows.
|
|
54
|
+
*
|
|
55
|
+
* @this {import('../WarpGraph.js').default}
|
|
56
|
+
* @param {(p: PatchBuilderV2) => void | Promise<void>} build - Callback that adds operations to the patch
|
|
57
|
+
* @returns {Promise<string>} The commit SHA of the new patch
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* const sha = await graph.patch(p => {
|
|
61
|
+
* p.addNode('user:alice');
|
|
62
|
+
* p.setProperty('user:alice', 'name', 'Alice');
|
|
63
|
+
* });
|
|
64
|
+
*/
|
|
65
|
+
export async function patch(build) {
|
|
66
|
+
if (this._patchInProgress) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
'graph.patch() is not reentrant. Use createPatch() for nested or concurrent patches.',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
this._patchInProgress = true;
|
|
72
|
+
try {
|
|
73
|
+
const p = await this.createPatch();
|
|
74
|
+
await build(p);
|
|
75
|
+
return await p.commit();
|
|
76
|
+
} finally {
|
|
77
|
+
this._patchInProgress = false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Gets the next lamport timestamp and current parent SHA for this writer.
|
|
83
|
+
* Reads from the current ref chain to determine values.
|
|
84
|
+
*
|
|
85
|
+
* @this {import('../WarpGraph.js').default}
|
|
86
|
+
* @returns {Promise<{lamport: number, parentSha: string|null}>} The next lamport and current parent
|
|
87
|
+
*/
|
|
88
|
+
export async function _nextLamport() {
|
|
89
|
+
const writerRef = buildWriterRef(this._graphName, this._writerId);
|
|
90
|
+
const currentRefSha = await this._persistence.readRef(writerRef);
|
|
91
|
+
|
|
92
|
+
if (!currentRefSha) {
|
|
93
|
+
// First commit for this writer
|
|
94
|
+
return { lamport: 1, parentSha: null };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Read the current patch commit to get its lamport timestamp
|
|
98
|
+
const commitMessage = await this._persistence.showNode(currentRefSha);
|
|
99
|
+
const kind = detectMessageKind(commitMessage);
|
|
100
|
+
|
|
101
|
+
if (kind !== 'patch') {
|
|
102
|
+
// Writer ref doesn't point to a patch commit - treat as first commit
|
|
103
|
+
return { lamport: 1, parentSha: currentRefSha };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const patchInfo = decodePatchMessage(commitMessage);
|
|
108
|
+
return { lamport: patchInfo.lamport + 1, parentSha: currentRefSha };
|
|
109
|
+
} catch {
|
|
110
|
+
// Malformed message - error with actionable message
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Failed to parse lamport from writer ref ${writerRef}: ` +
|
|
113
|
+
`commit ${currentRefSha} has invalid patch message format`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Loads all patches from a writer's ref chain.
|
|
120
|
+
*
|
|
121
|
+
* Walks commits from the tip SHA back to the first patch commit,
|
|
122
|
+
* collecting all patches along the way.
|
|
123
|
+
*
|
|
124
|
+
* @this {import('../WarpGraph.js').default}
|
|
125
|
+
* @param {string} writerId - The writer ID to load patches for
|
|
126
|
+
* @param {string|null} [stopAtSha=null] - Stop walking when reaching this SHA (exclusive)
|
|
127
|
+
* @returns {Promise<Array<{patch: import('../types/WarpTypesV2.js').PatchV2, sha: string}>>} Array of patches
|
|
128
|
+
*/
|
|
129
|
+
export async function _loadWriterPatches(writerId, stopAtSha = null) {
|
|
130
|
+
const writerRef = buildWriterRef(this._graphName, writerId);
|
|
131
|
+
const tipSha = await this._persistence.readRef(writerRef);
|
|
132
|
+
|
|
133
|
+
if (!tipSha) {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const patches = [];
|
|
138
|
+
let currentSha = tipSha;
|
|
139
|
+
|
|
140
|
+
while (currentSha && currentSha !== stopAtSha) {
|
|
141
|
+
// Get commit info and message
|
|
142
|
+
const nodeInfo = await this._persistence.getNodeInfo(currentSha);
|
|
143
|
+
const {message} = nodeInfo;
|
|
144
|
+
|
|
145
|
+
// Check if this is a patch commit
|
|
146
|
+
const kind = detectMessageKind(message);
|
|
147
|
+
if (kind !== 'patch') {
|
|
148
|
+
// Not a patch commit, stop walking
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Decode the patch message to get patchOid
|
|
153
|
+
const patchMeta = decodePatchMessage(message);
|
|
154
|
+
|
|
155
|
+
// Read the patch blob
|
|
156
|
+
const patchBuffer = await this._persistence.readBlob(patchMeta.patchOid);
|
|
157
|
+
const decoded = /** @type {import('../types/WarpTypesV2.js').PatchV2} */ (this._codec.decode(patchBuffer));
|
|
158
|
+
|
|
159
|
+
patches.push({ patch: decoded, sha: currentSha });
|
|
160
|
+
|
|
161
|
+
// Move to parent commit
|
|
162
|
+
if (nodeInfo.parents && nodeInfo.parents.length > 0) {
|
|
163
|
+
currentSha = nodeInfo.parents[0];
|
|
164
|
+
} else {
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Patches are collected in reverse order (newest first), reverse them
|
|
170
|
+
return patches.reverse();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Returns patches from a writer's ref chain.
|
|
175
|
+
*
|
|
176
|
+
* @this {import('../WarpGraph.js').default}
|
|
177
|
+
* @param {string} writerId - The writer ID to load patches for
|
|
178
|
+
* @param {string|null} [stopAtSha=null] - Stop walking when reaching this SHA (exclusive)
|
|
179
|
+
* @returns {Promise<Array<{patch: import('../types/WarpTypesV2.js').PatchV2, sha: string}>>} Array of patches
|
|
180
|
+
*/
|
|
181
|
+
export async function getWriterPatches(writerId, stopAtSha = null) {
|
|
182
|
+
return await this._loadWriterPatches(writerId, stopAtSha);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Post-commit hook: updates version vector, eager re-materialize,
|
|
187
|
+
* provenance index, frontier, and audit service.
|
|
188
|
+
*
|
|
189
|
+
* @this {import('../WarpGraph.js').default}
|
|
190
|
+
* @param {string} writerId - The writer that committed
|
|
191
|
+
* @param {{patch?: import('../types/WarpTypesV2.js').PatchV2, sha?: string}} [opts]
|
|
192
|
+
* @returns {Promise<void>}
|
|
193
|
+
*/
|
|
194
|
+
export async function _onPatchCommitted(writerId, { patch: committed, sha } = {}) {
|
|
195
|
+
vvIncrement(this._versionVector, writerId);
|
|
196
|
+
this._patchesSinceCheckpoint++;
|
|
197
|
+
// Eager re-materialize: apply the just-committed patch to cached state
|
|
198
|
+
// Only when the cache is clean — applying a patch to stale state would be incorrect
|
|
199
|
+
if (this._cachedState && !this._stateDirty && committed && sha) {
|
|
200
|
+
let tickReceipt = null;
|
|
201
|
+
if (this._auditService) {
|
|
202
|
+
// TODO(ts-cleanup): narrow joinPatch return + patch type to PatchV2
|
|
203
|
+
const result = /** @type {{state: import('../services/JoinReducer.js').WarpStateV5, receipt: import('../types/TickReceipt.js').TickReceipt}} */ (
|
|
204
|
+
joinPatch(this._cachedState, /** @type {any} */ (committed), sha, true) // TODO(ts-cleanup): narrow patch type
|
|
205
|
+
);
|
|
206
|
+
tickReceipt = result.receipt;
|
|
207
|
+
} else {
|
|
208
|
+
joinPatch(this._cachedState, /** @type {any} */ (committed), sha); // TODO(ts-cleanup): narrow patch type to PatchV2
|
|
209
|
+
}
|
|
210
|
+
await this._setMaterializedState(this._cachedState);
|
|
211
|
+
// Update provenance index with new patch
|
|
212
|
+
if (this._provenanceIndex) {
|
|
213
|
+
this._provenanceIndex.addPatch(sha, /** @type {string[]|undefined} */ (committed.reads), /** @type {string[]|undefined} */ (committed.writes));
|
|
214
|
+
}
|
|
215
|
+
// Keep _lastFrontier in sync so hasFrontierChanged() won't misreport stale
|
|
216
|
+
if (this._lastFrontier) {
|
|
217
|
+
this._lastFrontier.set(writerId, sha);
|
|
218
|
+
}
|
|
219
|
+
// Audit receipt — AFTER all state updates succeed
|
|
220
|
+
if (this._auditService && tickReceipt) {
|
|
221
|
+
try {
|
|
222
|
+
await this._auditService.commit(tickReceipt);
|
|
223
|
+
} catch {
|
|
224
|
+
// Data commit already succeeded. Logged inside service.
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
this._stateDirty = true;
|
|
229
|
+
if (this._auditService) {
|
|
230
|
+
this._auditSkipCount++;
|
|
231
|
+
this._logger?.warn('[warp:audit]', {
|
|
232
|
+
code: 'AUDIT_SKIPPED_DIRTY_STATE',
|
|
233
|
+
sha,
|
|
234
|
+
skipCount: this._auditSkipCount,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Creates a Writer bound to an existing (or resolved) writer ID.
|
|
242
|
+
*
|
|
243
|
+
* @this {import('../WarpGraph.js').default}
|
|
244
|
+
* @param {string} writerId - The writer ID to resolve
|
|
245
|
+
* @returns {Promise<Writer>} A Writer instance
|
|
246
|
+
*/
|
|
247
|
+
export async function writer(writerId) {
|
|
248
|
+
// Build config adapters for resolveWriterId
|
|
249
|
+
const configGet = async (/** @type {string} */ key) => await this._persistence.configGet(key);
|
|
250
|
+
const configSet = async (/** @type {string} */ key, /** @type {string} */ value) => await this._persistence.configSet(key, value);
|
|
251
|
+
|
|
252
|
+
// Resolve the writer ID
|
|
253
|
+
const resolvedWriterId = await resolveWriterId({
|
|
254
|
+
graphName: this._graphName,
|
|
255
|
+
explicitWriterId: writerId,
|
|
256
|
+
configGet,
|
|
257
|
+
configSet,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
return new Writer({
|
|
261
|
+
persistence: /** @type {any} */ (this._persistence), // TODO(ts-cleanup): narrow port type
|
|
262
|
+
graphName: this._graphName,
|
|
263
|
+
writerId: resolvedWriterId,
|
|
264
|
+
versionVector: this._versionVector,
|
|
265
|
+
getCurrentState: () => /** @type {any} */ (this._cachedState), // TODO(ts-cleanup): narrow port type
|
|
266
|
+
onDeleteWithData: this._onDeleteWithData,
|
|
267
|
+
onCommitSuccess: (/** @type {any} */ opts) => this._onPatchCommitted(resolvedWriterId, opts), // TODO(ts-cleanup): type sync protocol
|
|
268
|
+
codec: this._codec,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Creates a new Writer with a fresh canonical ID.
|
|
274
|
+
*
|
|
275
|
+
* This always generates a new unique writer ID, regardless of any
|
|
276
|
+
* existing configuration. Use this when you need a guaranteed fresh
|
|
277
|
+
* identity (e.g., spawning a new writer process).
|
|
278
|
+
*
|
|
279
|
+
* @deprecated Use `writer()` to resolve a stable ID from git config, or `writer(id)` with an explicit ID.
|
|
280
|
+
* @this {import('../WarpGraph.js').default}
|
|
281
|
+
* @param {Object} [opts]
|
|
282
|
+
* @param {'config'|'none'} [opts.persist='none'] - Whether to persist the new ID to git config
|
|
283
|
+
* @param {string} [opts.alias] - Optional alias for config key (used with persist:'config')
|
|
284
|
+
* @returns {Promise<Writer>} A Writer instance with new canonical ID
|
|
285
|
+
* @throws {Error} If config operations fail (when persist:'config')
|
|
286
|
+
*
|
|
287
|
+
* @example
|
|
288
|
+
* // Create ephemeral writer (not persisted)
|
|
289
|
+
* const writer = await graph.createWriter();
|
|
290
|
+
*
|
|
291
|
+
* @example
|
|
292
|
+
* // Create and persist to git config
|
|
293
|
+
* const writer = await graph.createWriter({ persist: 'config' });
|
|
294
|
+
*/
|
|
295
|
+
export async function createWriter(opts = {}) {
|
|
296
|
+
if (this._logger) {
|
|
297
|
+
this._logger.warn('[warp] createWriter() is deprecated. Use writer() or writer(id) instead.');
|
|
298
|
+
} else {
|
|
299
|
+
// eslint-disable-next-line no-console
|
|
300
|
+
console.warn('[warp] createWriter() is deprecated. Use writer() or writer(id) instead.');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const { persist = 'none', alias } = opts;
|
|
304
|
+
|
|
305
|
+
// Generate new canonical writerId
|
|
306
|
+
const freshWriterId = generateWriterId();
|
|
307
|
+
|
|
308
|
+
// Optionally persist to git config
|
|
309
|
+
if (persist === 'config') {
|
|
310
|
+
const configKey = alias
|
|
311
|
+
? `warp.writerId.${alias}`
|
|
312
|
+
: `warp.writerId.${this._graphName}`;
|
|
313
|
+
await this._persistence.configSet(configKey, freshWriterId);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return new Writer({
|
|
317
|
+
persistence: /** @type {any} */ (this._persistence), // TODO(ts-cleanup): narrow port type
|
|
318
|
+
graphName: this._graphName,
|
|
319
|
+
writerId: freshWriterId,
|
|
320
|
+
versionVector: this._versionVector,
|
|
321
|
+
getCurrentState: () => /** @type {any} */ (this._cachedState), // TODO(ts-cleanup): narrow port type
|
|
322
|
+
onDeleteWithData: this._onDeleteWithData,
|
|
323
|
+
onCommitSuccess: (/** @type {any} */ commitOpts) => this._onPatchCommitted(freshWriterId, commitOpts), // TODO(ts-cleanup): type sync protocol
|
|
324
|
+
codec: this._codec,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Ensures cached state is fresh. When autoMaterialize is enabled,
|
|
330
|
+
* materializes if state is null or dirty. Otherwise throws.
|
|
331
|
+
*
|
|
332
|
+
* @this {import('../WarpGraph.js').default}
|
|
333
|
+
* @returns {Promise<void>}
|
|
334
|
+
* @throws {QueryError} If no cached state and autoMaterialize is off (code: `E_NO_STATE`)
|
|
335
|
+
* @throws {QueryError} If cached state is dirty and autoMaterialize is off (code: `E_STALE_STATE`)
|
|
336
|
+
*/
|
|
337
|
+
export async function _ensureFreshState() {
|
|
338
|
+
if (this._autoMaterialize && (!this._cachedState || this._stateDirty)) {
|
|
339
|
+
await this.materialize();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (!this._cachedState) {
|
|
343
|
+
throw new QueryError(
|
|
344
|
+
E_NO_STATE_MSG,
|
|
345
|
+
{ code: 'E_NO_STATE' },
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
if (this._stateDirty) {
|
|
349
|
+
throw new QueryError(
|
|
350
|
+
E_STALE_STATE_MSG,
|
|
351
|
+
{ code: 'E_STALE_STATE' },
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Discovers all writers that have written to this graph.
|
|
358
|
+
*
|
|
359
|
+
* @this {import('../WarpGraph.js').default}
|
|
360
|
+
* @returns {Promise<string[]>} Sorted array of writer IDs
|
|
361
|
+
*/
|
|
362
|
+
export async function discoverWriters() {
|
|
363
|
+
const prefix = buildWritersPrefix(this._graphName);
|
|
364
|
+
const refs = await this._persistence.listRefs(prefix);
|
|
365
|
+
|
|
366
|
+
const writerIds = [];
|
|
367
|
+
for (const refPath of refs) {
|
|
368
|
+
const writerId = parseWriterIdFromRef(refPath);
|
|
369
|
+
if (writerId) {
|
|
370
|
+
writerIds.push(writerId);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return writerIds.sort();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Discovers all distinct Lamport ticks across all writers.
|
|
379
|
+
*
|
|
380
|
+
* Walks each writer's patch chain from tip to root, reading commit
|
|
381
|
+
* messages (no CBOR blob deserialization) to extract Lamport timestamps.
|
|
382
|
+
* Stops when a non-patch commit (e.g. checkpoint) is encountered.
|
|
383
|
+
* Logs a warning for any non-monotonic lamport sequence within a single
|
|
384
|
+
* writer's chain.
|
|
385
|
+
*
|
|
386
|
+
* @this {import('../WarpGraph.js').default}
|
|
387
|
+
* @returns {Promise<{
|
|
388
|
+
* ticks: number[],
|
|
389
|
+
* maxTick: number,
|
|
390
|
+
* perWriter: Map<string, {ticks: number[], tipSha: string|null, tickShas: Record<number, string>}>
|
|
391
|
+
* }>} `ticks` is the sorted (ascending) deduplicated union of all
|
|
392
|
+
* Lamport values; `maxTick` is the largest value (0 if none);
|
|
393
|
+
* `perWriter` maps each writer ID to its ticks in ascending order
|
|
394
|
+
* and its current tip SHA (or `null` if the writer ref is missing)
|
|
395
|
+
* @throws {Error} If reading refs or commit metadata fails
|
|
396
|
+
*/
|
|
397
|
+
export async function discoverTicks() {
|
|
398
|
+
const writerIds = await this.discoverWriters();
|
|
399
|
+
/** @type {Set<number>} */
|
|
400
|
+
const globalTickSet = new Set();
|
|
401
|
+
const perWriter = new Map();
|
|
402
|
+
|
|
403
|
+
for (const writerId of writerIds) {
|
|
404
|
+
const writerRef = buildWriterRef(this._graphName, writerId);
|
|
405
|
+
const tipSha = await this._persistence.readRef(writerRef);
|
|
406
|
+
const writerTicks = [];
|
|
407
|
+
/** @type {Record<number, string>} */
|
|
408
|
+
const tickShas = {};
|
|
409
|
+
|
|
410
|
+
if (tipSha) {
|
|
411
|
+
let currentSha = tipSha;
|
|
412
|
+
let lastLamport = Infinity;
|
|
413
|
+
|
|
414
|
+
while (currentSha) {
|
|
415
|
+
const nodeInfo = await this._persistence.getNodeInfo(currentSha);
|
|
416
|
+
const kind = detectMessageKind(nodeInfo.message);
|
|
417
|
+
if (kind !== 'patch') {
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const patchMeta = decodePatchMessage(nodeInfo.message);
|
|
422
|
+
globalTickSet.add(patchMeta.lamport);
|
|
423
|
+
writerTicks.push(patchMeta.lamport);
|
|
424
|
+
tickShas[patchMeta.lamport] = currentSha;
|
|
425
|
+
|
|
426
|
+
// Check monotonic invariant (walking newest->oldest, lamport should decrease)
|
|
427
|
+
if (patchMeta.lamport > lastLamport && this._logger) {
|
|
428
|
+
this._logger.warn(`[warp] non-monotonic lamport for writer ${writerId}: ${patchMeta.lamport} > ${lastLamport}`);
|
|
429
|
+
}
|
|
430
|
+
lastLamport = patchMeta.lamport;
|
|
431
|
+
|
|
432
|
+
if (nodeInfo.parents && nodeInfo.parents.length > 0) {
|
|
433
|
+
currentSha = nodeInfo.parents[0];
|
|
434
|
+
} else {
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
perWriter.set(writerId, {
|
|
441
|
+
ticks: writerTicks.reverse(),
|
|
442
|
+
tipSha: tipSha || null,
|
|
443
|
+
tickShas,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const ticks = [...globalTickSet].sort((a, b) => a - b);
|
|
448
|
+
const maxTick = ticks.length > 0 ? ticks[ticks.length - 1] : 0;
|
|
449
|
+
|
|
450
|
+
return { ticks, maxTick, perWriter };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Joins an external WarpStateV5 into the cached state using CRDT merge.
|
|
455
|
+
*
|
|
456
|
+
* @this {import('../WarpGraph.js').default}
|
|
457
|
+
* @param {import('../services/JoinReducer.js').WarpStateV5} otherState - The state to merge in
|
|
458
|
+
* @returns {{state: import('../services/JoinReducer.js').WarpStateV5, receipt: Object}} Merged state and receipt
|
|
459
|
+
* @throws {QueryError} If no cached state exists (code: `E_NO_STATE`)
|
|
460
|
+
* @throws {Error} If otherState is invalid
|
|
461
|
+
*/
|
|
462
|
+
export function join(otherState) {
|
|
463
|
+
if (!this._cachedState) {
|
|
464
|
+
throw new QueryError(E_NO_STATE_MSG, {
|
|
465
|
+
code: 'E_NO_STATE',
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (!otherState || !otherState.nodeAlive || !otherState.edgeAlive) {
|
|
470
|
+
throw new Error('Invalid state: must be a valid WarpStateV5 object');
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Capture pre-merge counts for receipt
|
|
474
|
+
const beforeNodes = orsetElements(this._cachedState.nodeAlive).length;
|
|
475
|
+
const beforeEdges = orsetElements(this._cachedState.edgeAlive).length;
|
|
476
|
+
const beforeFrontierSize = this._cachedState.observedFrontier.size;
|
|
477
|
+
|
|
478
|
+
// Perform the join
|
|
479
|
+
const mergedState = joinStates(this._cachedState, otherState);
|
|
480
|
+
|
|
481
|
+
// Calculate receipt
|
|
482
|
+
const afterNodes = orsetElements(mergedState.nodeAlive).length;
|
|
483
|
+
const afterEdges = orsetElements(mergedState.edgeAlive).length;
|
|
484
|
+
const afterFrontierSize = mergedState.observedFrontier.size;
|
|
485
|
+
|
|
486
|
+
// Count property changes (keys that existed in both but have different values)
|
|
487
|
+
let propsChanged = 0;
|
|
488
|
+
for (const [key, reg] of mergedState.prop) {
|
|
489
|
+
const oldReg = this._cachedState.prop.get(key);
|
|
490
|
+
if (!oldReg || oldReg.value !== reg.value) {
|
|
491
|
+
propsChanged++;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const receipt = {
|
|
496
|
+
nodesAdded: Math.max(0, afterNodes - beforeNodes),
|
|
497
|
+
nodesRemoved: Math.max(0, beforeNodes - afterNodes),
|
|
498
|
+
edgesAdded: Math.max(0, afterEdges - beforeEdges),
|
|
499
|
+
edgesRemoved: Math.max(0, beforeEdges - afterEdges),
|
|
500
|
+
propsChanged,
|
|
501
|
+
frontierMerged: afterFrontierSize !== beforeFrontierSize ||
|
|
502
|
+
!this._frontierEquals(this._cachedState.observedFrontier, mergedState.observedFrontier),
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
// Update cached state
|
|
506
|
+
this._cachedState = mergedState;
|
|
507
|
+
|
|
508
|
+
return { state: mergedState, receipt };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Compares two version vectors for equality.
|
|
513
|
+
*
|
|
514
|
+
* @this {import('../WarpGraph.js').default}
|
|
515
|
+
* @param {import('../crdt/VersionVector.js').VersionVector} a
|
|
516
|
+
* @param {import('../crdt/VersionVector.js').VersionVector} b
|
|
517
|
+
* @returns {boolean}
|
|
518
|
+
*/
|
|
519
|
+
export function _frontierEquals(a, b) {
|
|
520
|
+
if (a.size !== b.size) {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
for (const [key, val] of a) {
|
|
524
|
+
if (b.get(key) !== val) {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return true;
|
|
529
|
+
}
|