@git-stunts/git-warp 11.3.3 → 11.5.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 CHANGED
@@ -55,6 +55,10 @@ const result = await graph.query()
55
55
 
56
56
  ## How It Works
57
57
 
58
+ <p align="center">
59
+ <img src="docs/diagrams/fig-data-storage.svg" alt="WARP data storage — invisible to normal Git workflows" width="700">
60
+ </p>
61
+
58
62
  ### The Multi-Writer Problem (and How It's Solved)
59
63
 
60
64
  Multiple people (or machines, or processes) can write to the same graph **simultaneously, without any coordination**. There's no central server, no locking, no "wait your turn."
@@ -73,6 +77,10 @@ Every operation gets a unique **EventId** — `(lamport, writerId, patchSha, opI
73
77
 
74
78
  ## Multi-Writer Collaboration
75
79
 
80
+ <p align="center">
81
+ <img src="docs/diagrams/fig-multi-writer.svg" alt="Multi-writer convergence — independent chains, deterministic merge" width="700">
82
+ </p>
83
+
76
84
  Writers operate independently on the same Git repository. Sync happens through standard Git transport (push/pull) or the built-in HTTP sync protocol.
77
85
 
78
86
  ```javascript
@@ -328,6 +336,29 @@ const sha = await (await graph.createPatch())
328
336
 
329
337
  Each `commit()` creates one Git commit containing all the operations, advances the writer's Lamport clock, and updates the writer's ref via compare-and-swap.
330
338
 
339
+ ### Content Attachment
340
+
341
+ Attach content-addressed blobs to nodes and edges as first-class payloads (Paper I `Atom(p)`). Blobs are stored in the Git object store, referenced by SHA, and inherit CRDT merge, time-travel, and observer scoping automatically.
342
+
343
+ ```javascript
344
+ const patch = await graph.createPatch();
345
+ patch.addNode('adr:0007'); // sync — queues a NodeAdd op
346
+ await patch.attachContent('adr:0007', '# ADR 0007\n\nDecision text...'); // async — writes blob
347
+ await patch.commit();
348
+
349
+ // Read content back
350
+ const buffer = await graph.getContent('adr:0007'); // Buffer | null
351
+ const oid = await graph.getContentOid('adr:0007'); // hex SHA or null
352
+
353
+ // Edge content works the same way (assumes nodes and edge already exist)
354
+ const patch2 = await graph.createPatch();
355
+ await patch2.attachEdgeContent('a', 'b', 'rel', 'edge payload');
356
+ await patch2.commit();
357
+ const edgeBuf = await graph.getEdgeContent('a', 'b', 'rel');
358
+ ```
359
+
360
+ Content blobs survive `git gc` — their OIDs are embedded in the patch commit tree and checkpoint tree, keeping them reachable.
361
+
331
362
  ### Writer API
332
363
 
333
364
  For repeated writes, the Writer API is more convenient:
@@ -434,6 +465,10 @@ When a seek cursor is active, `query`, `info`, `materialize`, and `history` auto
434
465
 
435
466
  ## Architecture
436
467
 
468
+ <p align="center">
469
+ <img src="docs/diagrams/fig-architecture.svg" alt="Hexagonal architecture — dependency rule: arrows point inward only" width="700">
470
+ </p>
471
+
437
472
  The codebase follows hexagonal architecture with ports and adapters:
438
473
 
439
474
  **Ports** define abstract interfaces for infrastructure:
@@ -508,7 +543,7 @@ npm run test:matrix # All runtimes in parallel
508
543
  ## When NOT to Use It
509
544
 
510
545
  - **High-throughput transactional workloads.** If you need thousands of writes per second with immediate consistency, use Postgres or Redis.
511
- - **Large binary or blob storage.** Data lives in Git commit messages (default cap 1 MB). Use object storage for images or videos.
546
+ - **Large binary or blob storage.** Properties live in Git commit messages; content blobs live in the Git object store. Neither is optimized for large media files. Use object storage for images or videos.
512
547
  - **Sub-millisecond read latency.** Materialization has overhead. Use an in-memory database for real-time gaming physics or HFT.
513
548
  - **Simple key-value storage.** If you don't have relationships or need traversals, a graph database is overkill.
514
549
  - **Non-Git environments.** The value proposition depends on Git infrastructure (push/pull, content-addressing).
