Blocks and safe writes
Read a document as blocks with ids, write one block back, and make a write fail instead of overwriting someone else's change.
A block is one top-level piece of a markdown document: a heading, a paragraph, a list, a table, a code block. Every post, draft, library document and board card in the /v1/fs tree can be read as a list of blocks, each with an id, and written back one block at a time. This page covers those writes and the two ways sfora keeps you from overwriting a change you haven't seen: block ids and revisions.
Read the blocks
Add ?view=blocks to a document's path to get every block as JSON, or ?view=outline for the headings only:
curl -H "Authorization: Bearer $KEY" "$SITE/v1/fs/projects/general/library/documents/hill-chart-notes.md?view=blocks"Each block has an id, a type, its lines and offsets, its text, and writable. The full response, the id recipe and the fields are on the /v1/fs tree. Two things matter for writing:
- An id is derived from the block's content. The same markdown always gives the same ids, so you can compute them yourself. Edit a block and its id changes.
writablesays whether the id works on a write. The frontmatter fence and the# Titleat the top of every read are an envelope around the document's body, and a write can't aim at them. They arewritable: false; the body's own blocks arewritable: true. Filter on it before you write.
Write one block
PUT the document's path with ?block=<id>. The request body is that block's new markdown, not a file: no frontmatter and no title. A # heading in it is a heading in the body, not a rename.
curl -X PUT -H "Authorization: Bearer $KEY" --data-binary 'The hill chart answers two questions, not one.' "$SITE/v1/fs/projects/general/library/documents/hill-chart-notes.md?block=k7f3a2cx"sfora replaces exactly that block and copies every other byte of the document through. No other paragraph is re-spelled and no table is re-padded. The other blocks keep their ids, unless the block you edited was one of several identical blocks: those are told apart by an ordinal (k7f3a2cx, then k7f3a2cx.2), and the ordinal renumbers.
The answer is the ordinary write answer, 201:
{
"filename": "hill-chart-notes.md",
"path": "/v1/fs/projects/general/library/documents/hill-chart-notes.md",
"id": "j57d0c8...",
"url": "https://www.sfora.ai/org/acme/notes/j57d0c8...",
"created": false,
"present": true,
"changed": true,
"blockIds": { "rebound": 1, "orphaned": 0, "total": 4 }
}present: true is on document writes only: writing a document puts you in its live roster in the app, on the block you wrote.
?block= works on posts, drafts, library documents (and the docs/ alias) and board cards. Only a draft takes one among posts, because a published post can't be changed.
A block containing a mention has two spellings: the read shows @Ada Lovelace, the stored body holds @[Ada Lovelace](m93b…). writable already accounts for this, so use the ids it marks true.
On a board card
The column in the path is not an instruction on a block write. A whole-file PUT to board/04-done/0001-a-card.md with no column: in its frontmatter moves the card to Done and closes it, because you wrote the file into that folder. A ?block= write to the same path does not: the body is prose with no frontmatter, so the card keeps its column, status, title and labels. If the card moved since you read it, movedTo and path in the answer say where it is now.
What a write did
Every markdown write, whole-file or one block, answers with two fields:
changed: whether the stored body moved. sfora compares your bytes with the stored ones block by block and keeps the stored spelling wherever the two parse the same. So aPUTof what you just read, or of a document your formatter re-spelled, stores nothing:changedisfalse, nobody is notified, and the revision stays where it was.changedis about the body only. A whole-file write that edits only the# Titlerenames the document and its file and still answerschanged: false, so readpathandfilenamefor where it lives now.blockIds: what became of the blocks the document had before the write.totalis how many there were,reboundhow many are the same block under a new id (you edited them), andorphanedhow many have no match in the new version (you removed them or rewrote them past recognition). On a create all three are0.
// one paragraph edited in a four-block document
{ "changed": true, "blockIds": { "rebound": 1, "orphaned": 0, "total": 4 } }
// the same document written straight back after a read
{ "changed": false, "blockIds": { "rebound": 0, "orphaned": 0, "total": 4 } }Revisions
A block id protects one block. A revision protects the whole document. Drafts, documents and cards carry a revision number that goes up by one each time their title or body changes (for a document, its frontmatter too). A card's column, status, labels, assignees and priority don't count, so moving a card never conflicts with someone typing in it.
A markdown read sends the revision twice:
ETag: "7"
X-Sfora-Revision: 7Send it back with the write, either way:
curl -X PUT -H "Authorization: Bearer $KEY" -H 'If-Match: "7"' --data-binary @notes.md "$SITE/v1/fs/projects/general/library/documents/hill-chart-notes.md"
curl -X PUT -H "Authorization: Bearer $KEY" --data-binary @notes.md "$SITE/v1/fs/projects/general/library/documents/hill-chart-notes.md?expectedRevision=7"If the document is still at revision 7, the write goes through. If someone changed it in the meantime, nothing is written and you get 409:
{ "error": "conflict", "message": "NOTE_CONFLICT: This document changed in another session" }Read the document again, merge, and write with the new revision. If-Match takes "7", "7-gzip" (as some proxies rewrite an ETag) or a bare 7. Anything else is 422. Without If-Match or expectedRevision, the write is last-write-wins.
When a write is refused
| Status | When | What to do |
|---|---|---|
409 with block and blocks | The ?block= id no longer resolves: someone edited that block since you read it. | Pick the block you meant from blocks and write again. |
409 without block | The revision moved, or the target is a published post. | Re-read and merge; for a post, write a draft instead. |
422 | The ?block= body is empty, the ?block= id is empty, the path has no stored body (plan.md, map.md, links.md, asks.md), or If-Match is malformed. | Fix the request. To remove a block, PUT the whole document without it. |
A block conflict carries its recovery, so you can re-aim without a second read:
{
"error": "conflict",
"message": "`k7f3a2cx` is not in this document any more — it was edited, replaced or removed since you read it. Its blocks now are listed in `blocks`; re-aim at one of those, or PUT the whole file.",
"block": "k7f3a2cx",
"blocks": [
{ "id": "k4h2m9qt", "line": 1, "preview": "## Where the unknowns live" },
{ "id": "k9a12b4d", "line": 3, "preview": "The hill chart answers two." }
]
}blocks is every block the document has now, in order, with the line it starts on (1-based, in the body) and its first line of text. Key off the block field, not the status: a 409 without it is a different refusal, and re-aiming won't help.
A refused write changes nothing: no new revision and no activity.
Troubleshooting
Every write to a block answers 409
You are aiming at an envelope block (writable: false), or at a block that changed. Read ?view=blocks again and use an id marked writable: true.
A write answers changed: false
Your bytes parse the same as the stored ones, so there was nothing to store. If you changed only the title, the document was still renamed; read path.
A whole-file write to a card moved it
A whole-file PUT places the card in the column its column: frontmatter names, or, without that field, in the folder in its path. Moving a card out of Done reopens it. To edit the text only, use ?block=.
Last updated on
The /v1/fs file tree
Every project as a folder of markdown files, posts, drafts, library documents and board cards, that you list, read, write and delete over HTTP.
Board, library, plan and links
Cards and columns, library documents and files, and the plan.md, map.md, asks.md and links.md files sfora generates for each project, over /v1/fs.