Versions and stages
Turning on versioning snapshots every write of a global into a history table, and nesting a workflow under it adds draft and published stages you move between.
Someone edits the footer at four on a Friday and the wrong link goes live. You want the previous copy back, and next time you want the change to sit in draft until an editor releases it. Both come from one option.
Keeping a history
import { global } from "#questpie/factories";
export const siteSettings = global("site_settings")
.fields(({ f }) => ({ siteName: f.text().required().default("My Site") }))
.options({ versioning: true });Every write now snapshots the row into site_settings_versions, numbered from
one. Two methods work on that history:
const history = await app.globals.site_settings.findVersions({ limit: 10 });
// oldest first, each entry carries the field values of that snapshot
// plus versionId, versionNumber, versionOperation, versionUserId,
// versionCreatedAt and sourceRevision
await app.globals.site_settings.revertToVersion({ version: 3 });revertToVersion() also takes { versionId }. It needs one of the two and
throws 400 without either. findVersions() enforces the same read rule as
get(), and returns an empty array when versioning is off.
The server type for findVersions() declares the metadata only, so TypeScript
will not show you the field values the query does return. The client's type
carries them.
History is capped
maxVersions defaults to 50. Older snapshots are deleted as new ones land.
Raise it with versioning: { maxVersions: 200 }.
Draft and published
Nest a workflow under versioning. Stages are stored as version snapshots, so
they cannot exist without it.
.options({ versioning: { workflow: true } }) // stages: draft, publishedWrites land on the initial stage, draft. Move the global forward on its own,
without touching the data:
await app.globals.site_settings.transitionStage({ stage: "published" });
const live = await app.globals.site_settings.get({ stage: "published" });Reading a stage other than the initial one reads the newest snapshot carrying
that stage, so get({ stage: "published" }) returns null until a snapshot
lands there. Reading without a stage returns the live row.
Custom stages
.options({
versioning: {
workflow: {
stages: ["draft", "review", "published"],
initialStage: "draft",
},
},
})stages also takes a keyed object, { draft: { transitions: ["review"] }, … },
where transitions lists the stages you may move to next. Leave transitions
off and any stage can follow any other. An initialStage or a transition target
outside stages throws the first time the global is used, not at boot. Asking
for an unknown stage at runtime, or making a move transitions forbids, throws
400.
What a workflow adds
| Thing | Effect |
|---|---|
transitionStage() | Moves stage and writes a snapshot, no data change |
beforeTransition afterTransition | Both see fromStage and toStage. Only the first aborts on throw, the second is logged |
access.transition | Its own rule, falling back to access.update |
stage on get() and update() | Which snapshot to read, which stage to write to |
Pass scheduledAt with a future date and the transition is queued instead of
run, after the target stage, the record, the access rule and the transition
graph have all been checked. This needs a queue adapter and throws without one.
Optimistic concurrency
optimisticConcurrency: true adds a framework-owned revision column and makes
every write name the revision it expects.
.options({ versioning: true, optimisticConcurrency: true })await app.globals.site_settings.update({
data: { siteName: "QUESTPIE" },
expectedRevision: 1,
});The patch moves inside a data key, and expectedRevision sits beside it.
revertToVersion() and transitionStage() take it too. A mismatch throws
409. An absent row counts as revision 0, and the row starts at 1 once it
exists. You cannot declare a field called revision, the constructor throws.
Full behaviour is on Optimistic concurrency.
Switching versioning off breaks a workflow
versioning: { enabled: false, workflow: true } fails when the app builds the
global. Stages are version snapshots, so switching versioning off removes the
thing they are stored in.
The tables
<name>_versions holds versionId as its primary key, the source row's id,
versionNumber, sourceRevision, versionOperation, versionStage,
versionFromStage, versionUserId, versionCreatedAt, and a copy of every
unlocalized field plus the source row's timestamps. It is indexed on
(id, version_number),
(id, version_stage, version_number) and version_created_at.
Localized fields go to <name>_i18n_versions, one row per locale per version,
unique on (parent_id, version_number, locale).
Related
- Globals is the page this one hangs off.
- One row per tenant carries
scope_idinto the versions table too.
Optimistic concurrency
Two people editing one row is a race. Turn this on and every write carries the revision it expects, so the second one is rejected rather than silently overwriting the first.
One row per tenant
A scoped global keeps one row per city, property or customer behind a single definition, and picks the right one from the request context rather than from an argument you pass.