@git-stunts/git-warp 11.3.3 → 11.5.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 CHANGED
@@ -328,6 +328,29 @@ const sha = await (await graph.createPatch())
328
328
 
329
329
  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
330
 
331
+ ### Content Attachment
332
+
333
+ 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.
334
+
335
+ ```javascript
336
+ const patch = await graph.createPatch();
337
+ patch.addNode('adr:0007'); // sync — queues a NodeAdd op
338
+ await patch.attachContent('adr:0007', '# ADR 0007\n\nDecision text...'); // async — writes blob
339
+ await patch.commit();
340
+
341
+ // Read content back
342
+ const buffer = await graph.getContent('adr:0007'); // Buffer | null
343
+ const oid = await graph.getContentOid('adr:0007'); // hex SHA or null
344
+
345
+ // Edge content works the same way (assumes nodes and edge already exist)
346
+ const patch2 = await graph.createPatch();
347
+ await patch2.attachEdgeContent('a', 'b', 'rel', 'edge payload');
348
+ await patch2.commit();
349
+ const edgeBuf = await graph.getEdgeContent('a', 'b', 'rel');
350
+ ```
351
+
352
+ Content blobs survive `git gc` — their OIDs are embedded in the patch commit tree and checkpoint tree, keeping them reachable.
353
+
331
354
  ### Writer API
332
355
 
333
356
  For repeated writes, the Writer API is more convenient:
@@ -508,7 +531,7 @@ npm run test:matrix # All runtimes in parallel
508
531
  ## When NOT to Use It
509
532
 
510
533
  - **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.
534
+ - **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
535
  - **Sub-millisecond read latency.** Materialization has overhead. Use an in-memory database for real-time gaming physics or HFT.
513
536
  - **Simple key-value storage.** If you don't have relationships or need traversals, a graph database is overkill.
514
537
  - **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.0",
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",
@@ -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];
@@ -328,6 +328,19 @@ function propSetOutcome(propMap, op, eventId) {
328
328
  return { target, result: 'redundant' };
329
329
  }
330
330
 