package/index.d.ts CHANGED
@@ -1147,6 +1147,12 @@ export function decodeEdgePropKey(encoded: string): { from: string; to: string;
1147
1147
  */
1148
1148
  export function isEdgePropKey(key: string): boolean;
1149
1149
 
1150
+ /**
1151
+ * Well-known property key for content attachment.
1152
+ * Stores a content-addressed blob OID as the property value.
1153
+ */
1154
+ export const CONTENT_PROPERTY_KEY: '_content';
1155
+
1150
1156
  /**
1151
1157
  * Configuration for an observer view.
1152
1158
  */
@@ -1345,6 +1351,10 @@ export class PatchBuilderV2 {
1345
1351
  setProperty(nodeId: string, key: string, value: unknown): PatchBuilderV2;
1346
1352
  /** Sets a property on an edge. */
1347
1353
  setEdgeProperty(from: string, to: string, label: string, key: string, value: unknown): PatchBuilderV2;
1354
+ /** Attaches content to a node (writes blob + sets _content property). */
1355
+ attachContent(nodeId: string, content: Buffer | string): Promise<PatchBuilderV2>;
1356
+ /** Attaches content to an edge (writes blob + sets _content edge property). */
1357
+ attachEdgeContent(from: string, to: string, label: string, content: Buffer | string): Promise<PatchBuilderV2>;
1348
1358
  /** Builds the PatchV2 object without committing. */
1349
1359
  build(): PatchV2;
1350
1360
  /** Commits the patch to the graph and returns the commit SHA. */
@@ -1375,6 +1385,10 @@ export class PatchSession {
1375
1385
  setProperty(nodeId: string, key: string, value: unknown): this;
1376
1386
  /** Sets a property on an edge. */
1377
1387
  setEdgeProperty(from: string, to: string, label: string, key: string, value: unknown): this;
1388
+ /** Attaches content to a node (writes blob + sets _content property). */
1389
+ attachContent(nodeId: string, content: Buffer | string): Promise<this>;
1390
+ /** Attaches content to an edge (writes blob + sets _content edge property). */
1391
+ attachEdgeContent(from: string, to: string, label: string, content: Buffer | string): Promise<this>;
1378
1392
  /** Builds the PatchV2 object without committing. */
1379
1393
  build(): PatchV2;
1380
1394
  /** Commits the patch with CAS protection. */
@@ -1645,6 +1659,28 @@ export default class WarpGraph {
1645
1659
  */
1646
1660
  getEdgeProps(from: string, to: string, label: string): Promise<Record<string, unknown> | null>;
1647
1661
 
1662
+ /**
1663
+ * Gets the content blob OID for a node, or null if none is attached.
1664
+ */
1665
+ getContentOid(nodeId: string): Promise<string | null>;
1666
+
1667
+ /**
1668
+ * Gets the content blob for a node, or null if none is attached.
1669
+ * Returns raw Buffer; call `.toString('utf8')` for text.
1670
+ */
1671
+ getContent(nodeId: string): Promise<Buffer | null>;
1672
+
1673
+ /**
1674
+ * Gets the content blob OID for an edge, or null if none is attached.
1675
+ */
1676
+ getEdgeContentOid(from: string, to: string, label: string): Promise<string | null>;
1677
+
1678
+ /**
1679
+ * Gets the content blob for an edge, or null if none is attached.
1680
+ * Returns raw Buffer; call `.toString('utf8')` for text.
1681
+ */
1682
+ getEdgeContent(from: string, to: string, label: string): Promise<Buffer | null>;
1683
+
1648
1684
  /**
1649
1685
  * Checks if a node exists in the materialized state.
1650
1686
  */
package/index.js CHANGED
@@ -75,6 +75,7 @@ import {
75
75
  encodeEdgePropKey,
76
76
  decodeEdgePropKey,
77
77
  isEdgePropKey,
78
+ CONTENT_PROPERTY_KEY,
78
79
  } from './src/domain/services/KeyCodec.js';
79
80
  import {
80
81
  createTickReceipt,
@@ -173,6 +174,7 @@ export {
173
174
  encodeEdgePropKey,
174
175
  decodeEdgePropKey,
175
176
  isEdgePropKey,
177
+ CONTENT_PROPERTY_KEY,
176
178
 
177
179
  // WARP migration
178
180
  migrateV4toV5,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@git-stunts/git-warp",
3
- "version": "11.3.3",
3
+ "version": "11.5.1",
4
4
  "description": "Deterministic WARP graph over Git: graph-native storage, traversal, and tooling.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -90,7 +90,8 @@
90
90
  "typecheck:src": "tsc --noEmit -p tsconfig.src.json",
91
91
  "typecheck:test": "tsc --noEmit -p tsconfig.test.json",
92
92
  "typecheck:consumer": "tsc --noEmit -p test/type-check/tsconfig.json",
93
- "typecheck:policy": "node scripts/ts-policy-check.js"
93
+ "typecheck:policy": "node scripts/ts-policy-check.js",
94
+ "typecheck:surface": "node scripts/check-dts-surface.js"
94
95
  },
95
96
  "dependencies": {
96
97
  "@git-stunts/alfred": "^0.4.0",
@@ -18,12 +18,12 @@ import defaultCrypto from './utils/defaultCrypto.js';
18
18
  import defaultClock from './utils/defaultClock.js';
19
19
  import LogicalTraversal from './services/LogicalTraversal.js';
20
20
  import LRUCache from './utils/LRUCache.js';
21
+ import SyncController from './services/SyncController.js';
21
22
  import { wireWarpMethods } from './warp/_wire.js';
22
23
  import * as queryMethods from './warp/query.methods.js';
23
24
  import * as subscribeMethods from './warp/subscribe.methods.js';
24
25
  import * as provenanceMethods from './warp/provenance.methods.js';
25
26
  import * as forkMethods from './warp/fork.methods.js';
26
- import * as syncMethods from './warp/sync.methods.js';
27
27
  import * as checkpointMethods from './warp/checkpoint.methods.js';
28
28
  import * as patchMethods from './warp/patch.methods.js';
29
29
  import * as materializeMethods from './warp/materialize.methods.js';
@@ -172,6 +172,9 @@ export default class WarpGraph {
172
172
 
173
173
  /** @type {number} */
174
174
  this._auditSkipCount = 0;
175
+
176
+ /** @type {SyncController} */
177
+ this._syncController = new SyncController(this);
175
178
  }
176
179
 
177
180
  /**
@@ -410,9 +413,26 @@ wireWarpMethods(WarpGraph, [
410
413
  subscribeMethods,
411
414
  provenanceMethods,
412
415
  forkMethods,
413
- syncMethods,
414
416
  checkpointMethods,
415
417
  patchMethods,
416
418
  materializeMethods,
417
419
  materializeAdvancedMethods,
418
420
  ]);
421
+
422
+ // ── Sync methods: direct delegation to SyncController (no stub file) ────────
423
+ const syncDelegates = /** @type {const} */ ([
424
+ 'getFrontier', 'hasFrontierChanged', 'status',
425
+ 'createSyncRequest', 'processSyncRequest', 'applySyncResponse',
426
+ 'syncNeeded', 'syncWith', 'serve',
427
+ ]);
428
+ for (const method of syncDelegates) {
429
+ Object.defineProperty(WarpGraph.prototype, method, {
430
+ // eslint-disable-next-line object-shorthand -- function keyword needed for `this` binding
431
+ value: /** @this {WarpGraph} @param {*[]} args */ function (...args) {
432
+ return this._syncController[method](...args);
433
+ },
434
+ writable: true,
435
+ configurable: true,
436
+ enumerable: false,
437
+ });
438
+ }
@@ -4,6 +4,7 @@ import nullLogger from '../utils/nullLogger.js';
4
4
  import LRUCache from '../utils/LRUCache.js';
5
5
  import { getRoaringBitmap32 } from '../utils/roaring.js';
6
6
  import { canonicalStringify } from '../utils/canonicalStringify.js';
7
+ import { isValidShardOid } from '../utils/validateShardOid.js';
7
8
 
8
9
  /** @typedef {import('../../ports/IndexStoragePort.js').default} IndexStoragePort */
9
10
  /** @typedef {import('../types/WarpPersistence.js').IndexStorage} IndexStorage */
@@ -50,24 +51,24 @@ const computeChecksum = async (data, version, crypto) => {
50
51
  * - {@link ShardCorruptionError} for invalid shard format
51
52
  * - {@link ShardValidationError} for version or checksum mismatches
52
53
  *
53
- * In non-strict mode (default), validation failures are logged as warnings
54
+ * In non-strict mode (strict: false), validation failures are logged as warnings
54
55
  * and an empty shard is returned for graceful degradation.
55
56
  *
56
57
  * **Note**: Storage errors (e.g., `storage.readBlob` failures) always throw
57
58
  * {@link ShardLoadError} regardless of strict mode.
58
59
  *
59
60
  * @example
60
- * // Non-strict mode (default) - graceful degradation on validation errors
61
+ * // Strict mode (default) - throws on any validation failure
61
62
  * const reader = new BitmapIndexReader({ storage });
62
63
  * reader.setup(shardOids);
63
64
  * const parents = await reader.getParents('abc123...');
64
65
  *
65
66
  * @example
66
- * // Strict mode - throws on any validation failure
67
- * const strictReader = new BitmapIndexReader({ storage, strict: true });
68
- * strictReader.setup(shardOids);
67
+ * // Non-strict mode - graceful degradation on validation errors
68
+ * const lenientReader = new BitmapIndexReader({ storage, strict: false });
69
+ * lenientReader.setup(shardOids);
69
70
  * try {
70
- * const parents = await strictReader.getParents('abc123...');
71
+ * const parents = await lenientReader.getParents('abc123...');
71
72
  * } catch (err) {
72
73
  * if (err instanceof ShardValidationError) {
73
74
  * console.error('Shard validation failed:', err.field, err.expected, err.actual);
@@ -83,14 +84,14 @@ export default class BitmapIndexReader {
83
84
  * Creates a BitmapIndexReader instance.
84
85
  * @param {Object} options
85
86
  * @param {IndexStoragePort} options.storage - Storage adapter for reading index data
86
- * @param {boolean} [options.strict=false] - If true, throw errors on validation failures; if false, log warnings and return empty shards
87
+ * @param {boolean} [options.strict=true] - If true, throw errors on validation failures; if false, log warnings and return empty shards
87
88
  * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger for structured logging.
88
89
  * Defaults to NoOpLogger (no logging).
89
90
  * @param {number} [options.maxCachedShards=100] - Maximum number of shards to keep in the LRU cache.
90
91
  * When exceeded, least recently used shards are evicted to free memory.
91
92
  * @param {import('../../ports/CryptoPort.js').default} [options.crypto] - CryptoPort instance for checksum verification.
92
93
  */
93
- constructor({ storage, strict = false, logger = nullLogger, maxCachedShards = DEFAULT_MAX_CACHED_SHARDS, crypto } = /** @type {{ storage: IndexStoragePort, strict?: boolean, logger?: LoggerPort, maxCachedShards?: number, crypto?: CryptoPort }} */ ({})) {
94
+ constructor({ storage, strict = true, logger = nullLogger, maxCachedShards = DEFAULT_MAX_CACHED_SHARDS, crypto } = /** @type {{ storage: IndexStoragePort, strict?: boolean, logger?: LoggerPort, maxCachedShards?: number, crypto?: CryptoPort }} */ ({})) {
94
95
  if (!storage) {
95
96
  throw new Error('BitmapIndexReader requires a storage adapter');
96
97
  }
@@ -132,8 +133,29 @@ export default class BitmapIndexReader {
132
133
  * const parents = await reader.getParents('abcd1234...'); // loads meta_ab, shards_rev_ab
133
134
  */
134
135
  setup(shardOids) {
135
- this.shardOids = new Map(Object.entries(shardOids));
136
- this._idToShaCache = null; // Clear cache when shards change
136
+ const entries = Object.entries(shardOids);
137
+ /** @type {[string, string][]} */
138
+ const validEntries = [];
139
+ for (const [path, oid] of entries) {
140
+ if (isValidShardOid(oid)) {
141
+ validEntries.push([path, oid]);
142
+ } else if (this.strict) {
143
+ throw new ShardCorruptionError('Invalid shard OID', {
144
+ shardPath: path,
145
+ oid,
146
+ reason: 'invalid_oid',
147
+ });
148
+ } else {
149
+ this.logger.warn('Skipping shard with invalid OID', {
150
+ operation: 'setup',
151
+ shardPath: path,
152
+ oid,
153
+ reason: 'invalid_oid',
154
+ });
155
+ }
156
+ }
157
+ this.shardOids = new Map(validEntries);
158
+ this._idToShaCache = null;
137
159
  this.loadedShards.clear();
138
160
  }
139
161
 
@@ -25,7 +25,7 @@ import { createORSet, orsetAdd, orsetCompact } from '../crdt/ORSet.js';
25
25
  import { createDot } from '../crdt/Dot.js';
26
26
  import { createVersionVector } from '../crdt/VersionVector.js';
27
27
  import { cloneStateV5, reduceV5 } from './JoinReducer.js';
28
- import { encodeEdgeKey, encodePropKey } from './KeyCodec.js';
28
+ import { encodeEdgeKey, encodePropKey, CONTENT_PROPERTY_KEY, decodePropKey, isEdgePropKey, decodeEdgePropKey } from './KeyCodec.js';
29
29
  import { ProvenanceIndex } from './ProvenanceIndex.js';
30
30
 
31
31
  // ============================================================================
@@ -132,6 +132,20 @@ export async function createV5({
132
132
  provenanceIndexBlobOid = await persistence.writeBlob(/** @type {Buffer} */ (provenanceIndexBuffer));
133
133
  }
134
134
 
135
+ // 6c. Collect content blob OIDs from state properties for GC anchoring.
136
+ // If patch commits are ever pruned, content blobs remain reachable via
137
+ // the checkpoint tree. Without this, git gc would nuke content blobs
138
+ // whose only anchor was the (now-pruned) patch commit tree.
139
+ const contentOids = new Set();
140
+ for (const [propKey, register] of checkpointState.prop) {
141
+ const { propKey: decodedKey } = isEdgePropKey(propKey)
142
+ ? decodeEdgePropKey(propKey)
143
+ : decodePropKey(propKey);
144
+ if (decodedKey === CONTENT_PROPERTY_KEY && typeof register.value === 'string') {
145
+ contentOids.add(register.value);
146
+ }
147
+ }
148
+
135
149
  // 7. Create tree with sorted entries
136
150
  const treeEntries = [
137
151
  `100644 blob ${appliedVVBlobOid}\tappliedVV.cbor`,
@@ -145,6 +159,11 @@ export async function createV5({
145
159
  treeEntries.push(`100644 blob ${provenanceIndexBlobOid}\tprovenanceIndex.cbor`);
146
160
  }
147
161
 
162
+ // Add content blob anchors
163
+ for (const oid of contentOids) {
164
+ treeEntries.push(`100644 blob ${oid}\t_content_${oid}`);
165
+ }
166
+
148
167
  // Sort entries by filename for deterministic tree (git requires sorted entries by path)
149
168
  treeEntries.sort((a, b) => {
150
169
  const filenameA = a.split('\t')[1];
@@ -329,47 +329,68 @@ function propSetOutcome(propMap, op, eventId) {
329
329
  }
330
330
 
331
331
  /**
332
- * Joins a patch into state, applying all operations in order.
333
- *
334
- * This is the primary function for incorporating a single patch into WARP state.
335
- * It iterates through all operations in the patch, creates EventIds for causality
336
- * tracking, and applies each operation using `applyOpV2`.
337
- *
338
- * **Receipt Collection Mode**:
339
- * When `collectReceipts` is true, this function also computes the outcome of each
340
- * operation (applied, redundant, or superseded) and returns a TickReceipt for
341
- * provenance tracking. This has a small performance cost, so it's disabled by default.
342
- *
343
- * **Warning**: This function mutates `state` in place. For immutable operations,
344
- * clone the state first using `cloneStateV5()`.
332
+ * Folds a patch's own dot into the observed frontier.
333
+ * @param {Map<string, number>} frontier
334
+ * @param {string} writer
335
+ * @param {number} lamport
336
+ */
337
+ function foldPatchDot(frontier, writer, lamport) {
338
+ const current = frontier.get(writer) || 0;
339
+ if (lamport > current) {
340
+ frontier.set(writer, lamport);
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Merges a patch's context into state and folds the patch dot.
346
+ * @param {WarpStateV5} state
347
+ * @param {Object} patch
348
+ * @param {string} patch.writer
349
+ * @param {number} patch.lamport
350
+ * @param {Map<string, number>|{[x: string]: number}} patch.context
351
+ */
352
+ function updateFrontierFromPatch(state, patch) {
353
+ const contextVV = patch.context instanceof Map
354
+ ? patch.context
355
+ : vvDeserialize(patch.context || {});
356
+ state.observedFrontier = vvMerge(state.observedFrontier, contextVV);
357
+ foldPatchDot(state.observedFrontier, patch.writer, patch.lamport);
358
+ }
359
+
360
+ /**
361
+ * Applies a patch to state without receipt collection (zero overhead).
345
362
  *
346
- * @param {WarpStateV5} state - The state to mutate. Modified in place.
363
+ * @param {WarpStateV5} state - The state to mutate in place
347
364
  * @param {Object} patch - The patch to apply
348
- * @param {string} patch.writer - Writer ID who created this patch
349
- * @param {number} patch.lamport - Lamport timestamp of this patch
350
- * @param {Array<{type: string, node?: string, dot?: import('../crdt/Dot.js').Dot, observedDots?: string[], from?: string, to?: string, label?: string, key?: string, value?: unknown, oid?: string}>} patch.ops - Array of operations to apply
351
- * @param {Map<string, number>|{[x: string]: number}} patch.context - Version vector context (Map or serialized form)
352
- * @param {string} patchSha - The Git SHA of the patch commit (used for EventId creation)
353
- * @param {boolean} [collectReceipts=false] - When true, computes and returns receipt data
354
- * @returns {WarpStateV5|{state: WarpStateV5, receipt: import('../types/TickReceipt.js').TickReceipt}}
355
- * Returns mutated state directly when collectReceipts is false;
356
- * returns {state, receipt} object when collectReceipts is true
365
+ * @param {string} patch.writer
366
+ * @param {number} patch.lamport
367
+ * @param {Array<{type: string, node?: string, dot?: import('../crdt/Dot.js').Dot, observedDots?: string[], from?: string, to?: string, label?: string, key?: string, value?: unknown, oid?: string}>} patch.ops
368
+ * @param {Map<string, number>|{[x: string]: number}} patch.context
369
+ * @param {string} patchSha - Git SHA of the patch commit
370
+ * @returns {WarpStateV5} The mutated state
357
371
  */
358
- export function join(state, patch, patchSha, collectReceipts) {
359
- // ZERO-COST: when collectReceipts is falsy, skip all receipt logic
360
- if (!collectReceipts) {
361
- for (let i = 0; i < patch.ops.length; i++) {
362
- const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
363
- applyOpV2(state, patch.ops[i], eventId);
364
- }
365
- const contextVV = patch.context instanceof Map
366
- ? patch.context
367
- : vvDeserialize(patch.context);
368
- state.observedFrontier = vvMerge(state.observedFrontier, contextVV);
369
- return state;
372
+ export function applyFast(state, patch, patchSha) {
373
+ for (let i = 0; i < patch.ops.length; i++) {
374
+ const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
375
+ applyOpV2(state, patch.ops[i], eventId);
370
376
  }
377
+ updateFrontierFromPatch(state, patch);
378
+ return state;
379
+ }
371
380
 
372
- // Receipt-enabled path
381
+ /**
382
+ * Applies a patch to state with receipt collection for provenance tracking.
383
+ *
384
+ * @param {WarpStateV5} state - The state to mutate in place
385
+ * @param {Object} patch - The patch to apply
386
+ * @param {string} patch.writer
387
+ * @param {number} patch.lamport
388
+ * @param {Array<{type: string, node?: string, dot?: import('../crdt/Dot.js').Dot, observedDots?: string[], from?: string, to?: string, label?: string, key?: string, value?: unknown, oid?: string}>} patch.ops
389
+ * @param {Map<string, number>|{[x: string]: number}} patch.context
390
+ * @param {string} patchSha - Git SHA of the patch commit
391
+ * @returns {{state: WarpStateV5, receipt: import('../types/TickReceipt.js').TickReceipt}}
392
+ */
393
+ export function applyWithReceipt(state, patch, patchSha) {
373
394
  /** @type {import('../types/TickReceipt.js').OpOutcome[]} */
374
395
  const opResults = [];
375
396
  for (let i = 0; i < patch.ops.length; i++) {
@@ -419,10 +440,7 @@ export function join(state, patch, patchSha, collectReceipts) {
419
440
  opResults.push(entry);
420
441
  }
421
442
 
422
- const contextVV = patch.context instanceof Map
423
- ? patch.context
424
- : vvDeserialize(patch.context);
425
- state.observedFrontier = vvMerge(state.observedFrontier, contextVV);
443
+ updateFrontierFromPatch(state, patch);
426
444
 
427
445
  const receipt = createTickReceipt({
428
446
  patchSha,
@@ -434,6 +452,39 @@ export function join(state, patch, patchSha, collectReceipts) {
434
452
  return { state, receipt };
435
453
  }
436
454
 
455
+ /**
456
+ * Joins a patch into state, applying all operations in order.
457
+ *
458
+ * This is the primary function for incorporating a single patch into WARP state.
459
+ * It iterates through all operations in the patch, creates EventIds for causality
460
+ * tracking, and applies each operation using `applyOpV2`.
461
+ *
462
+ * **Receipt Collection Mode**:
463
+ * When `collectReceipts` is true, this function also computes the outcome of each
464
+ * operation (applied, redundant, or superseded) and returns a TickReceipt for
465
+ * provenance tracking. This has a small performance cost, so it's disabled by default.
466
+ *
467
+ * **Warning**: This function mutates `state` in place. For immutable operations,
468
+ * clone the state first using `cloneStateV5()`.
469
+ *
470
+ * @param {WarpStateV5} state - The state to mutate. Modified in place.
471
+ * @param {Object} patch - The patch to apply
472
+ * @param {string} patch.writer - Writer ID who created this patch
473
+ * @param {number} patch.lamport - Lamport timestamp of this patch
474
+ * @param {Array<{type: string, node?: string, dot?: import('../crdt/Dot.js').Dot, observedDots?: string[], from?: string, to?: string, label?: string, key?: string, value?: unknown, oid?: string}>} patch.ops - Array of operations to apply
475
+ * @param {Map<string, number>|{[x: string]: number}} patch.context - Version vector context (Map or serialized form)
476
+ * @param {string} patchSha - The Git SHA of the patch commit (used for EventId creation)
477
+ * @param {boolean} [collectReceipts=false] - When true, computes and returns receipt data
478
+ * @returns {WarpStateV5|{state: WarpStateV5, receipt: import('../types/TickReceipt.js').TickReceipt}}
479
+ * Returns mutated state directly when collectReceipts is false;
480
+ * returns {state, receipt} object when collectReceipts is true
481
+ */
482
+ export function join(state, patch, patchSha, collectReceipts) {
483
+ return collectReceipts
484
+ ? applyWithReceipt(state, patch, patchSha)
485
+ : applyFast(state, patch, patchSha);
486
+ }
487
+
437
488
  /**
438
489
  * Joins two V5 states together using CRDT merge semantics.
439
490
  *
@@ -545,14 +596,14 @@ export function reduceV5(patches, initialState, options) {
545
596
  if (options && options.receipts) {
546
597
  const receipts = [];
547
598
  for (const { patch, sha } of patches) {
548
- const result = /** @type {{state: WarpStateV5, receipt: import('../types/TickReceipt.js').TickReceipt}} */ (join(state, patch, sha, true));
599
+ const result = applyWithReceipt(state, patch, sha);
549
600
  receipts.push(result.receipt);
550
601
  }
551
602
  return { state, receipts };
552
603
  }
553
604
 
554
605
  for (const { patch, sha } of patches) {
555
- join(state, patch, sha);
606
+ applyFast(state, patch, sha);
556
607
  }
557
608
  return state;
558
609
  }
@@ -18,6 +18,13 @@ export const FIELD_SEPARATOR = '\0';
18
18
  */
19
19
  export const EDGE_PROP_PREFIX = '\x01';
20
20
 
21
+ /**
22
+ * Well-known property key for content attachment.
23
+ * Stores a content-addressed blob OID as the property value.
24
+ * @const {string}
25
+ */
26
+ export const CONTENT_PROPERTY_KEY = '_content';
27
+
21
28
  /**
22
29
  * Encodes an edge key to a string for Map storage.
23
30
  *
@@ -23,7 +23,7 @@ import {
23
23
  createPropSetV2,
24
24
  createPatchV2,
25
25
  } from '../types/WarpTypesV2.js';
26
- import { encodeEdgeKey, EDGE_PROP_PREFIX } from './KeyCodec.js';
26
+ import { encodeEdgeKey, EDGE_PROP_PREFIX, CONTENT_PROPERTY_KEY } from './KeyCodec.js';
27
27
  import { encodePatchMessage, decodePatchMessage, detectMessageKind } from './WarpMessageCodec.js';
28
28
  import { buildWriterRef } from '../utils/RefLayout.js';
29
29
  import WriterError from '../errors/WriterError.js';
@@ -124,6 +124,13 @@ export class PatchBuilderV2 {
124
124
  /** @type {{ warn: Function }} */
125
125
  this._logger = logger || nullLogger;
126
126
 
127
+ /**
128
+ * Content blob OIDs written during this patch via attachContent/attachEdgeContent.
129
+ * These are embedded in the commit tree for GC protection.
130
+ * @type {string[]}
131
+ */
132
+ this._contentBlobs = [];
133
+
127
134
  /**
128
135
  * Nodes/edges read by this patch (for provenance tracking).
129
136
  *
@@ -430,6 +437,45 @@ export class PatchBuilderV2 {
430
437
  return this;
431
438
  }
432
439
 
440
+ /**
441
+ * Attaches content to a node by writing the blob to the Git object store
442
+ * and storing the blob OID as the `_content` property.
443
+ *
444
+ * The blob OID is also tracked for embedding in the commit tree, which
445
+ * ensures content blobs survive `git gc` (GC protection via reachability).
446
+ *
447
+ * Note: The node must exist in the materialized state (or be added in
448
+ * this patch) for `getContent()` to find it later. `attachContent()`
449
+ * only sets the `_content` property — it does not create the node.
450
+ *
451
+ * @param {string} nodeId - The node ID to attach content to
452
+ * @param {Buffer|string} content - The content to attach
453
+ * @returns {Promise<PatchBuilderV2>} This builder instance for method chaining
454
+ */
455
+ async attachContent(nodeId, content) {
456
+ const oid = await this._persistence.writeBlob(content);
457
+ this.setProperty(nodeId, CONTENT_PROPERTY_KEY, oid);
458
+ this._contentBlobs.push(oid);
459
+ return this;
460
+ }
461
+
462
+ /**
463
+ * Attaches content to an edge by writing the blob to the Git object store
464
+ * and storing the blob OID as the `_content` edge property.
465
+ *
466
+ * @param {string} from - Source node ID
467
+ * @param {string} to - Target node ID
468
+ * @param {string} label - Edge label
469
+ * @param {Buffer|string} content - The content to attach
470
+ * @returns {Promise<PatchBuilderV2>} This builder instance for method chaining
471
+ */
472
+ async attachEdgeContent(from, to, label, content) {
473
+ const oid = await this._persistence.writeBlob(content);
474
+ this.setEdgeProperty(from, to, label, CONTENT_PROPERTY_KEY, oid);
475
+ this._contentBlobs.push(oid);
476
+ return this;
477
+ }
478
+
433
479
  /**
434
480
  * Builds the PatchV2 object without committing.
435
481
  *
@@ -577,10 +623,14 @@ export class PatchBuilderV2 {
577
623
  const patchCbor = this._codec.encode(patch);
578
624
  const patchBlobOid = await this._persistence.writeBlob(/** @type {Buffer} */ (patchCbor));
579
625
 
580
- // 6. Create tree with the blob
626
+ // 6. Create tree with the patch blob + any content blobs (deduplicated)
581
627
  // Format for mktree: "mode type oid\tpath"
582
- const treeEntry = `100644 blob ${patchBlobOid}\tpatch.cbor`;
583
- const treeOid = await this._persistence.writeTree([treeEntry]);
628
+ const treeEntries = [`100644 blob ${patchBlobOid}\tpatch.cbor`];
629
+ const uniqueBlobs = [...new Set(this._contentBlobs)];
630
+ for (const blobOid of uniqueBlobs) {
631
+ treeEntries.push(`100644 blob ${blobOid}\t_content_${blobOid}`);
632
+ }
633
+ const treeOid = await this._persistence.writeTree(treeEntries);
584
634
 
585
635
  // 7. Create commit with proper trailers linking to the parent
586
636
  const commitMessage = encodePatchMessage({