Versions
Numbered snapshots of a subject's content, with JSON Patch diffs, restore, debounced auto snapshots, frozen versions and retention.
versions keeps numbered snapshots of any subject: a document, a quote, a board. Each version holds JSON content (or a Storage path), a SHA-256 content hash, an optional label and where it came from (manual, auto or restore). Reading versions needs versions.read, taking and labeling one needs versions.create, restoring needs versions.restore and deleting needs versions.manage. Every call also checks the subject's own read permission, and a restore checks its write permission from config.subjects.
better-supabase sql add versionsimport { createVersions } from "better-supabase/blocks/versions";
const versions = createVersions({ transport });
const v = await versions
.snapshot({
organizationId,
subjectType: "quote",
subjectId,
label: "Sent to customer",
})
.orThrow();
const patch = await versions.diff(previousId, v.id).orThrow();
await versions.restore(previousId).orThrow();A snapshot whose content matches the latest version is not written again: snapshot returns the latest version with unchanged: true. Versions are numbered per subject from 1.
Configure subjects
Each subject type lists the columns a snapshot copies and a restore writes back. A type with an apply function restores through it instead: the function takes (subject_id text, content jsonb).
subjects: {
quote: { table: "public.quotes", idType: "uuid", permissions: { read: "quotes.read", write: "quotes.update" } },
page: { table: "public.pages", idType: "uuid" },
},
sql: {
modules: {
versions: {
options: {
keep: 50,
debounce: 300,
subjects: {
quote: {
columns: ["title", "lines", "total"],
freeze: { column: "status", values: ["sent", "accepted"] },
},
page: { columns: ["body"], auto: true, apply: "public.apply_page_version" },
},
},
},
},
},| Option | Default | What it does |
|---|---|---|
subjects.<type>.columns | none | The columns a snapshot without content copies, and a restore writes back |
subjects.<type>.apply | none | A function (subject_id text, content jsonb) that restores a version in place of columns |
subjects.<type>.auto | false | Snapshot on every insert or change to columns |
subjects.<type>.freeze | none | { column, values }: when column changes to one of values, keep a frozen version labeled with it |
keep | 50 | How many versions without a label or freeze each subject keeps |
debounce | 300 | Seconds an auto snapshot replaces the previous auto snapshot's content instead of adding a version |
A frozen version can't be relabeled or deleted, so a quote keeps what was sent. A label keeps a version out of retention; label(id, null) removes it.
Diff
diff(fromId, toId) returns the RFC 6902 JSON Patch that turns one version's content into the other's. createPatch(from, to) and applyPatch(document, patch) work on any JSON value, and applyPatch throws a JsonPatchError with the failing operation's index for an operation the RFC calls an error. Object members are compared by name and array elements by position.
Restore
restore(id) writes the version back to the subject through its apply function or columns, then records the result as a new version with source restore. The auto and freeze triggers skip the write a restore makes. A version kept in Storage (storagePath without content) is restored by the app.
Share a version
A share link to a subject can point at one version: keep the version id in the link's metadata and read it with get(id), which returns the content.
Events and hooks
The module records version.created, version.restored and version.deleted, each with organizationId, subjectType and subjectId, so the activity and watcher sinks handle them. It calls before_version_snapshot, after_version_snapshot, before_version_restore and after_version_restore around its writes.
With ReUI
The client has no UI dependency. A history panel lists list(), shows diff() between two rows, and calls restore() from a ReUI timeline or table:
const rows = await versions.list(organizationId, "quote", quoteId).orThrow();
// <Timeline> from ReUI: one item per row, labeled with row.label ?? `Version ${row.number}`Last updated on
Bookings
Bookable resources with weekly hours, services with buffers, open slots computed in SQL, holds that expire, double bookings refused by an exclusion constraint, and public booking through a share link.
Trash
Soft delete through each subject's deletedAt column, with children trashed in one batch, restore with unique checks, a list per organization and purge after a retention period.