331
+ /**
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
+
331
344
  /**
332
345
  * Joins a patch into state, applying all operations in order.
333
346
  *
@@ -366,6 +379,7 @@ export function join(state, patch, patchSha, collectReceipts) {
366
379
  ? patch.context
367
380
  : vvDeserialize(patch.context);
368
381
  state.observedFrontier = vvMerge(state.observedFrontier, contextVV);
382
+ foldPatchDot(state.observedFrontier, patch.writer, patch.lamport);
369
383
  return state;
370
384
  }
371
385
 
@@ -423,6 +437,7 @@ export function join(state, patch, patchSha, collectReceipts) {
423
437
  ? patch.context
424
438
  : vvDeserialize(patch.context);
425
439
  state.observedFrontier = vvMerge(state.observedFrontier, contextVV);
440
+ foldPatchDot(state.observedFrontier, patch.writer, patch.lamport);
426
441
 
427
442
  const receipt = createTickReceipt({
428
443
  patchSha,
@@ -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({
@@ -148,6 +148,37 @@ export class PatchSession {
148
148
  return this;
149
149
  }
150
150
 
151
+ /**
152
+ * Attaches content to a node.
153
+ *
154
+ * @param {string} nodeId - The node ID to attach content to
155
+ * @param {Buffer|string} content - The content to attach
156
+ * @returns {Promise<this>} This session for chaining
157
+ * @throws {Error} If this session has already been committed
158
+ */
159
+ async attachContent(nodeId, content) {
160
+ this._ensureNotCommitted();
161
+ await this._builder.attachContent(nodeId, content);
162
+ return this;
163
+ }
164
+
165
+ /**
166
+ * Attaches content to an edge.
167
+ *
168
+ * @param {string} from - Source node ID
169
+ * @param {string} to - Target node ID
170
+ * @param {string} label - Edge label/type
171
+ * @param {Buffer|string} content - The content to attach
172
+ * @returns {Promise<this>} This session for chaining
173
+ * @throws {Error} If this session has already been committed
174
+ */
175
+ // eslint-disable-next-line max-params -- direct delegate matching PatchBuilderV2 signature
176
+ async attachEdgeContent(from, to, label, content) {
177
+ this._ensureNotCommitted();
178
+ await this._builder.attachEdgeContent(from, to, label, content);
179
+ return this;
180
+ }
181
+
151
182
  /**
152
183
  * Builds the PatchV2 object without committing.
153
184
  *
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { orsetContains, orsetElements } from '../crdt/ORSet.js';
11
- import { decodePropKey, isEdgePropKey, decodeEdgePropKey, encodeEdgeKey, decodeEdgeKey } from '../services/KeyCodec.js';
11
+ import { decodePropKey, isEdgePropKey, decodeEdgePropKey, encodeEdgeKey, decodeEdgeKey, CONTENT_PROPERTY_KEY } from '../services/KeyCodec.js';
12
12
  import { compareEventIds } from '../utils/EventId.js';
13
13
  import { cloneStateV5 } from '../services/JoinReducer.js';
14
14
  import QueryBuilder from '../services/QueryBuilder.js';
@@ -278,3 +278,85 @@ export async function translationCost(configA, configB) {
278
278
  const s = /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (this._cachedState);
279
279
  return computeTranslationCost(configA, configB, s);
280
280
  }
281
+
282
+ /**
283
+ * Gets the content blob OID for a node, or null if none is attached.
284
+ *
285
+ * @this {import('../WarpGraph.js').default}
286
+ * @param {string} nodeId - The node ID to check
287
+ * @returns {Promise<string|null>} Hex blob OID or null
288
+ * @throws {import('../errors/QueryError.js').default} If no cached state exists (code: `E_NO_STATE`)
289
+ */
290
+ export async function getContentOid(nodeId) {
291
+ const props = await getNodeProps.call(this, nodeId);
292
+ if (!props) {
293
+ return null;
294
+ }
295
+ // getNodeProps returns a Map — use .get() for property access
296
+ const oid = props.get(CONTENT_PROPERTY_KEY);
297
+ return (typeof oid === 'string') ? oid : null;
298
+ }
299
+
300
+ /**
301
+ * Gets the content blob for a node, or null if none is attached.
302
+ *
303
+ * Returns the raw Buffer from `readBlob()`. Consumers wanting text
304
+ * should call `.toString('utf8')` on the result.
305
+ *
306
+ * @this {import('../WarpGraph.js').default}
307
+ * @param {string} nodeId - The node ID to get content for
308
+ * @returns {Promise<Buffer|null>} Content buffer or null
309
+ * @throws {Error} If the referenced blob OID is not in the object store
310
+ * (e.g., garbage-collected despite anchoring). Callers should handle this
311
+ * if operating on repos with aggressive GC or partial clones.
312
+ */
313
+ export async function getContent(nodeId) {
314
+ const oid = await getContentOid.call(this, nodeId);
315
+ if (!oid) {
316
+ return null;
317
+ }
318
+ return await this._persistence.readBlob(oid);
319
+ }
320
+
321
+ /**
322
+ * Gets the content blob OID for an edge, or null if none is attached.
323
+ *
324
+ * @this {import('../WarpGraph.js').default}
325
+ * @param {string} from - Source node ID
326
+ * @param {string} to - Target node ID
327
+ * @param {string} label - Edge label
328
+ * @returns {Promise<string|null>} Hex blob OID or null
329
+ * @throws {import('../errors/QueryError.js').default} If no cached state exists (code: `E_NO_STATE`)
330
+ */
331
+ export async function getEdgeContentOid(from, to, label) {
332
+ const props = await getEdgeProps.call(this, from, to, label);
333
+ if (!props) {
334
+ return null;
335
+ }
336
+ // getEdgeProps returns a plain object — use bracket access
337
+ const oid = props[CONTENT_PROPERTY_KEY];
338
+ return (typeof oid === 'string') ? oid : null;
339
+ }
340
+
341
+ /**
342
+ * Gets the content blob for an edge, or null if none is attached.
343
+ *
344
+ * Returns the raw Buffer from `readBlob()`. Consumers wanting text
345
+ * should call `.toString('utf8')` on the result.
346
+ *
347
+ * @this {import('../WarpGraph.js').default}
348
+ * @param {string} from - Source node ID
349
+ * @param {string} to - Target node ID
350
+ * @param {string} label - Edge label
351
+ * @returns {Promise<Buffer|null>} Content buffer or null
352
+ * @throws {Error} If the referenced blob OID is not in the object store
353
+ * (e.g., garbage-collected despite anchoring). Callers should handle this
354
+ * if operating on repos with aggressive GC or partial clones.
355
+ */
356
+ export async function getEdgeContent(from, to, label) {
357
+ const oid = await getEdgeContentOid.call(this, from, to, label);
358
+ if (!oid) {
359
+ return null;
360
+ }
361
+ return await this._persistence.readBlob(oid);
362
+ }
@@ -43,6 +43,7 @@
43
43
  * @see {@link https://git-scm.com/book/en/v2/Git-Internals-Plumbing-and-Porcelain} for Git plumbing concepts
44
44
  */
45
45
 
46
+ import { Buffer } from 'node:buffer';
46
47
  import { retry } from '@git-stunts/alfred';
47
48
  import GraphPersistencePort from '../../ports/GraphPersistencePort.js';
48
49
  import { validateOid, validateRef, validateLimit, validateConfigKey } from './adapterValidation.js';
@@ -510,7 +511,9 @@ export default class GitGraphAdapter extends GraphPersistencePort {
510
511
  const stream = await this.plumbing.executeStream({
511
512
  args: ['cat-file', 'blob', oid]
512
513
  });
513
- return await stream.collect({ asString: false });
514
+ const raw = await stream.collect({ asString: false });
515
+ // Ensure a real Node Buffer (plumbing may return Uint8Array)
516
+ return Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
514
517
  }
515
518
 
516
519
  /**