|
1 | | -# OG-014 — Stream content attachments through git-cas |
| 1 | +# OG-014 — Mandatory CAS blob storage with streaming I/O |
2 | 2 |
|
3 | | -Status: QUEUED |
| 3 | +Status: DONE |
4 | 4 |
|
5 | 5 | Legend: Observer Geometry |
6 | 6 |
|
7 | | -## Problem |
8 | | - |
9 | | -`getContent()` and `getEdgeContent()` currently return full `Uint8Array` |
10 | | -buffers. That means attachment reads materialize the entire payload in memory |
11 | | -before user code can process it. |
12 | | - |
13 | | -This is fine for small text blobs, but it is the wrong default shape for large |
14 | | -attachments: |
| 7 | +Design doc: `docs/design/streaming-cas-blob-storage.md` |
15 | 8 |
|
16 | | -- the attachment may not fit comfortably in memory |
17 | | -- the caller cannot decide between buffered read and stream processing |
18 | | -- builder-facing docs risk teaching attachment reads as eager byte loads |
19 | | -- the current blob-storage abstraction still forces `retrieve()` to return a |
20 | | - full buffer rather than a stream-capable interface |
| 9 | +## Problem |
21 | 10 |
|
22 | | -`git-warp` already contains a `CasBlobAdapter` that stores attachments in |
23 | | -`git-cas` with CDC chunking, but the public attachment path still terminates in |
24 | | -buffered reads. That leaves the most scalable backend present but not fully |
25 | | -expressed through the public API. |
| 11 | +Content blob attachments in `git-warp` have two structural problems: |
26 | 12 |
|
27 | | -## Why this matters |
| 13 | +### 1. CAS blob storage is opt-in |
28 | 14 |
|
29 | | -WARP graphs can legitimately carry attached documents, artifacts, and other |
30 | | -payloads that are larger than normal graph properties. |
| 15 | +`attachContent()` and `attachEdgeContent()` accept an optional `blobStorage` |
| 16 | +injection. When callers do not provide it, blobs fall through to raw |
| 17 | +`persistence.writeBlob()` — a single unchunked Git object with no CDC |
| 18 | +deduplication, no encryption support, and no streaming restore path. |
31 | 19 |
|
32 | | -The API should make the memory tradeoff explicit: |
| 20 | +This means the substrate's chunking, deduplication, and encryption capabilities |
| 21 | +are present but silently bypassed by default. There is no good reason for a |
| 22 | +content blob to skip CAS. Every blob should be chunked. |
33 | 23 |
|
34 | | -- buffered reads when you actually want all bytes in memory |
35 | | -- streaming reads when you want to process incrementally |
| 24 | +### 2. Neither write nor read paths support streaming |
36 | 25 |
|
37 | | -That decision should belong to the caller, not be forced by the default |
38 | | -attachment API shape. |
| 26 | +**Write path**: `attachContent(nodeId, content)` accepts `Uint8Array | string`. |
| 27 | +The caller must buffer the entire payload in memory before handing it to the |
| 28 | +patch builder. `CasBlobAdapter.store()` then wraps that buffer in |
| 29 | +`Readable.from([buf])` — a synthetic stream from an already-buffered payload. |
39 | 30 |
|
40 | | -## Current state |
| 31 | +**Read path**: `getContent(nodeId)` returns `Promise<Uint8Array | null>`. The |
| 32 | +full blob is materialized into memory before the caller can process it. |
| 33 | +`CasBlobAdapter.retrieve()` calls `cas.restore()` which buffers internally. |
41 | 34 |
|
42 | | -Today the attachment read path is eager: |
| 35 | +`git-cas` already supports streaming on both sides: |
| 36 | +- `cas.store({ source })` accepts any readable/iterable source |
| 37 | +- `cas.restoreStream()` returns `AsyncIterable<Buffer>` |
43 | 38 |
|
44 | | -- `getContent()` -> `Promise<Uint8Array|null>` |
45 | | -- `getEdgeContent()` -> `Promise<Uint8Array|null>` |
46 | | -- `BlobStoragePort.retrieve()` -> `Promise<Uint8Array>` |
47 | | -- default Git blob reads go through `readBlob()` and collect the full blob |
48 | | -- `CasBlobAdapter` can already store attachment content in `git-cas`, but it |
49 | | - still restores into one full buffer via `retrieve()` |
50 | | -- `git-cas` streaming restore is already used in `CasSeekCacheAdapter`, but not |
51 | | - yet exposed through attachment reads |
| 39 | +The streaming substrate is there. It is not expressed through the public API. |
52 | 40 |
|
53 | | -## Desired outcome |
| 41 | +## Why this matters |
54 | 42 |
|
55 | | -Make `git-cas` the first-class streaming attachment path without breaking the |
56 | | -simple buffered paths. |
| 43 | +WARP graphs can carry attached documents, media, model weights, and other |
| 44 | +payloads that are legitimately large. The API should not force full in-memory |
| 45 | +buffering on either side of the I/O boundary. |
57 | 46 |
|
58 | | -Likely shape: |
| 47 | +- Callers writing large content should be able to pipe a stream in |
| 48 | +- Callers reading large content should be able to consume it incrementally |
| 49 | +- Every blob should get CDC chunking and deduplication as a substrate guarantee |
| 50 | +- The decision between buffered and streaming I/O should belong to the caller |
59 | 51 |
|
60 | | -- `getContentStream(nodeId)` |
61 | | -- `getEdgeContentStream(from, to, label)` |
62 | | -- `BlobStoragePort.retrieveStream(oid)` |
63 | | -- `CasBlobAdapter.retrieveStream(oid)` backed by `git-cas restoreStream()` |
64 | | -- a clear default/recommended way to wire `CasBlobAdapter` into `WarpApp.open()` |
65 | | - / `WarpCore.open()` for attachment storage |
| 52 | +## Current state |
66 | 53 |
|
67 | | -Buffered helpers should remain available for convenience, but they should be |
68 | | -clearly layered on top of the stream-capable substrate. |
| 54 | +As of `v15.0.1`: |
| 55 | + |
| 56 | +- `BlobStoragePort`: `store(content, options) → Promise<string>`, |
| 57 | + `retrieve(oid) → Promise<Uint8Array>` — both buffered |
| 58 | +- `CasBlobAdapter`: fully implemented CAS adapter with CDC chunking, optional |
| 59 | + encryption, backward-compat fallback to raw Git blobs — but only buffered I/O |
| 60 | +- `CasBlobAdapter` is internal (not exported from `index.js`) |
| 61 | +- `PatchBuilderV2.attachContent()`: accepts `Uint8Array | string`, uses |
| 62 | + `blobStorage.store()` if injected, else raw `persistence.writeBlob()` |
| 63 | +- `getContent()` / `getEdgeContent()`: returns `Promise<Uint8Array | null>`, |
| 64 | + uses `blobStorage.retrieve()` if injected, else raw `persistence.readBlob()` |
| 65 | +- `WarpApp` and `WarpCore` do not expose content read methods at all |
| 66 | +- `git-cas` streaming (`restoreStream()`) is already used in |
| 67 | + `CasSeekCacheAdapter` but not in blob reads |
| 68 | +- `InMemoryGraphAdapter` has `writeBlob()`/`readBlob()` for browser/test path |
69 | 69 |
|
70 | | -Longer-term, if attachment storage standardizes on `git-cas`, the builder story |
71 | | -gets cleaner too: |
| 70 | +## Desired outcome |
72 | 71 |
|
73 | | -- large attachments become chunked CAS assets |
74 | | -- reads can stream incrementally |
75 | | -- dedupe happens below the API surface |
76 | | -- legacy raw Git blob attachments can remain readable for compatibility |
| 72 | +1. CAS blob storage is mandatory — no fallback to raw `writeBlob()` for content |
| 73 | +2. Write path accepts streaming input and pipes through without buffering |
| 74 | +3. Read path returns a stream the caller can consume incrementally |
| 75 | +4. Buffered convenience methods remain available, layered on top of streams |
| 76 | +5. Browser and in-memory paths still work via a conforming adapter |
| 77 | +6. Legacy raw Git blob attachments remain readable for backward compatibility |
77 | 78 |
|
78 | 79 | ## Acceptance criteria |
79 | 80 |
|
80 | | -1. `git-warp` exposes explicit streaming APIs for node and edge attachments. |
81 | | -2. Callers can choose stream vs buffered read intentionally. |
82 | | -3. `BlobStoragePort` grows a stream-capable retrieval contract. |
83 | | -4. `CasBlobAdapter` supports streaming retrieval via `git-cas`. |
84 | | -5. `git-cas` becomes the recommended path for large attachment storage. |
85 | | -6. Legacy raw Git blob attachments remain readable for compatibility. |
86 | | -7. Builder docs explain when to use buffered reads vs streams. |
87 | | -8. Large attachment reads no longer require full in-memory buffering by |
88 | | - default in the stream path. |
| 81 | +1. Every content blob written through `attachContent()` / `attachEdgeContent()` |
| 82 | + goes through `BlobStoragePort` — no raw `persistence.writeBlob()` fallback. |
| 83 | +2. `attachContent()` / `attachEdgeContent()` accept streaming input |
| 84 | + (`AsyncIterable<Uint8Array>`, `ReadableStream`, `Uint8Array`, `string`). |
| 85 | +3. New `getContentStream()` / `getEdgeContentStream()` return |
| 86 | + `AsyncIterable<Uint8Array>` for incremental consumption. |
| 87 | +4. Existing `getContent()` / `getEdgeContent()` remain as buffered convenience, |
| 88 | + implemented on top of the stream primitive. |
| 89 | +5. `BlobStoragePort` grows `storeStream()` and `retrieveStream()` methods. |
| 90 | +6. `CasBlobAdapter` implements streaming via `git-cas` natively. |
| 91 | +7. An `InMemoryBlobStorageAdapter` implements the port contract for browser and |
| 92 | + test paths. |
| 93 | +8. Legacy raw Git blob attachments remain readable through backward-compat |
| 94 | + fallback in `CasBlobAdapter.retrieveStream()`. |
| 95 | +9. Content stream methods are exposed on `WarpApp` and `WarpCore`. |
89 | 96 |
|
90 | 97 | ## Non-goals |
91 | 98 |
|
92 | | -- no automatic conversion of all existing attachment reads to streams |
93 | | -- no silent breaking change to `getContent()` / `getEdgeContent()` |
94 | | -- no attempt to solve whole-state out-of-core replay here |
| 99 | +- No automatic migration of existing raw Git blobs to CAS format |
| 100 | +- No silent breaking change to existing `getContent()` / `getEdgeContent()` |
| 101 | + return types |
| 102 | +- No attempt to solve whole-state out-of-core replay (that is OG-013) |
| 103 | +- No encryption-by-default (encryption remains an opt-in CAS capability) |
95 | 104 |
|
96 | 105 | ## Notes |
97 | 106 |
|
98 | | -This item is related to, but narrower than, |
99 | | -`OG-013-out-of-core-materialization-and-streaming-reads.md`. |
100 | | -`OG-013` is about whole-state and replay architecture. |
101 | | -This item is specifically about attachment payload I/O and making |
102 | | -`git-cas` the streaming/chunked attachment path. |
| 107 | +This item supersedes the original OG-014 scope, which covered only streaming |
| 108 | +reads. The expanded scope now includes mandatory CAS and streaming writes. |
| 109 | + |
| 110 | +Related items: |
| 111 | +- `OG-013`: out-of-core materialization and streaming reads (broader, separate) |
| 112 | +- `B160`: blob attachments via CAS (done, but opt-in — this item makes it |
| 113 | + mandatory) |
| 114 | +- `B163`: streaming restore for seek cache (done, pattern to follow for blobs) |
0 commit comments