Fabric.js backend storage is one object with two functions. Below is a full adapter for a REST API, and the server logic that keeps saving safe.
The rules
- Reject a save when the stored revision is not
expectedRevision. Throw an error withcode: "SAVE_CONFLICT", or usecreateConflictError. - Skip that revision check when
expectedRevisionisnull. The user chose to overwrite. - Throw an error with
retryable: falsewhen trying again cannot help, such as for a permission error. - Pass
signaltofetch, so a save is cancelled when another document is opened.
The adapter
import type { DocumentStorage } from "fabricjs-document-engine";
export const restStorage: DocumentStorage = {
async loadDocument(id) {
const response = await fetch(`/api/documents/${encodeURIComponent(id)}`);
if (response.status === 404) {
throw Object.assign(new Error("Not found"), {
code: "DOCUMENT_NOT_FOUND",
});
}
if (!response.ok) throw new Error(`Loading failed with ${response.status}`);
return response.json();
},
async saveDocument(document, { expectedRevision, signal }) {
const response = await fetch(
`/api/documents/${encodeURIComponent(document.id)}`,
{
method: "PUT",
signal,
headers: {
"Content-Type": "application/json",
"If-Match":
expectedRevision === null ? "*" : String(expectedRevision),
},
body: JSON.stringify(document),
}
);
if (response.status === 409 || response.status === 412) {
throw Object.assign(new Error("Someone else saved this document"), {
code: "SAVE_CONFLICT",
});
}
if ([401, 403, 422].includes(response.status)) {
throw Object.assign(new Error(`Save refused with ${response.status}`), {
retryable: false,
});
}
if (!response.ok) throw new Error(`Save failed with ${response.status}`);
const { revision } = (await response.json()) as { revision: number };
return { revision };
},
};document.revision is the next revision. If your backend assigns its own number, return it as { revision }.
The server
Run the revision check and the write as one step, so two saves can never both pass. In SQL:
UPDATE documents
SET body = $1, revision = revision + 1
WHERE id = $2 AND ($3::int IS NULL OR revision = $3)
RETURNING revision;If no row comes back, answer 409. Checking first and writing second, in two queries, lets a second save slip in between.
Image upload while saving
Images that exist only in the user's tab are uploaded once, and the document stores the returned URL:
const engine = createDocumentEngine({
canvas,
storage: restStorage,
assets: {
upload: async ({ blob }) => {
const body = new FormData();
body.append("file", blob);
const response = await fetch("/api/uploads", { method: "POST", body });
const { url } = (await response.json()) as { url: string };
return url;
},
},
});Versions
To keep versions in your Fabric.js backend storage, add four methods to the adapter: saveVersion(version), listVersions(documentId), loadVersion(documentId, versionId) and deleteVersion(documentId, versionId). listVersions returns summaries without the document field, newest first.
When the adapter is done, run the adapter check.