clay.js
  1. ClayJS
  2. Reference
  3. Hosts and protocols
  4. 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:

HTTP
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:

  1. The documentetag root attribute: Stamped on <html> by the host when serving the page, read once at boot, and stripped before every save.
  2. Discovery (/_/meta): If the served HTML carried no documentetag, ClayJS seeds the stamp from document.etag in the /_/meta response.
  3. Accepted saves: Every 2xx save response updates the held stamp to result.etag (or clears the held stamp if the response omits etag).
  4. Disk sync frames: When live sync applies a disk frame, 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:

  1. msgType: "conflict": clay.save() resolves with ok: false, msgType: "conflict", code: "conflict", and the refusal metadata (conflictEtag, changedBy, afterTimeout, hadTimeout, reason, retryAfter).
  2. Root attribute and event: ClayJS sets <html savestatus="conflict"> and dispatches clay:save-conflict on document with detail: { msg, msgType, timestamp, changedBy, afterTimeout, etag }, where etag is the conflictEtag the host refused the save against.
  3. 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.
  4. The conflict notice: ClayJS shows a built-in conflict bar with three actions:
    • Keep mine: Calls clay.save.overwrite(), which fetches the host’s current etag from /_/meta and runs clay.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.

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:

okmsgTypeMeaning
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 and etag is the last seen stamp.
    • state: "saved": This call wrote the current bytes to the host and etag is the new stamp.
  • Rejects with an Error carrying err.state:
    • err.state === "failed": The save failed or timeoutMs (default 15000 ms) elapsed.
    • err.state === "conflict": The page is in an unresolved 412 conflict. flush never overwrites a conflict on its own; overwriting always requires clay.save.overwrite().
    • err.state === "blocked": The DOM bytes are saved, but a check registered through clay.registerUnsavedState reports 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 beforeSave and 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, and etag becomes If-Match. With no etag the tab’s held stamp is used, and a tab that has never saved seeds one from /_/meta first.
  • A host that never announced conditional gets no request at all. Without the capability If-Match means nothing, so the answer is unsupported without a write.
  • One answer, and it never rejects:
ResolvesMeaning
{ 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:

  1. Check the receipt on /_/meta: When a save times out, ClayJS queries /_/meta (and if the host announces save-history, sends Save-ID and Save-Request-Hash to inspect document.saveOutcome).
  2. Confirmed landed: If document.saveId matches the timed-out attempt’s Save-ID (or document.saveOutcome reports "applied" for that Save-ID and hash), the host has proved that the bytes on disk came from this exact save. ClayJS adopts the new etag and resolves the save as { ok: true, msg: "Saved", msgType: "success", etag }.
  3. Safe retry under the original If-Match: If the host reports no receipt or "unknown", ClayJS re-sends the save with the original If-Match and the same Save-ID.
  4. Late-duplicate 412 recovery: If a retry or subsequent save receives a 412 whose receipt names any of the last 8 Save-ID values sent by this tab, ClayJS knows the version on disk is its own earlier save. It adopts the refusal’s etag, mints a new Save-ID, and re-sends the current document conditionally without showing a conflict notice. If the 412 names a Save-ID from another tab or user, ClayJS raises a normal conflict (afterTimeout: true).