- ClayJS
- Reference
- Hosts and protocols
- Conditional saves and receipts
Conditional saves and receipts
How ClayJS uses ETags, If-Match, and Save-ID receipts to prevent lost updates, handle HTTP 412 conflicts, and recover from timed-out saves.
ClayJS protects documents from silent overwrites using HTTP entity tags (ETag and If-Match) and per-attempt save receipts (Save-ID). When the file on the host changes underneath an open tab, the host refuses the stale save instead of overwriting the newer version.
ETag and If-Match#
Conditional saves activate when the host’s /_/meta discovery response includes "conditional" in extensions (supported by Hyperclay, HTML Clay, and Hyperclay Local; see Host compatibility). If a host never announces conditional, ClayJS never sends If-Match, even if a save response includes an etag.
On a conditional host, ClayJS tracks the version stamp (etag) of the file the tab is based on and sends it in the If-Match header on every save:
POST /_/save HTTP/1.1
Document-URL: https://example.hyperclay.com/board
If-Match: "8f02c16e7b4d9a30"
Save-ID: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d
ClayJS learns and updates the held stamp from four sources:
- The
documentetagroot attribute: Stamped on<html>by the host when serving the page, read once at boot, and stripped before every save. - Discovery (
/_/meta): If the served HTML carried nodocumentetag, ClayJS seeds the stamp fromdocument.etagin the/_/metaresponse. - Accepted saves: Every
2xxsave response updates the held stamp toresult.etag(or clears the held stamp if the response omitsetag). - Disk sync frames: When live sync applies a
diskframe, ClayJS updates the stamp to match that frame.
If a tab holds no stamp yet (for example, if the user saves immediately after load before /_/meta has responded on a host that did not set documentetag), that single save is sent without If-Match and uses last-write-wins.
The conflict path (412)#
If the file on the host has changed since the version named in If-Match and live sync has not merged that change into the tab yet, the host refuses the save with HTTP 412 Precondition Failed and writes nothing.
When a save receives HTTP 412:
msgType: "conflict":clay.save()resolves withok: false,msgType: "conflict",code: "conflict", and the refusal metadata (conflictEtag,changedBy,afterTimeout,hadTimeout,reason,retryAfter).- Root attribute and event: ClayJS sets
<html savestatus="conflict">and dispatchesclay:save-conflictondocumentwithdetail: { msg, msgType, timestamp, changedBy, afterTimeout, etag }, whereetagis theconflictEtagthe host refused the save against. - The autosave hold: ClayJS suspends autosave so the tab stops sending requests that would either fail or overwrite the host. Your unsaved edits stay intact in the live DOM. Pressing
Cmd+S(Ctrl+S) still attempts an explicit save, which will be refused again while the conflict stands. - The conflict notice: ClayJS shows a built-in conflict bar with three actions:
- Keep mine: Calls
clay.save.overwrite(), which fetches the host’s currentetagfrom/_/metaand runsclay.save.force()to overwrite the host’s version with this tab’s page. - Accept theirs: A two-step confirmation (Accept theirs, then Yes, drop my edits) that reloads the page and discards this tab’s unsaved changes.
- Download my copy: Downloads this tab’s HTML document along with a list of replaced edits.
- Keep mine: Calls
Automatic resolution with live sync#
When the sync plugin is loaded, most 412 refusals resolve automatically a moment later: when live sync receives and merges the frame whose etag matches the refusal’s conflictEtag without losing local edits, ClayJS releases the autosave hold, sets <html savestatus="saved">, hides the conflict notice (on an autosave page), and fires clay:save-conflict-resolved (detail: { timestamp }) on document.
What clay.save() and clay.save.flush() return#
clay.save()#
clay.save() snapshots the page, sends it to the host if dirty, and returns Promise<{ ok, msg, msgType, code, etag }>. The promise never rejects (note that if a registered snapshot or transform hook throws during capture, the resolved object has msgType: "error" and no ok property).
Always branch on result.msgType rather than result.ok, because ok: false also covers skipped and queued saves:
ok | msgType | Meaning |
|---|---|---|
true | "success" (or "warning") | The host accepted the save and returned the new etag. |
false | "skipped" | Nothing was sent: "Not in edit mode", "No changes to save", or "Save already in progress" (when a save is already in flight, the new bytes are queued and sent automatically after it finishes). |
false | "conflict" | HTTP 412 refusal (code: "conflict"). Also includes changedBy, afterTimeout, and conflictEtag. |
false | "error" | The host returned an error status, a header was invalid (code: "invalid-header"), or the network request failed. |
false | "unknown" | The 12-second save timeout expired and the host could not confirm whether the write landed. |
clay.save.flush(options)#
Use clay.save.flush({ keepalive = false, timeoutMs = 15000 }) when you need a promise that resolves only once the host holds the exact bytes currently on the page (for example, before running an external script or hiding a manual-save tab).
flush waits for the initial load-settle window (clay:baseline-settled), waits for any in-flight save to finish, and retries (up to 4 times) if the page is edited while the save is in flight. keepalive: true passes keepalive to fetch for page-unload saves and is honored only when the UTF-8 HTML body is under 60 KB.
- Resolves with
{ state, etag }:state: "view": The page is in view mode and cannot save.state: "clean": The host already has the current bytes; nothing was sent andetagis the last seen stamp.state: "saved": This call wrote the current bytes to the host andetagis the new stamp.
- Rejects with an
Errorcarryingerr.state:err.state === "failed": The save failed ortimeoutMs(default15000ms) elapsed.err.state === "conflict": The page is in an unresolved412conflict.flushnever overwrites a conflict on its own; overwriting always requiresclay.save.overwrite().err.state === "blocked": The DOM bytes are saved, but a check registered throughclay.registerUnsavedStatereports pending work outside the DOM.
clay.save.replace(html, { etag })#
One conditional write of a whole document this page did not build: html is the entire file as a string, and etag names the version it replaces. It is the write an upgrade makes, so it lives outside the save core (src/core/save-replace.js) and only the upgrade plugin loads it, which is also why it is present in view mode: on a template copy whose tab can edit, the upgrade loads there and clay.save holds this member alone.
- The bytes go out as given. No capture, no snapshot, no
beforeSaveand no document transform runs: the string handed in is already the whole document, and this page is not touched. It also does not retry. - The save lane’s own request. Endpoint, headers and the save-token route are the same as
clay.save()’s, andetagbecomesIf-Match. With noetagthe tab’s held stamp is used, and a tab that has never saved seeds one from/_/metafirst. - A host that never announced
conditionalgets no request at all. Without the capabilityIf-Matchmeans nothing, so the answer isunsupportedwithout a write. - One answer, and it never rejects:
| Resolves | Meaning |
|---|---|
{ ok: true, etag } | The host took the bytes. etag is the stamp it answered with, or null when it named none, and this tab holds that stamp from here on. |
{ ok: false, code: "conflict" } | HTTP 412: the file was no longer the version named. Nothing was written. |
{ ok: false, code: "forbidden" } | HTTP 401 or 403. |
{ ok: false, code: "unsupported" } | The host does not announce conditional, so nothing was sent. |
{ ok: false, code: "failed" } | Anything else: another status, a dropped connection, the 30-second timeout, or an html argument that is not a non-empty string. |
Save receipts and timeout recovery#
Every time ClayJS sends a save, it generates a fresh, opaque 128-bit identifier and sends it in the Save-ID request header. ClayJS sends Save-ID to every host and remembers the last 8 Save-ID values sent by the current tab.
A receipt is the host’s record of the Save-ID that wrote the current version of the document.
Why receipts matter when a save times out#
Suppose your tab holds ETag "v1" and sends a save with If-Match: "v1" and Save-ID: "attempt-1". The host writes the file and updates its ETag to "v2", but the connection drops or the 12-second timeout expires before the 200 response reaches your browser.
Without a receipt, your tab still believes the file is at "v1". If your tab retries with If-Match: "v1", the host sees "v2" on disk and rejects your retry with a false 412 conflict against your own save. Conversely, if your tab blindly adopted "v2", it could overwrite a collaborator’s save that landed during the timeout.
With receipts, ClayJS resolves the ambiguity safely:
- Check the receipt on
/_/meta: When a save times out, ClayJS queries/_/meta(and if the host announcessave-history, sendsSave-IDandSave-Request-Hashto inspectdocument.saveOutcome). - Confirmed landed: If
document.saveIdmatches the timed-out attempt’sSave-ID(ordocument.saveOutcomereports"applied"for thatSave-IDand hash), the host has proved that the bytes on disk came from this exact save. ClayJS adopts the newetagand resolves the save as{ ok: true, msg: "Saved", msgType: "success", etag }. - Safe retry under the original
If-Match: If the host reports no receipt or"unknown", ClayJS re-sends the save with the originalIf-Matchand the sameSave-ID. - Late-duplicate
412recovery: If a retry or subsequent save receives a412whose receipt names any of the last 8Save-IDvalues sent by this tab, ClayJS knows the version on disk is its own earlier save. It adopts the refusal’setag, mints a newSave-ID, and re-sends the current document conditionally without showing a conflict notice. If the412names aSave-IDfrom another tab or user, ClayJS raises a normal conflict (afterTimeout: true).