Documents
This page shows how to use the document store: named byte blobs owned by a peer, each with an ETag for conditional reads and writes, and a visibility that decides who may read it. Writes are the owner’s alone.
A document is addressed by its owner’s peer ID and a path. Paths use letters, digits, -, _ and /,
at most 256 characters, with no leading / and no ... A dot is not allowed, so a file name such as
notes.md needs mapping; see Vault sync.
API at a glance
Section titled “API at a glance”| Call | Returns | Notes |
|---|---|---|
PutDocument(ctx, owner, path, content, opts...) |
*wire.DocumentPutResponse |
Create or replace. Created is true for a new document. |
GetDocument(ctx, owner, path, opts...) |
*wire.DocumentResponse |
Reports 304, 403 and 404 in Status, not as an error. |
HeadDocument(ctx, owner, path, opts...) |
*wire.DocumentMetadata |
ETag, content type, length, last modified, visibility. |
ListDocuments(ctx, owner, opts...) |
[]wire.DocumentInfo |
Every document the caller may read; walks all pages. |
PatchDocument(ctx, owner, path, patch, opts...) |
*wire.DocumentPutResponse |
JSON merge-patch of an existing document. |
DeleteDocument(ctx, owner, path, opts...) |
bool |
false, nil if it did not exist. |
PutDocuments(ctx, owner, docs, opts...) |
[]client.BatchDocumentResult |
Up to 100 documents in one request. |
DocumentAccess, SetDocumentVisibility, GrantDocumentReader, RevokeDocumentReader |
*wire.StoreAccess |
Who may read one of your documents. |
| Option | Applies to |
|---|---|
WithContentType(ct) |
Put. Default application/octet-stream. |
WithIfMatch(etag) |
Put, Patch: write only if the current ETag matches. |
WithIfNoneMatch(etag) |
Get: answer 304 with no body if the ETag still matches. |
WithVisibility(v) |
Put: wire.VisibilityPrivate, VisibilityShared or VisibilityPublic. |
WithDocServer(id) |
All: send this request to one server only. |
Write and read
Section titled “Write and read”owner := cl.PeerID()
put, err := cl.PutDocument(ctx, owner, "config/settings", []byte(`{"theme":"dark"}`), client.WithContentType("application/json"),)fmt.Println(put.ETag, put.Created) // sha256:… true
doc, err := cl.GetDocument(ctx, owner, "config/settings")switch {case err != nil: return errcase doc.IsSuccess(): fmt.Println(string(doc.Content), doc.ContentType, doc.ETag)case doc.IsNotFound(): // no such documentcase doc.IsForbidden(): // exists, but you may not read it}GetDocument is the one call that reports the server’s status in its result, so a conditional get can
see a 304. Every other document call turns a failure into a typed error.
An ETag is sha256: followed by the hex SHA-256 of the content. You can compute it locally to check
whether a file matches the stored document without fetching it.
Conditional requests
Section titled “Conditional requests”WithIfNoneMatch makes a refresh cheap: if the document has not changed, the server answers 304 with no
body.
doc, err = cl.GetDocument(ctx, owner, "config/settings", client.WithIfNoneMatch(cachedETag))if err == nil && doc.IsNotModified() { // keep the cached copy}WithIfMatch makes a write conditional on the version you last saw. If the document changed since, the
write fails with a *client.ConflictError carrying the server’s current ETag. An If-Match against a
path that does not exist yet is treated as satisfied, and the document is created. DeleteDocument is
not conditional.
Optimistic concurrency: laptop and desktop
Section titled “Optimistic concurrency: laptop and desktop”Two devices edit the same note. Both start from ETag E1.
- The laptop writes with
WithIfMatch(E1). It succeeds; the document is nowE2. - The desktop writes with
WithIfMatch(E1). The server refuses: the document isE2. - The desktop reads
E2, merges its edit into it, and writes again withWithIfMatch(E2).
// Sketch: merge is your app's function.func save(ctx context.Context, cl *client.Client, path string, local []byte, base string) (string, error) { owner := cl.PeerID() for { res, err := cl.PutDocument(ctx, owner, path, local, client.WithIfMatch(base)) if err == nil { return res.ETag, nil } var conflict *client.ConflictError if !errors.As(err, &conflict) { return "", err } remote, err := cl.GetDocument(ctx, owner, path) if err != nil { return "", err } local = merge(local, remote.Content) base = remote.ETag // conflict.ActualETag holds the same value }}Retrying the same write unchanged fails the same way, which is why client.IsRetryable is false for a
conflict.
Merge-patch
Section titled “Merge-patch”PatchDocument applies a JSON merge-patch (RFC 7396) to an existing JSON document. A nil value
removes a key. Patching a document that does not exist is client.ErrNotFound.
// {"theme":"dark","fontSize":12} becomes {"theme":"dark","fontSize":14,"lang":"en"}_, err = cl.PatchDocument(ctx, owner, "config/settings", map[string]any{"fontSize": 14, "lang": "en"}, client.WithIfMatch(put.ETag),)// remove a key_, err = cl.PatchDocument(ctx, owner, "config/settings", map[string]any{"lang": nil})List, head and delete
Section titled “List, head and delete”docs, err := cl.ListDocuments(ctx, owner)for _, d := range docs { fmt.Println(d.Path, d.Size, d.ETag, d.Visibility)}
meta, err := cl.HeadDocument(ctx, owner, "config/settings")fmt.Println(meta.ContentLength, meta.LastModified)
deleted, err := cl.DeleteDocument(ctx, owner, "config/settings")// deleted == false, err == nil: it was already goneListDocuments returns paths, sizes and ETags without bodies, and follows the server’s pages until the
listing is complete. Listing another peer’s documents shows only the ones you may read.
Batch writes
Section titled “Batch writes”PutDocuments writes up to client.MaxBatchDocuments (100) documents, at most
client.MaxBatchContentBytes (6 MiB) of content, in one request. Each document has its own optional
IfMatch and Visibility and its own result; one conflict does not fail the batch.
batch := []client.BatchDocumentPut{ {Path: "notes/a", Content: a, ContentType: "text/markdown", IfMatch: etagA}, {Path: "notes/b", Content: b, ContentType: "text/markdown"},}for _, chunk := range client.BatchDocuments(batch) { // splits by count and by 2 MiB of content results, err := cl.PutDocuments(ctx, owner, chunk) if err != nil { return err // the request as a whole failed } for _, r := range results { if !r.OK() { fmt.Println(r.Path, r.Status, r.Error, r.ActualETag) // 409 carries ActualETag } }}Visibility and readers
Section titled “Visibility and readers”A new document is private: only the owner reads it. A shared document is also readable by the peers on
its reader list; a public one by any peer. A PutDocument without WithVisibility keeps an existing
document’s visibility.
_, err = cl.PutDocument(ctx, owner, "profile", []byte(`{"name":"Ann"}`), client.WithVisibility(wire.VisibilityPublic))
_, err = cl.SetDocumentVisibility(ctx, "config/settings", wire.VisibilityShared)access, err := cl.GrantDocumentReader(ctx, "config/settings", friendID)fmt.Println(access.Visibility, access.Readers) // shared [{12D3KooW… 1791873013138}]
_, err = cl.RevokeDocumentReader(ctx, "config/settings", friendID)The access calls act on your own documents, so they take a path but no owner. A grant made while a document is private is kept and applies once the document is shared. Visibility covers every stored version of the document.
The server stores document bytes as sent. Visibility is enforced by the server, not by encryption; to keep a document from the server itself, encrypt it before you store it.
History
Section titled “History”The document protocol has a HISTORY operation, and the server numbers every write. pkg/client 1.1.0
has no method for history and does not expose version numbers. The 1.1.0 server keeps old contents only
for documents with history enabled, which is off by default and which no request turns on, so treat each
PutDocument as replacing the previous content. The protocol reference lists
the operation for clients that speak the protocol directly.