@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.
Files changed (71) hide show
  1. package/README.md +53 -32
  2. package/SECURITY.md +64 -0
  3. package/bin/cli/commands/check.js +168 -0
  4. package/bin/cli/commands/doctor/checks.js +422 -0
  5. package/bin/cli/commands/doctor/codes.js +46 -0
  6. package/bin/cli/commands/doctor/index.js +239 -0
  7. package/bin/cli/commands/doctor/types.js +89 -0
  8. package/bin/cli/commands/history.js +73 -0
  9. package/bin/cli/commands/info.js +139 -0
  10. package/bin/cli/commands/install-hooks.js +128 -0
  11. package/bin/cli/commands/materialize.js +99 -0
  12. package/bin/cli/commands/path.js +88 -0
  13. package/bin/cli/commands/query.js +194 -0
  14. package/bin/cli/commands/registry.js +28 -0
  15. package/bin/cli/commands/seek.js +592 -0
  16. package/bin/cli/commands/trust.js +154 -0
  17. package/bin/cli/commands/verify-audit.js +113 -0
  18. package/bin/cli/commands/view.js +45 -0
  19. package/bin/cli/infrastructure.js +336 -0
  20. package/bin/cli/schemas.js +177 -0
  21. package/bin/cli/shared.js +244 -0
  22. package/bin/cli/types.js +85 -0
  23. package/bin/presenters/index.js +214 -0
  24. package/bin/presenters/json.js +66 -0
  25. package/bin/presenters/text.js +543 -0
  26. package/bin/warp-graph.js +19 -2824
  27. package/index.d.ts +32 -2
  28. package/index.js +2 -0
  29. package/package.json +9 -7
  30. package/src/domain/WarpGraph.js +106 -3252
  31. package/src/domain/errors/QueryError.js +2 -2
  32. package/src/domain/errors/TrustError.js +29 -0
  33. package/src/domain/errors/index.js +1 -0
  34. package/src/domain/services/AuditMessageCodec.js +137 -0
  35. package/src/domain/services/AuditReceiptService.js +471 -0
  36. package/src/domain/services/AuditVerifierService.js +693 -0
  37. package/src/domain/services/HttpSyncServer.js +36 -22
  38. package/src/domain/services/MessageCodecInternal.js +3 -0
  39. package/src/domain/services/MessageSchemaDetector.js +2 -2
  40. package/src/domain/services/SyncAuthService.js +69 -3
  41. package/src/domain/services/WarpMessageCodec.js +4 -1
  42. package/src/domain/trust/TrustCanonical.js +42 -0
  43. package/src/domain/trust/TrustCrypto.js +111 -0
  44. package/src/domain/trust/TrustEvaluator.js +180 -0
  45. package/src/domain/trust/TrustRecordService.js +274 -0
  46. package/src/domain/trust/TrustStateBuilder.js +209 -0
  47. package/src/domain/trust/canonical.js +68 -0
  48. package/src/domain/trust/reasonCodes.js +64 -0
  49. package/src/domain/trust/schemas.js +160 -0
  50. package/src/domain/trust/verdict.js +42 -0
  51. package/src/domain/types/git-cas.d.ts +20 -0
  52. package/src/domain/utils/RefLayout.js +59 -0
  53. package/src/domain/warp/PatchSession.js +18 -0
  54. package/src/domain/warp/Writer.js +18 -3
  55. package/src/domain/warp/_internal.js +26 -0
  56. package/src/domain/warp/_wire.js +58 -0
  57. package/src/domain/warp/_wiredMethods.d.ts +100 -0
  58. package/src/domain/warp/checkpoint.methods.js +397 -0
  59. package/src/domain/warp/fork.methods.js +323 -0
  60. package/src/domain/warp/materialize.methods.js +188 -0
  61. package/src/domain/warp/materializeAdvanced.methods.js +339 -0
  62. package/src/domain/warp/patch.methods.js +529 -0
  63. package/src/domain/warp/provenance.methods.js +284 -0
  64. package/src/domain/warp/query.methods.js +279 -0
  65. package/src/domain/warp/subscribe.methods.js +272 -0
  66. package/src/domain/warp/sync.methods.js +549 -0
  67. package/src/infrastructure/adapters/GitGraphAdapter.js +67 -1
  68. package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
  69. package/src/ports/CommitPort.js +10 -0
  70. package/src/ports/RefPort.js +17 -0
  71. 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
+ }