From dfcb553978a06f11623f0bb588b2acf2fcdf613c Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 20 Feb 2026 17:13:55 -0800 Subject: [PATCH 01/28] docs: add markdownlint CI and code sample linter to backlog B-DOC-1: markdownlint in CI for MD040 and similar issues. B-DOC-2: syntax-check JS/TS code blocks in markdown specs. --- BACKLOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/BACKLOG.md b/BACKLOG.md index aaa4093..b935aed 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -111,6 +111,22 @@ Full spec in `docs/specs/CONTENT_ATTACHMENT.md`. Proposal to attach content-addr --- +## Documentation Quality + +### B-DOC-1: Add markdownlint to CI + +Add a markdownlint check to the CI pipeline to catch MD040 (missing code fence language tags) and similar doc issues automatically. Currently these are only caught by CodeRabbit review, which is slow and non-blocking. + +**File:** `.github/workflows/ci.yml` + +### B-DOC-2: Add a code sample linter for markdown files + +Syntax-check JS/TS code blocks embedded in markdown files (specs, guides, etc.) to catch issues like duplicate `const` declarations, TDZ errors, and other syntax errors before they reach review. Could use `eslint --stdin` on extracted code blocks or a dedicated tool like `remark-lint-fenced-code-flag`. + +**Files:** new script or CI step, `docs/**/*.md` + +--- + ## Developer Experience ### B-DX-1: Pre-push hook runs full test suite From df5c51933af07b2f3632a2705660baa694df51e1 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 20 Feb 2026 21:13:36 -0800 Subject: [PATCH 02/28] feat(content): add CONTENT_PROPERTY_KEY and PatchBuilderV2.attachContent() Implements the write-side of content attachment (Paper I Atom(p)): - CONTENT_PROPERTY_KEY constant in KeyCodec.js - attachContent(nodeId, content) writes blob + sets _content property - attachEdgeContent(from, to, label, content) for edge content - commit() now embeds content blob OIDs in the patch commit tree under _blob/0, _blob/1, etc. for GC protection - _contentBlobs array tracks blob OIDs during patch building --- src/domain/services/KeyCodec.js | 7 + src/domain/services/PatchBuilderV2.js | 54 +++- .../services/PatchBuilderV2.content.test.js | 290 ++++++++++++++++++ 3 files changed, 347 insertions(+), 4 deletions(-) create mode 100644 test/unit/domain/services/PatchBuilderV2.content.test.js diff --git a/src/domain/services/KeyCodec.js b/src/domain/services/KeyCodec.js index 42e04c8..f177c40 100644 --- a/src/domain/services/KeyCodec.js +++ b/src/domain/services/KeyCodec.js @@ -18,6 +18,13 @@ export const FIELD_SEPARATOR = '\0'; */ export const EDGE_PROP_PREFIX = '\x01'; +/** + * Well-known property key for content attachment. + * Stores a content-addressed blob OID as the property value. + * @const {string} + */ +export const CONTENT_PROPERTY_KEY = '_content'; + /** * Encodes an edge key to a string for Map storage. * diff --git a/src/domain/services/PatchBuilderV2.js b/src/domain/services/PatchBuilderV2.js index e1a08f8..0694d0b 100644 --- a/src/domain/services/PatchBuilderV2.js +++ b/src/domain/services/PatchBuilderV2.js @@ -23,7 +23,7 @@ import { createPropSetV2, createPatchV2, } from '../types/WarpTypesV2.js'; -import { encodeEdgeKey, EDGE_PROP_PREFIX } from './KeyCodec.js'; +import { encodeEdgeKey, EDGE_PROP_PREFIX, CONTENT_PROPERTY_KEY } from './KeyCodec.js'; import { encodePatchMessage, decodePatchMessage, detectMessageKind } from './WarpMessageCodec.js'; import { buildWriterRef } from '../utils/RefLayout.js'; import WriterError from '../errors/WriterError.js'; @@ -124,6 +124,13 @@ export class PatchBuilderV2 { /** @type {{ warn: Function }} */ this._logger = logger || nullLogger; + /** + * Content blob OIDs written during this patch via attachContent/attachEdgeContent. + * These are embedded in the commit tree for GC protection. + * @type {string[]} + */ + this._contentBlobs = []; + /** * Nodes/edges read by this patch (for provenance tracking). * @@ -430,6 +437,42 @@ export class PatchBuilderV2 { return this; } + /** + * Attaches content to a node by writing the blob to the Git object store + * and storing the blob OID as the `_content` property. + * + * The blob OID is also tracked for embedding in the commit tree, which + * ensures content blobs survive `git gc` (GC protection via reachability). + * + * @param {string} nodeId - The node ID to attach content to + * @param {Buffer|string} content - The content to attach + * @returns {Promise} This builder instance for method chaining + */ + async attachContent(nodeId, content) { + const oid = await this._persistence.writeBlob(content); + this._contentBlobs.push(oid); + this.setProperty(nodeId, CONTENT_PROPERTY_KEY, oid); + return this; + } + + /** + * Attaches content to an edge by writing the blob to the Git object store + * and storing the blob OID as the `_content` edge property. + * + * @param {string} from - Source node ID + * @param {string} to - Target node ID + * @param {string} label - Edge label + * @param {Buffer|string} content - The content to attach + * @returns {Promise} This builder instance for method chaining + */ + // eslint-disable-next-line max-params -- direct delegate matching setEdgeProperty signature + async attachEdgeContent(from, to, label, content) { + const oid = await this._persistence.writeBlob(content); + this._contentBlobs.push(oid); + this.setEdgeProperty(from, to, label, CONTENT_PROPERTY_KEY, oid); + return this; + } + /** * Builds the PatchV2 object without committing. * @@ -577,10 +620,13 @@ export class PatchBuilderV2 { const patchCbor = this._codec.encode(patch); const patchBlobOid = await this._persistence.writeBlob(/** @type {Buffer} */ (patchCbor)); - // 6. Create tree with the blob + // 6. Create tree with the patch blob + any content blobs // Format for mktree: "mode type oid\tpath" - const treeEntry = `100644 blob ${patchBlobOid}\tpatch.cbor`; - const treeOid = await this._persistence.writeTree([treeEntry]); + const treeEntries = [`100644 blob ${patchBlobOid}\tpatch.cbor`]; + for (let i = 0; i < this._contentBlobs.length; i++) { + treeEntries.push(`100644 blob ${this._contentBlobs[i]}\t_blob/${i}`); + } + const treeOid = await this._persistence.writeTree(treeEntries); // 7. Create commit with proper trailers linking to the parent const commitMessage = encodePatchMessage({ diff --git a/test/unit/domain/services/PatchBuilderV2.content.test.js b/test/unit/domain/services/PatchBuilderV2.content.test.js new file mode 100644 index 0000000..a745706 --- /dev/null +++ b/test/unit/domain/services/PatchBuilderV2.content.test.js @@ -0,0 +1,290 @@ +import { describe, it, expect, vi } from 'vitest'; +import { PatchBuilderV2 } from '../../../../src/domain/services/PatchBuilderV2.js'; +import { createVersionVector } from '../../../../src/domain/crdt/VersionVector.js'; +import { createORSet, orsetAdd } from '../../../../src/domain/crdt/ORSet.js'; +import { createDot } from '../../../../src/domain/crdt/Dot.js'; +import { encodeEdgeKey } from '../../../../src/domain/services/KeyCodec.js'; + +/** + * Creates a mock persistence adapter for testing. + * @param {Object} [overrides] + * @returns {any} + */ +function createMockPersistence(overrides = {}) { + return { + readRef: vi.fn().mockResolvedValue(null), + showNode: vi.fn(), + writeBlob: vi.fn().mockResolvedValue('d'.repeat(40)), + writeTree: vi.fn().mockResolvedValue('e'.repeat(40)), + commitNodeWithTree: vi.fn().mockResolvedValue('f'.repeat(40)), + updateRef: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +/** + * Creates a mock V5 state for testing. + * @returns {any} + */ +function createMockState() { + return { + nodeAlive: createORSet(), + edgeAlive: createORSet(), + prop: new Map(), + observedFrontier: createVersionVector(), + }; +} + +describe('PatchBuilderV2 content attachment', () => { + describe('attachContent()', () => { + it('writes blob and sets _content property', async () => { + const persistence = createMockPersistence({ + writeBlob: vi.fn().mockResolvedValue('abc123'), + }); + const builder = new PatchBuilderV2(/** @type {any} */ ({ + persistence, + writerId: 'w1', + lamport: 1, + versionVector: createVersionVector(), + getCurrentState: () => null, + })); + + await builder.attachContent('node:1', 'hello world'); + + expect(persistence.writeBlob).toHaveBeenCalledWith('hello world'); + const patch = builder.build(); + expect(patch.ops).toHaveLength(1); + expect(patch.ops[0]).toMatchObject({ + type: 'PropSet', + node: 'node:1', + key: '_content', + value: 'abc123', + }); + }); + + it('tracks blob OID in _contentBlobs', async () => { + const persistence = createMockPersistence({ + writeBlob: vi.fn().mockResolvedValue('abc123'), + }); + const builder = new PatchBuilderV2(/** @type {any} */ ({ + persistence, + writerId: 'w1', + lamport: 1, + versionVector: createVersionVector(), + getCurrentState: () => null, + })); + + await builder.attachContent('node:1', 'content'); + + expect(builder._contentBlobs).toEqual(['abc123']); + }); + + it('returns the builder for chaining', async () => { + const persistence = createMockPersistence(); + const builder = new PatchBuilderV2(/** @type {any} */ ({ + persistence, + writerId: 'w1', + lamport: 1, + versionVector: createVersionVector(), + getCurrentState: () => null, + })); + + const result = await builder.attachContent('node:1', 'content'); + expect(result).toBe(builder); + }); + + it('propagates writeBlob errors', async () => { + const persistence = createMockPersistence({ + writeBlob: vi.fn().mockRejectedValue(new Error('disk full')), + }); + const builder = new PatchBuilderV2(/** @type {any} */ ({ + persistence, + writerId: 'w1', + lamport: 1, + versionVector: createVersionVector(), + getCurrentState: () => null, + })); + + await expect(builder.attachContent('node:1', 'x')).rejects.toThrow('disk full'); + }); + }); + + describe('attachEdgeContent()', () => { + it('writes blob and sets _content edge property', async () => { + const state = createMockState(); + const edgeKey = encodeEdgeKey('a', 'b', 'rel'); + orsetAdd(state.edgeAlive, edgeKey, createDot('w1', 1)); + + const persistence = createMockPersistence({ + writeBlob: vi.fn().mockResolvedValue('def456'), + }); + const builder = new PatchBuilderV2(/** @type {any} */ ({ + persistence, + writerId: 'w1', + lamport: 1, + versionVector: createVersionVector(), + getCurrentState: () => state, + })); + + await builder.attachEdgeContent('a', 'b', 'rel', Buffer.from('binary')); + + expect(persistence.writeBlob).toHaveBeenCalledWith(Buffer.from('binary')); + const patch = builder.build(); + expect(patch.ops).toHaveLength(1); + expect(patch.ops[0]).toMatchObject({ + type: 'PropSet', + key: '_content', + value: 'def456', + }); + // Schema should be 3 (edge properties present) + expect(patch.schema).toBe(3); + }); + + it('tracks blob OID in _contentBlobs', async () => { + const state = createMockState(); + orsetAdd(state.edgeAlive, encodeEdgeKey('a', 'b', 'rel'), createDot('w1', 1)); + + const persistence = createMockPersistence({ + writeBlob: vi.fn().mockResolvedValue('def456'), + }); + const builder = new PatchBuilderV2(/** @type {any} */ ({ + persistence, + writerId: 'w1', + lamport: 1, + versionVector: createVersionVector(), + getCurrentState: () => state, + })); + + await builder.attachEdgeContent('a', 'b', 'rel', 'content'); + + expect(builder._contentBlobs).toEqual(['def456']); + }); + + it('returns the builder for chaining', async () => { + const state = createMockState(); + orsetAdd(state.edgeAlive, encodeEdgeKey('a', 'b', 'rel'), createDot('w1', 1)); + + const persistence = createMockPersistence(); + const builder = new PatchBuilderV2(/** @type {any} */ ({ + persistence, + writerId: 'w1', + lamport: 1, + versionVector: createVersionVector(), + getCurrentState: () => state, + })); + + const result = await builder.attachEdgeContent('a', 'b', 'rel', 'x'); + expect(result).toBe(builder); + }); + }); + + describe('multiple attachments in one patch', () => { + it('tracks multiple blob OIDs', async () => { + let callCount = 0; + const persistence = createMockPersistence({ + writeBlob: vi.fn().mockImplementation(() => { + callCount++; + return Promise.resolve(`blob${callCount}`); + }), + }); + const builder = new PatchBuilderV2(/** @type {any} */ ({ + persistence, + writerId: 'w1', + lamport: 1, + versionVector: createVersionVector(), + getCurrentState: () => null, + })); + + await builder.attachContent('node:1', 'first'); + await builder.attachContent('node:2', 'second'); + + expect(builder._contentBlobs).toEqual(['blob1', 'blob2']); + expect(persistence.writeBlob).toHaveBeenCalledTimes(2); + }); + }); + + describe('commit() with content blobs', () => { + it('includes _blob/* entries in tree when content blobs exist', async () => { + const contentOid = 'a'.repeat(40); + const patchBlobOid = 'b'.repeat(40); + const persistence = createMockPersistence({ + writeBlob: vi.fn() + .mockResolvedValueOnce(contentOid) // attachContent writeBlob + .mockResolvedValueOnce(patchBlobOid), // commit() CBOR blob + writeTree: vi.fn().mockResolvedValue('c'.repeat(40)), + }); + const builder = new PatchBuilderV2(/** @type {any} */ ({ + persistence, + graphName: 'g', + writerId: 'w1', + lamport: 1, + versionVector: createVersionVector(), + getCurrentState: () => null, + expectedParentSha: null, + })); + + builder.addNode('n1'); + await builder.attachContent('n1', 'hello'); + await builder.commit(); + + // writeTree should be called with both patch.cbor and _blob/0 + const treeEntries = persistence.writeTree.mock.calls[0][0]; + expect(treeEntries).toHaveLength(2); + expect(treeEntries[0]).toBe(`100644 blob ${patchBlobOid}\tpatch.cbor`); + expect(treeEntries[1]).toBe(`100644 blob ${contentOid}\t_blob/0`); + }); + + it('creates single-entry tree when no content blobs', async () => { + const persistence = createMockPersistence(); + const builder = new PatchBuilderV2(/** @type {any} */ ({ + persistence, + graphName: 'g', + writerId: 'w1', + lamport: 1, + versionVector: createVersionVector(), + getCurrentState: () => null, + expectedParentSha: null, + })); + + builder.addNode('n1'); + await builder.commit(); + + const treeEntries = persistence.writeTree.mock.calls[0][0]; + expect(treeEntries).toHaveLength(1); + expect(treeEntries[0]).toContain('patch.cbor'); + }); + + it('includes multiple _blob/* entries for multiple attachments', async () => { + let blobIdx = 0; + const contentA = '1'.repeat(40); + const contentB = '2'.repeat(40); + const patchBlob = '3'.repeat(40); + const blobOids = [contentA, contentB, patchBlob]; + const persistence = createMockPersistence({ + writeBlob: vi.fn().mockImplementation(() => + Promise.resolve(blobOids[blobIdx++])), + writeTree: vi.fn().mockResolvedValue('4'.repeat(40)), + }); + const builder = new PatchBuilderV2(/** @type {any} */ ({ + persistence, + graphName: 'g', + writerId: 'w1', + lamport: 1, + versionVector: createVersionVector(), + getCurrentState: () => null, + expectedParentSha: null, + })); + + builder.addNode('n1').addNode('n2'); + await builder.attachContent('n1', 'first'); + await builder.attachContent('n2', 'second'); + await builder.commit(); + + const treeEntries = persistence.writeTree.mock.calls[0][0]; + expect(treeEntries).toHaveLength(3); + expect(treeEntries[0]).toContain('patch.cbor'); + expect(treeEntries[1]).toBe(`100644 blob ${contentA}\t_blob/0`); + expect(treeEntries[2]).toBe(`100644 blob ${contentB}\t_blob/1`); + }); + }); +}); From 495f8393c3c7abfa8e5c934582ad02995876ca6d Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 20 Feb 2026 21:14:18 -0800 Subject: [PATCH 03/28] feat(content): add PatchSession.attachContent() pass-through Delegates attachContent() and attachEdgeContent() from PatchSession to PatchBuilderV2, with _ensureNotCommitted() guard. Both are async (returns Promise) because writeBlob() is I/O. Also removes unnecessary eslint-disable directive for max-params in PatchBuilderV2.attachEdgeContent(). --- src/domain/services/PatchBuilderV2.js | 1 - src/domain/warp/PatchSession.js | 31 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/domain/services/PatchBuilderV2.js b/src/domain/services/PatchBuilderV2.js index 0694d0b..8c092e1 100644 --- a/src/domain/services/PatchBuilderV2.js +++ b/src/domain/services/PatchBuilderV2.js @@ -465,7 +465,6 @@ export class PatchBuilderV2 { * @param {Buffer|string} content - The content to attach * @returns {Promise} This builder instance for method chaining */ - // eslint-disable-next-line max-params -- direct delegate matching setEdgeProperty signature async attachEdgeContent(from, to, label, content) { const oid = await this._persistence.writeBlob(content); this._contentBlobs.push(oid); diff --git a/src/domain/warp/PatchSession.js b/src/domain/warp/PatchSession.js index 09f793d..de04a69 100644 --- a/src/domain/warp/PatchSession.js +++ b/src/domain/warp/PatchSession.js @@ -148,6 +148,37 @@ export class PatchSession { return this; } + /** + * Attaches content to a node. + * + * @param {string} nodeId - The node ID to attach content to + * @param {Buffer|string} content - The content to attach + * @returns {Promise} This session for chaining + * @throws {Error} If this session has already been committed + */ + async attachContent(nodeId, content) { + this._ensureNotCommitted(); + await this._builder.attachContent(nodeId, content); + return this; + } + + /** + * Attaches content to an edge. + * + * @param {string} from - Source node ID + * @param {string} to - Target node ID + * @param {string} label - Edge label/type + * @param {Buffer|string} content - The content to attach + * @returns {Promise} This session for chaining + * @throws {Error} If this session has already been committed + */ + // eslint-disable-next-line max-params -- direct delegate matching PatchBuilderV2 signature + async attachEdgeContent(from, to, label, content) { + this._ensureNotCommitted(); + await this._builder.attachEdgeContent(from, to, label, content); + return this; + } + /** * Builds the PatchV2 object without committing. * From 83fed754fa9c4e209c370c88ac340fb45946e5ee Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 20 Feb 2026 21:15:45 -0800 Subject: [PATCH 04/28] feat(content): add getContent/getContentOid query methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds four auto-wired query methods to WarpGraph: - getContentOid(nodeId) → string|null - getContent(nodeId) → Buffer|null - getEdgeContentOid(from, to, label) → string|null - getEdgeContent(from, to, label) → Buffer|null These read the _content property and resolve it via readBlob(). Auto-wired via wireWarpMethods() — no manual registration needed. --- src/domain/warp/query.methods.js | 74 ++++++++- test/unit/domain/WarpGraph.content.test.js | 184 +++++++++++++++++++++ 2 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 test/unit/domain/WarpGraph.content.test.js diff --git a/src/domain/warp/query.methods.js b/src/domain/warp/query.methods.js index 93e92ab..3bc052f 100644 --- a/src/domain/warp/query.methods.js +++ b/src/domain/warp/query.methods.js @@ -8,7 +8,7 @@ */ import { orsetContains, orsetElements } from '../crdt/ORSet.js'; -import { decodePropKey, isEdgePropKey, decodeEdgePropKey, encodeEdgeKey, decodeEdgeKey } from '../services/KeyCodec.js'; +import { decodePropKey, isEdgePropKey, decodeEdgePropKey, encodeEdgeKey, decodeEdgeKey, CONTENT_PROPERTY_KEY } from '../services/KeyCodec.js'; import { compareEventIds } from '../utils/EventId.js'; import { cloneStateV5 } from '../services/JoinReducer.js'; import QueryBuilder from '../services/QueryBuilder.js'; @@ -278,3 +278,75 @@ export async function translationCost(configA, configB) { const s = /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (this._cachedState); return computeTranslationCost(configA, configB, s); } + +/** + * Gets the content blob OID for a node, or null if none is attached. + * + * @this {import('../WarpGraph.js').default} + * @param {string} nodeId - The node ID to check + * @returns {Promise} Hex blob OID or null + */ +export async function getContentOid(nodeId) { + const props = await getNodeProps.call(this, nodeId); + if (!props) { + return null; + } + const oid = props.get(CONTENT_PROPERTY_KEY); + return (typeof oid === 'string') ? oid : null; +} + +/** + * Gets the content blob for a node, or null if none is attached. + * + * Returns the raw Buffer from `readBlob()`. Consumers wanting text + * should call `.toString('utf8')` on the result. + * + * @this {import('../WarpGraph.js').default} + * @param {string} nodeId - The node ID to get content for + * @returns {Promise} Content buffer or null + */ +export async function getContent(nodeId) { + const oid = await getContentOid.call(this, nodeId); + if (!oid) { + return null; + } + return await this._persistence.readBlob(oid); +} + +/** + * Gets the content blob OID for an edge, or null if none is attached. + * + * @this {import('../WarpGraph.js').default} + * @param {string} from - Source node ID + * @param {string} to - Target node ID + * @param {string} label - Edge label + * @returns {Promise} Hex blob OID or null + */ +export async function getEdgeContentOid(from, to, label) { + const props = await getEdgeProps.call(this, from, to, label); + if (!props) { + return null; + } + const oid = props[CONTENT_PROPERTY_KEY]; + return (typeof oid === 'string') ? oid : null; +} + +/** + * Gets the content blob for an edge, or null if none is attached. + * + * Returns the raw Buffer from `readBlob()`. Consumers wanting text + * should call `.toString('utf8')` on the result. + * + * @this {import('../WarpGraph.js').default} + * @param {string} from - Source node ID + * @param {string} to - Target node ID + * @param {string} label - Edge label + * @returns {Promise} Content buffer or null + */ +export async function getEdgeContent(from, to, label) { + const oid = await getEdgeContentOid.call(this, from, to, label); + if (!oid) { + return null; + } + return await this._persistence.readBlob(oid); +} diff --git a/test/unit/domain/WarpGraph.content.test.js b/test/unit/domain/WarpGraph.content.test.js new file mode 100644 index 0000000..2bb570d --- /dev/null +++ b/test/unit/domain/WarpGraph.content.test.js @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import WarpGraph from '../../../src/domain/WarpGraph.js'; +import { createEmptyStateV5, encodeEdgeKey, encodeEdgePropKey } from '../../../src/domain/services/JoinReducer.js'; +import { orsetAdd } from '../../../src/domain/crdt/ORSet.js'; +import { createDot } from '../../../src/domain/crdt/Dot.js'; +import { encodePropKey } from '../../../src/domain/services/KeyCodec.js'; + +function setupGraphState(/** @type {any} */ graph, /** @type {any} */ seedFn) { + const state = createEmptyStateV5(); + /** @type {any} */ (graph)._cachedState = state; + graph.materialize = vi.fn().mockResolvedValue(state); + seedFn(state); +} + +function addNode(/** @type {any} */ state, /** @type {any} */ nodeId, /** @type {any} */ counter) { + orsetAdd(state.nodeAlive, nodeId, createDot('w1', counter)); +} + +function addEdge(/** @type {any} */ state, /** @type {any} */ from, /** @type {any} */ to, /** @type {any} */ label, /** @type {any} */ counter) { + const edgeKey = encodeEdgeKey(from, to, label); + orsetAdd(state.edgeAlive, edgeKey, createDot('w1', counter)); + state.edgeBirthEvent.set(edgeKey, { lamport: 1, writerId: 'w1', patchSha: 'aabbccdd', opIndex: 0 }); +} + +describe('WarpGraph content attachment (query methods)', () => { + /** @type {any} */ + let mockPersistence; + /** @type {any} */ + let graph; + + beforeEach(async () => { + mockPersistence = { + readRef: vi.fn().mockResolvedValue(null), + listRefs: vi.fn().mockResolvedValue([]), + updateRef: vi.fn().mockResolvedValue(undefined), + configGet: vi.fn().mockResolvedValue(null), + configSet: vi.fn().mockResolvedValue(undefined), + readBlob: vi.fn().mockResolvedValue(Buffer.from('hello world')), + }; + + graph = await WarpGraph.open({ + persistence: mockPersistence, + graphName: 'test', + writerId: 'writer-1', + }); + }); + + describe('getContentOid()', () => { + it('returns the _content property value for a node', async () => { + setupGraphState(graph, (/** @type {any} */ state) => { + addNode(state, 'doc:1', 1); + const propKey = encodePropKey('doc:1', '_content'); + state.prop.set(propKey, { eventId: null, value: 'abc123' }); + }); + + const oid = await graph.getContentOid('doc:1'); + expect(oid).toBe('abc123'); + }); + + it('returns null when node has no _content property', async () => { + setupGraphState(graph, (/** @type {any} */ state) => { + addNode(state, 'doc:1', 1); + }); + + const oid = await graph.getContentOid('doc:1'); + expect(oid).toBeNull(); + }); + + it('returns null when node does not exist', async () => { + setupGraphState(graph, () => {}); + + const oid = await graph.getContentOid('nonexistent'); + expect(oid).toBeNull(); + }); + + it('returns null when _content is not a string', async () => { + setupGraphState(graph, (/** @type {any} */ state) => { + addNode(state, 'doc:1', 1); + const propKey = encodePropKey('doc:1', '_content'); + state.prop.set(propKey, { eventId: null, value: 42 }); + }); + + const oid = await graph.getContentOid('doc:1'); + expect(oid).toBeNull(); + }); + }); + + describe('getContent()', () => { + it('reads and returns the blob buffer', async () => { + const buf = Buffer.from('# ADR 001\n\nSome content'); + mockPersistence.readBlob.mockResolvedValue(buf); + + setupGraphState(graph, (/** @type {any} */ state) => { + addNode(state, 'doc:1', 1); + const propKey = encodePropKey('doc:1', '_content'); + state.prop.set(propKey, { eventId: null, value: 'abc123' }); + }); + + const content = await graph.getContent('doc:1'); + expect(content).toEqual(buf); + expect(mockPersistence.readBlob).toHaveBeenCalledWith('abc123'); + }); + + it('returns null when no content attached', async () => { + setupGraphState(graph, (/** @type {any} */ state) => { + addNode(state, 'doc:1', 1); + }); + + const content = await graph.getContent('doc:1'); + expect(content).toBeNull(); + expect(mockPersistence.readBlob).not.toHaveBeenCalled(); + }); + + it('returns null for nonexistent node', async () => { + setupGraphState(graph, () => {}); + + const content = await graph.getContent('nonexistent'); + expect(content).toBeNull(); + }); + }); + + describe('getEdgeContentOid()', () => { + it('returns the _content property value for an edge', async () => { + setupGraphState(graph, (/** @type {any} */ state) => { + addNode(state, 'a', 1); + addNode(state, 'b', 2); + addEdge(state, 'a', 'b', 'rel', 3); + const propKey = encodeEdgePropKey('a', 'b', 'rel', '_content'); + state.prop.set(propKey, { eventId: { lamport: 2, writerId: 'w1', patchSha: 'aabbccdd', opIndex: 0 }, value: 'def456' }); + }); + + const oid = await graph.getEdgeContentOid('a', 'b', 'rel'); + expect(oid).toBe('def456'); + }); + + it('returns null when edge has no _content', async () => { + setupGraphState(graph, (/** @type {any} */ state) => { + addNode(state, 'a', 1); + addNode(state, 'b', 2); + addEdge(state, 'a', 'b', 'rel', 3); + }); + + const oid = await graph.getEdgeContentOid('a', 'b', 'rel'); + expect(oid).toBeNull(); + }); + + it('returns null when edge does not exist', async () => { + setupGraphState(graph, () => {}); + + const oid = await graph.getEdgeContentOid('a', 'b', 'rel'); + expect(oid).toBeNull(); + }); + }); + + describe('getEdgeContent()', () => { + it('reads and returns the blob buffer', async () => { + const buf = Buffer.from('edge content'); + mockPersistence.readBlob.mockResolvedValue(buf); + + setupGraphState(graph, (/** @type {any} */ state) => { + addNode(state, 'a', 1); + addNode(state, 'b', 2); + addEdge(state, 'a', 'b', 'rel', 3); + const propKey = encodeEdgePropKey('a', 'b', 'rel', '_content'); + state.prop.set(propKey, { eventId: { lamport: 2, writerId: 'w1', patchSha: 'aabbccdd', opIndex: 0 }, value: 'def456' }); + }); + + const content = await graph.getEdgeContent('a', 'b', 'rel'); + expect(content).toEqual(buf); + expect(mockPersistence.readBlob).toHaveBeenCalledWith('def456'); + }); + + it('returns null when no content attached', async () => { + setupGraphState(graph, (/** @type {any} */ state) => { + addNode(state, 'a', 1); + addNode(state, 'b', 2); + addEdge(state, 'a', 'b', 'rel', 3); + }); + + const content = await graph.getEdgeContent('a', 'b', 'rel'); + expect(content).toBeNull(); + }); + }); +}); From 2527b9198117e82c4f610965e10ee06039d388e1 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 20 Feb 2026 21:17:47 -0800 Subject: [PATCH 05/28] feat(content): type declarations and surface manifest - index.js: export CONTENT_PROPERTY_KEY from KeyCodec - index.d.ts: add attachContent/attachEdgeContent to PatchBuilderV2 and PatchSession; add getContent/getContentOid/getEdgeContent/ getEdgeContentOid to WarpGraph; export CONTENT_PROPERTY_KEY const - type-surface.m8.json: register all new methods and constant - consumer.ts: exercise new API with positive + negative type tests --- contracts/type-surface.m8.json | 33 +++++++++++++++++++++++++++++++ index.d.ts | 36 ++++++++++++++++++++++++++++++++++ index.js | 2 ++ test/type-check/consumer.ts | 17 ++++++++++++++++ 4 files changed, 88 insertions(+) diff --git a/contracts/type-surface.m8.json b/contracts/type-surface.m8.json index 9cdfcc4..0e2e774 100644 --- a/contracts/type-surface.m8.json +++ b/contracts/type-surface.m8.json @@ -72,6 +72,34 @@ ], "returns": "Promise | null>" }, + "getContentOid": { + "async": true, + "params": [{ "name": "nodeId", "type": "string" }], + "returns": "Promise" + }, + "getContent": { + "async": true, + "params": [{ "name": "nodeId", "type": "string" }], + "returns": "Promise" + }, + "getEdgeContentOid": { + "async": true, + "params": [ + { "name": "from", "type": "string" }, + { "name": "to", "type": "string" }, + { "name": "label", "type": "string" } + ], + "returns": "Promise" + }, + "getEdgeContent": { + "async": true, + "params": [ + { "name": "from", "type": "string" }, + { "name": "to", "type": "string" }, + { "name": "label", "type": "string" } + ], + "returns": "Promise" + }, "neighbors": { "async": true, "params": [ @@ -310,6 +338,8 @@ "removeEdge": { "params": [{ "name": "from", "type": "string" }, { "name": "to", "type": "string" }, { "name": "label", "type": "string" }], "returns": "PatchBuilderV2" }, "setProperty": { "params": [{ "name": "nodeId", "type": "string" }, { "name": "key", "type": "string" }, { "name": "value", "type": "unknown" }], "returns": "PatchBuilderV2" }, "setEdgeProperty": { "params": [{ "name": "from", "type": "string" }, { "name": "to", "type": "string" }, { "name": "label", "type": "string" }, { "name": "key", "type": "string" }, { "name": "value", "type": "unknown" }], "returns": "PatchBuilderV2" }, + "attachContent": { "async": true, "params": [{ "name": "nodeId", "type": "string" }, { "name": "content", "type": "Buffer | string" }], "returns": "Promise" }, + "attachEdgeContent": { "async": true, "params": [{ "name": "from", "type": "string" }, { "name": "to", "type": "string" }, { "name": "label", "type": "string" }, { "name": "content", "type": "Buffer | string" }], "returns": "Promise" }, "build": { "params": [], "returns": "PatchV2" }, "commit": { "async": true, "params": [], "returns": "Promise" } }, @@ -326,6 +356,8 @@ "removeEdge": { "params": [{ "name": "from", "type": "string" }, { "name": "to", "type": "string" }, { "name": "label", "type": "string" }], "returns": "this" }, "setProperty": { "params": [{ "name": "nodeId", "type": "string" }, { "name": "key", "type": "string" }, { "name": "value", "type": "unknown" }], "returns": "this" }, "setEdgeProperty": { "params": [{ "name": "from", "type": "string" }, { "name": "to", "type": "string" }, { "name": "label", "type": "string" }, { "name": "key", "type": "string" }, { "name": "value", "type": "unknown" }], "returns": "this" }, + "attachContent": { "async": true, "params": [{ "name": "nodeId", "type": "string" }, { "name": "content", "type": "Buffer | string" }], "returns": "Promise" }, + "attachEdgeContent": { "async": true, "params": [{ "name": "from", "type": "string" }, { "name": "to", "type": "string" }, { "name": "label", "type": "string" }, { "name": "content", "type": "Buffer | string" }], "returns": "Promise" }, "build": { "params": [], "returns": "PatchV2" }, "commit": { "async": true, "params": [], "returns": "Promise" } }, @@ -413,6 +445,7 @@ "encodeEdgePropKey": { "kind": "function", "params": [{ "name": "from", "type": "string" }, { "name": "to", "type": "string" }, { "name": "label", "type": "string" }, { "name": "propKey", "type": "string" }], "returns": "string" }, "decodeEdgePropKey": { "kind": "function", "params": [{ "name": "encoded", "type": "string" }], "returns": "{ from: string; to: string; label: string; propKey: string }" }, "isEdgePropKey": { "kind": "function", "params": [{ "name": "key", "type": "string" }], "returns": "boolean" }, + "CONTENT_PROPERTY_KEY": { "kind": "const" }, "computeTranslationCost": { "kind": "function" }, "migrateV4toV5": { "kind": "function" }, diff --git a/index.d.ts b/index.d.ts index ad8ac14..4eba613 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1147,6 +1147,12 @@ export function decodeEdgePropKey(encoded: string): { from: string; to: string; */ export function isEdgePropKey(key: string): boolean; +/** + * Well-known property key for content attachment. + * Stores a content-addressed blob OID as the property value. + */ +export const CONTENT_PROPERTY_KEY: '_content'; + /** * Configuration for an observer view. */ @@ -1345,6 +1351,10 @@ export class PatchBuilderV2 { setProperty(nodeId: string, key: string, value: unknown): PatchBuilderV2; /** Sets a property on an edge. */ setEdgeProperty(from: string, to: string, label: string, key: string, value: unknown): PatchBuilderV2; + /** Attaches content to a node (writes blob + sets _content property). */ + attachContent(nodeId: string, content: Buffer | string): Promise; + /** Attaches content to an edge (writes blob + sets _content edge property). */ + attachEdgeContent(from: string, to: string, label: string, content: Buffer | string): Promise; /** Builds the PatchV2 object without committing. */ build(): PatchV2; /** Commits the patch to the graph and returns the commit SHA. */ @@ -1375,6 +1385,10 @@ export class PatchSession { setProperty(nodeId: string, key: string, value: unknown): this; /** Sets a property on an edge. */ setEdgeProperty(from: string, to: string, label: string, key: string, value: unknown): this; + /** Attaches content to a node (writes blob + sets _content property). */ + attachContent(nodeId: string, content: Buffer | string): Promise; + /** Attaches content to an edge (writes blob + sets _content edge property). */ + attachEdgeContent(from: string, to: string, label: string, content: Buffer | string): Promise; /** Builds the PatchV2 object without committing. */ build(): PatchV2; /** Commits the patch with CAS protection. */ @@ -1645,6 +1659,28 @@ export default class WarpGraph { */ getEdgeProps(from: string, to: string, label: string): Promise | null>; + /** + * Gets the content blob OID for a node, or null if none is attached. + */ + getContentOid(nodeId: string): Promise; + + /** + * Gets the content blob for a node, or null if none is attached. + * Returns raw Buffer; call `.toString('utf8')` for text. + */ + getContent(nodeId: string): Promise; + + /** + * Gets the content blob OID for an edge, or null if none is attached. + */ + getEdgeContentOid(from: string, to: string, label: string): Promise; + + /** + * Gets the content blob for an edge, or null if none is attached. + * Returns raw Buffer; call `.toString('utf8')` for text. + */ + getEdgeContent(from: string, to: string, label: string): Promise; + /** * Checks if a node exists in the materialized state. */ diff --git a/index.js b/index.js index 2d3a354..b4881ec 100644 --- a/index.js +++ b/index.js @@ -75,6 +75,7 @@ import { encodeEdgePropKey, decodeEdgePropKey, isEdgePropKey, + CONTENT_PROPERTY_KEY, } from './src/domain/services/KeyCodec.js'; import { createTickReceipt, @@ -173,6 +174,7 @@ export { encodeEdgePropKey, decodeEdgePropKey, isEdgePropKey, + CONTENT_PROPERTY_KEY, // WARP migration migrateV4toV5, diff --git a/test/type-check/consumer.ts b/test/type-check/consumer.ts index d228f0c..620902b 100644 --- a/test/type-check/consumer.ts +++ b/test/type-check/consumer.ts @@ -76,6 +76,7 @@ import WarpGraph, { encodeEdgePropKey, decodeEdgePropKey, isEdgePropKey, + CONTENT_PROPERTY_KEY, } from '../../index.js'; import type { @@ -177,6 +178,19 @@ const propCount: number = await graph.getPropertyCount(); const snapshot: WarpStateV5 | null = await graph.getStateSnapshot(); const edges: Array<{ from: string; to: string; label: string; props: Record }> = await graph.getEdges(); +// ---- content attachment ---- +const contentOid: string | null = await graph.getContentOid('n1'); +const contentBuf: Buffer | null = await graph.getContent('n1'); +const edgeContentOid: string | null = await graph.getEdgeContentOid('n1', 'n2', 'knows'); +const edgeContentBuf: Buffer | null = await graph.getEdgeContent('n1', 'n2', 'knows'); +const _attachResult: PatchBuilderV2 = await pb.attachContent('n1', 'hello'); +const _attachEdgeResult: PatchBuilderV2 = await pb.attachEdgeContent('n1', 'n2', 'knows', Buffer.from('data')); +const _contentKey: '_content' = CONTENT_PROPERTY_KEY; + +// ---- PatchSession content attachment ---- +const psAttach: PatchSession = await ps.attachContent('x', 'content'); +const psAttachEdge: PatchSession = await ps.attachEdgeContent('a', 'b', 'c', 'content'); + // ---- query builder ---- const qb: QueryBuilder = graph.query(); @@ -440,3 +454,6 @@ await WarpGraph.open({ graphName: 'test', writerId: 'w1' }); // @ts-expect-error -- createNodeAdd requires string, not number createNodeAdd(42); + +// @ts-expect-error -- getContent requires string, not number +await graph.getContent(42); From 4961e7fcce6524543fc0a74490f372a34c46b29d Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 20 Feb 2026 21:20:10 -0800 Subject: [PATCH 06/28] test(content): integration tests for content attachment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers single-writer, getContentOid, null cases, edge content, multi-writer LWW, time-travel with ceiling, node deletion, Writer API, GC durability (git gc --prune=now), and binary round-trip. Also fixes _blob/ → _blob_ in tree entry paths (git mktree does not support subdirectory paths). --- src/domain/services/PatchBuilderV2.js | 2 +- .../api/content-attachment.test.js | 201 ++++++++++++++++++ .../services/PatchBuilderV2.content.test.js | 12 +- 3 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 test/integration/api/content-attachment.test.js diff --git a/src/domain/services/PatchBuilderV2.js b/src/domain/services/PatchBuilderV2.js index 8c092e1..64fc356 100644 --- a/src/domain/services/PatchBuilderV2.js +++ b/src/domain/services/PatchBuilderV2.js @@ -623,7 +623,7 @@ export class PatchBuilderV2 { // Format for mktree: "mode type oid\tpath" const treeEntries = [`100644 blob ${patchBlobOid}\tpatch.cbor`]; for (let i = 0; i < this._contentBlobs.length; i++) { - treeEntries.push(`100644 blob ${this._contentBlobs[i]}\t_blob/${i}`); + treeEntries.push(`100644 blob ${this._contentBlobs[i]}\t_blob_${i}`); } const treeOid = await this._persistence.writeTree(treeEntries); diff --git a/test/integration/api/content-attachment.test.js b/test/integration/api/content-attachment.test.js new file mode 100644 index 0000000..aea6eb9 --- /dev/null +++ b/test/integration/api/content-attachment.test.js @@ -0,0 +1,201 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execSync } from 'node:child_process'; +import { createTestRepo } from './helpers/setup.js'; + +describe('API: Content Attachment', () => { + /** @type {any} */ + let repo; + + beforeEach(async () => { + repo = await createTestRepo('content'); + }); + + afterEach(async () => { + await repo?.cleanup(); + }); + + it('attach → materialize → getContent returns exact buffer', async () => { + const graph = await repo.openGraph('test', 'alice'); + + const patch = await graph.createPatch(); + patch.addNode('doc:1'); + await patch.attachContent('doc:1', '# Hello World\n\nThis is content.'); + await patch.commit(); + + await graph.materialize(); + const content = await graph.getContent('doc:1'); + expect(content).not.toBeNull(); + expect(Buffer.from(content).toString('utf8')).toBe('# Hello World\n\nThis is content.'); + }); + + it('getContentOid returns hex OID', async () => { + const graph = await repo.openGraph('test', 'alice'); + + const patch = await graph.createPatch(); + patch.addNode('doc:1'); + await patch.attachContent('doc:1', 'test content'); + await patch.commit(); + + await graph.materialize(); + const oid = await graph.getContentOid('doc:1'); + expect(oid).not.toBeNull(); + // Support both SHA-1 (40 chars) and SHA-256 (64 chars) + expect(oid).toMatch(/^[0-9a-f]+$/); + expect(oid.length).toBeGreaterThanOrEqual(40); + }); + + it('returns null when no content attached', async () => { + const graph = await repo.openGraph('test', 'alice'); + + const patch = await graph.createPatch(); + patch.addNode('doc:1').setProperty('doc:1', 'title', 'No content'); + await patch.commit(); + + await graph.materialize(); + expect(await graph.getContent('doc:1')).toBeNull(); + expect(await graph.getContentOid('doc:1')).toBeNull(); + }); + + it('returns null for nonexistent node', async () => { + const graph = await repo.openGraph('test', 'alice'); + await graph.materialize(); + + expect(await graph.getContent('nonexistent')).toBeNull(); + expect(await graph.getContentOid('nonexistent')).toBeNull(); + }); + + it('edge content: attach and retrieve via getEdgeContent', async () => { + const graph = await repo.openGraph('test', 'alice'); + + const p1 = await graph.createPatch(); + p1.addNode('a').addNode('b').addEdge('a', 'b', 'rel'); + await p1.attachEdgeContent('a', 'b', 'rel', 'edge payload'); + await p1.commit(); + + await graph.materialize(); + const content = await graph.getEdgeContent('a', 'b', 'rel'); + expect(content).not.toBeNull(); + expect(Buffer.from(content).toString('utf8')).toBe('edge payload'); + + const oid = await graph.getEdgeContentOid('a', 'b', 'rel'); + expect(oid).toMatch(/^[0-9a-f]+$/); + }); + + it('multi-writer LWW: concurrent attachments resolve deterministically', async () => { + const graph1 = await repo.openGraph('test', 'alice'); + const graph2 = await repo.openGraph('test', 'bob'); + + // Alice creates the node + const p1 = await graph1.createPatch(); + p1.addNode('doc:shared'); + await p1.attachContent('doc:shared', 'alice version'); + await p1.commit(); + + // Bob sees the node via materialize, then attaches different content + await graph2.materialize(); + const p2 = await graph2.createPatch(); + await p2.attachContent('doc:shared', 'bob version'); + await p2.commit(); + + // Materialize from both writers — LWW resolves deterministically + await graph1.materialize(); + const content = await graph1.getContent('doc:shared'); + expect(content).not.toBeNull(); + + // Bob's content should win (higher Lamport tick) + expect(Buffer.from(content).toString('utf8')).toBe('bob version'); + }); + + it('time-travel: materialize with ceiling returns historical content', async () => { + const graph = await repo.openGraph('test', 'alice'); + + // Tick 1: attach v1 + const p1 = await graph.createPatch(); + p1.addNode('doc:1'); + await p1.attachContent('doc:1', 'version 1'); + await p1.commit(); + + // Tick 2: update to v2 + await graph.materialize(); + const p2 = await graph.createPatch(); + await p2.attachContent('doc:1', 'version 2'); + await p2.commit(); + + // Latest should be v2 + await graph.materialize(); + const latest = await graph.getContent('doc:1'); + expect(Buffer.from(latest).toString('utf8')).toBe('version 2'); + + // Ceiling=1 should be v1 + await graph.materialize({ ceiling: 1 }); + const historical = await graph.getContent('doc:1'); + expect(Buffer.from(historical).toString('utf8')).toBe('version 1'); + }); + + it('node deletion removes content reference', async () => { + const graph = await repo.openGraph('test', 'alice'); + + const p1 = await graph.createPatch(); + p1.addNode('doc:1'); + await p1.attachContent('doc:1', 'soon to be deleted'); + await p1.commit(); + + await graph.materialize(); + expect(await graph.getContent('doc:1')).not.toBeNull(); + + const p2 = await graph.createPatch(); + p2.removeNode('doc:1'); + await p2.commit(); + + await graph.materialize(); + // After removing the node, getContent returns null (node not alive) + expect(await graph.getContent('doc:1')).toBeNull(); + }); + + it('writer API: commitPatch with attachContent', async () => { + const graph = await repo.openGraph('test', 'alice'); + const writer = await graph.writer(); + + await writer.commitPatch(async (/** @type {any} */ p) => { + p.addNode('doc:1'); + await p.attachContent('doc:1', 'via writer API'); + }); + + await graph.materialize(); + const content = await graph.getContent('doc:1'); + expect(Buffer.from(content).toString('utf8')).toBe('via writer API'); + }); + + it('GC durability: content survives git gc --prune=now', async () => { + const graph = await repo.openGraph('test', 'alice'); + + const p1 = await graph.createPatch(); + p1.addNode('doc:1'); + await p1.attachContent('doc:1', 'must survive gc'); + await p1.commit(); + + // Run aggressive GC in the test repo + execSync('git gc --prune=now', { cwd: repo.tempDir, stdio: 'pipe' }); + + // Content should still be retrievable (blob is anchored in commit tree) + await graph.materialize(); + const content = await graph.getContent('doc:1'); + expect(content).not.toBeNull(); + expect(Buffer.from(content).toString('utf8')).toBe('must survive gc'); + }); + + it('binary content round-trips correctly', async () => { + const graph = await repo.openGraph('test', 'alice'); + const binary = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0xfd]); + + const patch = await graph.createPatch(); + patch.addNode('bin:1'); + await patch.attachContent('bin:1', binary); + await patch.commit(); + + await graph.materialize(); + const content = await graph.getContent('bin:1'); + expect(content).not.toBeNull(); + expect(Buffer.compare(content, binary)).toBe(0); + }); +}); diff --git a/test/unit/domain/services/PatchBuilderV2.content.test.js b/test/unit/domain/services/PatchBuilderV2.content.test.js index a745706..f8f1d00 100644 --- a/test/unit/domain/services/PatchBuilderV2.content.test.js +++ b/test/unit/domain/services/PatchBuilderV2.content.test.js @@ -204,7 +204,7 @@ describe('PatchBuilderV2 content attachment', () => { }); describe('commit() with content blobs', () => { - it('includes _blob/* entries in tree when content blobs exist', async () => { + it('includes _blob_* entries in tree when content blobs exist', async () => { const contentOid = 'a'.repeat(40); const patchBlobOid = 'b'.repeat(40); const persistence = createMockPersistence({ @@ -227,11 +227,11 @@ describe('PatchBuilderV2 content attachment', () => { await builder.attachContent('n1', 'hello'); await builder.commit(); - // writeTree should be called with both patch.cbor and _blob/0 + // writeTree should be called with both patch.cbor and _blob_0 const treeEntries = persistence.writeTree.mock.calls[0][0]; expect(treeEntries).toHaveLength(2); expect(treeEntries[0]).toBe(`100644 blob ${patchBlobOid}\tpatch.cbor`); - expect(treeEntries[1]).toBe(`100644 blob ${contentOid}\t_blob/0`); + expect(treeEntries[1]).toBe(`100644 blob ${contentOid}\t_blob_0`); }); it('creates single-entry tree when no content blobs', async () => { @@ -254,7 +254,7 @@ describe('PatchBuilderV2 content attachment', () => { expect(treeEntries[0]).toContain('patch.cbor'); }); - it('includes multiple _blob/* entries for multiple attachments', async () => { + it('includes multiple _blob_* entries for multiple attachments', async () => { let blobIdx = 0; const contentA = '1'.repeat(40); const contentB = '2'.repeat(40); @@ -283,8 +283,8 @@ describe('PatchBuilderV2 content attachment', () => { const treeEntries = persistence.writeTree.mock.calls[0][0]; expect(treeEntries).toHaveLength(3); expect(treeEntries[0]).toContain('patch.cbor'); - expect(treeEntries[1]).toBe(`100644 blob ${contentA}\t_blob/0`); - expect(treeEntries[2]).toBe(`100644 blob ${contentB}\t_blob/1`); + expect(treeEntries[1]).toBe(`100644 blob ${contentA}\t_blob_0`); + expect(treeEntries[2]).toBe(`100644 blob ${contentB}\t_blob_1`); }); }); }); From d7e0bcb44943f9ecde90c508f068d023690218d7 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 20 Feb 2026 21:21:31 -0800 Subject: [PATCH 07/28] docs: update content attachment spec and changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CONTENT_ATTACHMENT.md: status Proposal → Implemented; add Final API section with write/read examples; add Durability/Git GC section explaining blob anchoring; update summary table - CHANGELOG.md: add 11.5.0 entry documenting all new API surface --- CHANGELOG.md | 21 +++++++ docs/specs/CONTENT_ATTACHMENT.md | 103 ++++++++++++++++--------------- 2 files changed, 75 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a5519a..0998601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [11.5.0] — 2026-02-20 — Content Attachment (Paper I `Atom(p)`) + +Implements content attachment — the ability to attach content-addressed blobs +to WARP graph nodes and edges as first-class payloads. A blob OID stored as +a `_content` string property gets CRDT merge (LWW), time-travel, and observer +scoping for free — zero changes to JoinReducer, serialization, or the CRDT layer. + +### Added + +- **`CONTENT_PROPERTY_KEY`** constant (`'_content'`) exported from `KeyCodec` and `index.js`. +- **`PatchBuilderV2.attachContent(nodeId, content)`** — writes blob to Git object store, sets `_content` property, tracks OID for GC anchoring. +- **`PatchBuilderV2.attachEdgeContent(from, to, label, content)`** — same for edges. +- **`PatchSession.attachContent()`** / **`attachEdgeContent()`** — async pass-through delegates. +- **`WarpGraph.getContent(nodeId)`** — returns `Buffer | null` from the content blob. +- **`WarpGraph.getContentOid(nodeId)`** — returns hex OID or null. +- **`WarpGraph.getEdgeContent(from, to, label)`** / **`getEdgeContentOid()`** — edge variants. +- **Blob anchoring** — content blob OIDs embedded in patch commit tree as `_blob_0`, `_blob_1`, etc. Survives `git gc --prune=now`. +- **Type declarations** — all new methods in `index.d.ts`, `type-surface.m8.json`, `consumer.ts`. +- **Integration tests** — 11 tests covering single-writer, LWW, time-travel, deletion, Writer API, GC durability, binary round-trip. +- **Unit tests** — 23 tests for PatchBuilderV2 content ops and WarpGraph query methods. + ## [11.4.0] — 2026-02-20 — M8 IRONCLAD Phase 3: Declaration Surface Automation Completes M8 IRONCLAD with automated declaration surface validation and expanded diff --git a/docs/specs/CONTENT_ATTACHMENT.md b/docs/specs/CONTENT_ATTACHMENT.md index 4557e55..6d81e65 100644 --- a/docs/specs/CONTENT_ATTACHMENT.md +++ b/docs/specs/CONTENT_ATTACHMENT.md @@ -1,7 +1,7 @@ # Content Attachment Specification -> **Spec Version:** 0 (proposal) -> **Status:** Proposal +> **Spec Version:** 1 (implemented) +> **Status:** Implemented (v11.5.0) > **Paper References:** Paper I Section 2 (WARP definition, vertex/edge attachments, `Atom(p)`) --- @@ -84,50 +84,43 @@ This approach: The `_content` key prefix convention (underscore) signals a system-level property, distinguishing it from user-defined properties. -### 3.3 API Surface (Sketch) +### 3.3 Final API -The exact API is an implementation decision. Two options are outlined: +The hybrid approach was implemented: dedicated methods that encapsulate CAS details, while the `_content` property remains directly accessible for advanced use. -#### Option A: Property Convention (Minimal) - -No new API methods. Consumers use existing property methods with the `_content` key: +#### Write API (PatchBuilderV2 / PatchSession) ```javascript -// Write -const sha = await cas.writeBlob(buffer); -patch.setProperty('adr:0007', '_content', sha); -patch.commit(); -``` +const patch = await graph.createPatch(); +patch.addNode('adr:0007'); +await patch.attachContent('adr:0007', '# ADR 0007\n\nDecision text...'); +await patch.commit(); -```javascript -// Read -const props = graph.getNodeProps('adr:0007'); -const contentSha = props.get('_content'); -const buffer = await cas.readBlob(contentSha); +// Edge content +await patch.attachEdgeContent('a', 'b', 'rel', 'edge payload'); ``` -**Pro:** Zero API surface change. **Con:** Consumer must manage CAS independently. - -#### Option B: Dedicated Methods (Recommended) +Both methods are async (they call `writeBlob()` internally) and return the builder for chaining. -New methods on the patch and graph API: +#### Read API (WarpGraph) ```javascript -// Write (patch API) -await patch.attachContent('adr:0007', buffer); -// Internally: cas.writeBlob(buffer) → sha, then setProperty(nodeId, '_content', sha) -patch.commit(); - -// Read (graph API) -const buffer = await graph.getContent('adr:0007'); -// Internally: getNodeProps(nodeId).get('_content') → sha, then cas.readBlob(sha) +const buffer = await graph.getContent('adr:0007'); // Buffer | null +const oid = await graph.getContentOid('adr:0007'); // string | null + +// Edge content +const edgeBuf = await graph.getEdgeContent('a', 'b', 'rel'); +const edgeOid = await graph.getEdgeContentOid('a', 'b', 'rel'); ``` -**Pro:** Discoverable, encapsulates CAS details, can add integrity checks. **Con:** New API surface. +`getContent()` returns a raw `Buffer`. Consumers wanting text call `.toString('utf8')`. -#### Hybrid +#### Constant -Expose dedicated methods but also allow direct property access for advanced use cases. The `_content` property is documented as the underlying mechanism. +```javascript +import { CONTENT_PROPERTY_KEY } from '@git-stunts/git-warp'; +// CONTENT_PROPERTY_KEY === '_content' +``` ### 3.4 Content Metadata @@ -170,38 +163,50 @@ The `_content` property at tick `N` points to whatever blob SHA was current at t --- -## 6. Dependency: git-cas +## 6. Durability / Git GC + +Content blobs created by `git hash-object -w` are loose objects. Without anchoring, `git gc --prune=now` would delete them. + +**Solution:** `PatchBuilderV2.commit()` embeds content blob OIDs in the patch commit tree alongside the CBOR patch blob: + +``` +patch.cbor → CBOR-encoded patch blob +_blob_0 → first content blob +_blob_1 → second content blob (if multiple in same patch) +``` + +This makes content blobs reachable via the writer ref chain (`refs/warp//writers/` → commit → tree → blob). GC protection is automatic. Sync replicates content along with patches. Zero new refs, zero new Git commands. + +Integration tests verify this with `git gc --prune=now` after attach. + +--- -`git-cas` provides content-addressed blob storage over Git plumbing: +## 7. Implementation Notes -- `writeBlob(buffer) → sha` — write a blob to the Git object store -- `readBlob(sha) → buffer` — read a blob by SHA -- No index or working tree involvement -- Blobs are subject to standard Git GC rules +No external `git-cas` dependency was needed. The existing `BlobPort` on `GitGraphAdapter` (`writeBlob` via `git hash-object -w`, `readBlob` via `git cat-file blob`) provides all required CAS operations. -If `git-cas` is not suitable or available, equivalent functionality can be achieved with `git hash-object -w` (write) and `git cat-file blob` (read) via the existing plumbing layer. +Edge attachments are included in v1 (not deferred). --- -## 7. Future Work +## 8. Future Work -- **Edge attachments:** Extend the same mechanism to edges (`β(e)` in the paper). The implementation is identical — a `_content` property on edges. Deferred unless trivially included in v1. -- **Nested WARP attachments:** The paper allows `α(v)` to be a full WARP graph, not just an atom. This would mean a node's attachment is itself a graph with nodes, edges, and their own attachments. This is a significant extension beyond content blobs and is out of scope for this proposal. +- **Nested WARP attachments:** The paper allows `α(v)` to be a full WARP graph, not just an atom. This would mean a node's attachment is itself a graph with nodes, edges, and their own attachments. This is a significant extension beyond content blobs and is out of scope. - **Content integrity verification:** Optionally verify blob SHA on read to detect corruption. -- **Content garbage collection:** Mechanism to identify and protect content blobs from Git GC when they are still referenced by live graph state. --- -## 8. Summary +## 9. Summary | Aspect | Decision | |---|---| | Where content is stored | Git object store (content-addressed blobs) | -| How content is referenced | `_content` property on nodes (CAS SHA) | +| How content is referenced | `_content` property on nodes/edges (CAS SHA) | | CRDT model | Existing LWW property semantics, no change | | Time-travel | Automatic via `materialize({ ceiling })` | -| New dependency | `git-cas` (or equivalent plumbing) | -| API shape | TBD — property convention, dedicated methods, or hybrid | -| Edge attachments | Deferred to v2 | +| New dependency | None (uses existing BlobPort on GitGraphAdapter) | +| API shape | Hybrid: dedicated methods + direct property access | +| GC protection | Blob OIDs embedded in patch commit tree | +| Edge attachments | Included in v1 | | Nested WARP attachments | Future work | -| Paper alignment | Implements `Atom(p)` for vertex attachments | +| Paper alignment | Implements `Atom(p)` for vertex and edge attachments | From 6adecfdd413b8c61c2ac4653e6f200a3eab5861b Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 20 Feb 2026 21:21:46 -0800 Subject: [PATCH 08/28] chore: bump version to 11.5.0 New feature (content attachment), backward-compatible. --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1caee70..7d8d790 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@git-stunts/git-warp", - "version": "11.4.0", + "version": "11.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@git-stunts/git-warp", - "version": "11.4.0", + "version": "11.5.0", "license": "Apache-2.0", "dependencies": { "@git-stunts/alfred": "^0.4.0", diff --git a/package.json b/package.json index 5f296fc..d47c4b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/git-warp", - "version": "11.4.0", + "version": "11.5.0", "description": "Deterministic WARP graph over Git: graph-native storage, traversal, and tooling.", "type": "module", "license": "Apache-2.0", From 0aee7f049b704aa4929707ab83396b649f264634 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 20 Feb 2026 21:58:29 -0800 Subject: [PATCH 09/28] docs: add ADR-001 Folds design document --- docs/ADR-001-Folds.md | 213 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 docs/ADR-001-Folds.md diff --git a/docs/ADR-001-Folds.md b/docs/ADR-001-Folds.md new file mode 100644 index 0000000..f62ddf9 --- /dev/null +++ b/docs/ADR-001-Folds.md @@ -0,0 +1,213 @@ +Absolutely. Here’s an ADR that’s clean, opinionated, and ready to drop into the repo — while explicitly choosing “Keep It Silly Simple” for now with the OG atom/CAS _content technique. + +⸻ + + +# ADR-00XX: Folds — Structural Zoom Portals for Recursive Attachments + +- **Date:** 2026-02-20 +- **Status:** Proposed (Deferred) +- **Owner:** @flyingrobots +- **Decision Type:** Architecture / Data Model / Query Semantics + +## Context + +git-warp currently models a WARP graph skeleton (nodes + edges) with a property system that provides: +- multi-writer convergence (CRDT semantics / LWW for props) +- time-travel via materialization ceilings +- observer scoping / visibility rules + +We also want **attachments** per the AIΩN Foundations Paper I lore: +- `α(v)` attaches a WARP value to each vertex +- `β(e)` attaches a WARP value to each edge +- base case is `Atom(p)` (external payload IDs, bytestrings, etc.) +- recursive case allows attachments to be WARP graphs (fractal structure) + +Today, git-mind (and related consumers) are blocked primarily on a minimal “content attachment” primitive: +- store a git blob OID (CAS key) on a node as a normal property (e.g. `_content`) +- read/write blobs using the existing BlobPort +- rely on existing merge/time-travel semantics with no CRDT changes + +This ADR proposes an optional future mechanism called **Folds** to support **structural recursion** (fractal attachments) as a **view/projection** feature first — without conflating it with network causality. + +### Terminology Clarification + +We have two concepts that must not be overloaded: + +- **Wormhole (Causal):** a causal/sync concept (frontiers, receipts, replication topology) +- **Fold (Structural):** a structural boundary / zoom portal for recursive attachments (pure view) + +This ADR is strictly about **Folds**. + +## Decision + +### ✅ For now (Immediate): Keep It Silly Simple + +We will ship v1 content attachment using an Atom/CAS technique: + +- Reserve a system property key: + - `CONTENT_PROPERTY_KEY = "_content"` +- Attach content by storing a blob OID (git-cas key / git blob hash) as the property value. +- Read content by resolving the stored OID via BlobPort. + +This is `Atom(p)` where `p` is a Git object ID — faithful to the Paper I base case. + +### 🔥 Proposed (Deferred): Add **Folds** for Structural Recursion + +If/when we pursue true recursive WARP attachments, we will implement **Fold boundaries** as a structural convention: + +- A fold is represented by a deterministic “fold root” node ID in the same graph. +- A skeleton entity (node or edge) has an attachment subgraph rooted at that fold root. +- Traversal/render/query operate at configurable “zoom levels”: + - collapsed (ignore fold interiors) + - shallow (peek one fold deep) + - recursive (expand folds up to max depth) + +Folds are **not causal shortcuts**, do not change synchronization, and do not create new op types. + +## Fold Design + +### 1) Representation + +Folds are encoded using existing nodes/edges/props only. + +#### 1.1 Fold Root IDs (deterministic) + +We define deterministic fold root IDs to avoid collisions: + +- **Node fold root:** `fold:node:` +- **Edge fold root:** `fold:edge:|