Skip to content

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.

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.
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 err
case doc.IsSuccess():
fmt.Println(string(doc.Content), doc.ContentType, doc.ETag)
case doc.IsNotFound():
// no such document
case 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.

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.

  1. The laptop writes with WithIfMatch(E1). It succeeds; the document is now E2.
  2. The desktop writes with WithIfMatch(E1). The server refuses: the document is E2.
  3. The desktop reads E2, merges its edit into it, and writes again with WithIfMatch(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.

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})
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 gone

ListDocuments 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.

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
}
}
}

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.

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.