Skip to content

Latest commit

 

History

History
184 lines (110 loc) · 27.6 KB

versioning.md

File metadata and controls

184 lines (110 loc) · 27.6 KB

Versioning & Deletion

Version history is managed by the library as a side channel — apps do not manage it directly. It is one of the two durable tiers a mutation writes; the other is the change journal, which records what each change moved rather than what a rollback could put back.

Version history

Two kinds of write. Every mutation of a Record that touches content — a change set naming contentPatch, a soft delete, an undelete, a migration commit, a restore — snapshots the Record's prior full state and bumps version exactly once. A mutation that changes nothing (restating the content a Record already holds, deleting an already-deleted Record) is a no-op: no bump, no snapshot. Purge is the one exception among these — it destroys the Record and its version history outright, so there's nothing to snapshot.

Everything else a Record carries — containment, listing, associations and permissions alike — bumps nothing and snapshots nothing. Those are the no-bump writes, spelled out below.

version is the ordinal of the record's snapshot history, not a count of its changes. It advances exactly when a write destroys state that only a snapshot preserves. Every other change is counted by the journal's seq, which is dense over all of them. A record's version tells you how many recoverable states it has; seq tells you how many times it has been touched.

The field is not a content hash under another name: a restore creates a new version holding old content, so two versions can be byte-identical, and ifVersion/If-Match guard the whole Record rather than its content.

The no-bump writes: four aspects that never bump. associate()/dissociate()/amendAssociations(), grantAccess()/revokeAccess()/amendAccess() — and a change set naming only associations, permissions, parentId and/or unlisted — never bump version, touch updatedAt, or snapshot. Each of those four aspects is a write whose prior state the change journal keeps in full, so a snapshot would preserve nothing a reader cannot already reach: a move's origin is the entry's previousParentId, a listing transition's inverse is the unlist/list op's own opposite, and an association delta — the authority half included — is the entry's associations. That is the whole reason version history exists — recoverability for a write that can't otherwise be undone — and none of the four needs it.

Filing them here anyway would cost a full duplicate copy of content apiece for state the entry already holds, and in unlisted's case would bump version while storing nothing about the aspect that caused the bump.

A change set is atomic, not a version batch (see Data model § Mutations): one that names any of the four alongside contentPatch still produces exactly one version covering everything it moved; they just aren't among the aspects that decide whether the call bumps at all. The same completeness is why a restore leaves all four where they stand and why the no-bump verbs take no ifVersion — see Restore semantics and Optimistic concurrency.

Every mutating method answers with the Record it produced. mutate(), patchContent(), associate(), dissociate(), amendAssociations(), grantAccess(), revokeAccess(), amendAccess(), undelete(), restoreVersion() and commitMigration() all return the Record as it now stands, so a caller can report what it just wrote without a second read — the same body their wire endpoints answer with, rather than a client that discards it. A no-op returns the Record unchanged: what distinguishes it is the version that didn't move, not an answer that never came. delete() returns a DeleteResult rather than a Record, because it is the one verb with a variant that has no Record to return — a purge leaves no Record and no version behind (a soft delete's tombstone is read back with get(id, { includeDeleted: true })). deleteAndReturn() is the call for a caller that needs the tombstone or the purged body; see Attachments § A purge strands the bytes it referenced.

type RecordVersion = {
  version: number;
  typeId: string; // The Record's typeId at the moment this version was snapshotted
  content: object;
  updatedAt: Date;
  createdBy?: Actor; // The Record's author, carried through from the snapshot
  updatedBy?: Actor; // Who performed the mutation that produced this version
};

A snapshot carries only what a snapshot preserves. content, the typeId it is read under, and the actors. There is no parentId, no unlistedAt, no associations and no permissions: none of those aspects bumps version, so no version is ever taken of one, and a server that emits any of them is writing a key every client drops.

typeId makes a version entry interpretable regardless of migration state: a snapshot taken before a migration records the pre-migration type, so restoring it later doesn't mislabel @1-shaped content as @2. It's also what lets _grant baseId matching work independent of which version a snapshot predates.

createdBy on a version is authorship; updatedBy is attribution of the change. createdBy is the Record's own author copied into the snapshot, and it never moves — so every version reports the same author. updatedBy is the requester that produced that version, naming the principal beside it under delegation. The two agree at version 1 and diverge whenever anyone but the author writes. See Data model § Authorship and attribution.

API surface:

  • stack.getVersions(recordId, query?) — retrieve version history, newest first on every adapter (the same order GET /records/:id/versions answers in), so the restore point just before the current version comes first and a page of history starts at the useful end. getJournal() runs the other way: an append-only log tailed forward from a cursor reads oldest first.

    query is a VersionsQuery — beforeVersion (exclusive: versions strictly older than it) and limit — in the shape of the journal's afterSeq and limit. Omitting both reads every version, as omitting them on getJournal() reads the whole log. Paging walks every version exactly once: send the last version of a page as the next beforeVersion.

  • stack.restoreVersion(recordId, version, opts?) — revert to a prior version. Restores content and typeId, and restores nothing else — see Restore semantics. The snapshot deliberately does not capture appId either, so restore never reverts an app reattribution.

Snapshot atomicity

A snapshot is written as part of the same atomic write as the mutation it precedes, never as a separate call before it. Adapters accept the snapshot as an option on every mutating method and fold the insert into their own transaction; Stack builds it from the record it has already read and passes it through. A standalone saveVersion() exists for tooling, but no mutation path uses it — and it is a deliberate no-op over APIAdapter, where the server is the only snapshot writer.

The reason is recoverability of the history mechanism itself. Were the snapshot a separate call preceding the mutation, a crash in between would leave a versions row at the record's own current version — a version number no legitimate snapshot can carry, since a snapshot is written in the same breath as the bump past it. That orphan would then collide with every future mutation's snapshot attempt, permanently blocking writes to the record.

Adapters therefore treat a (recordId, version) collision two ways:

  • Colliding row below the record's current version — a genuine conflict: two writers read the same stale version and raced to snapshot it. The loser is rejected with StackConflictError before its mutation applies. Never silently discarded, which would leave a hole in rollback history exactly where a conflict happened.
  • Colliding row at the record's current version — an orphan from an interrupted write, impossible to produce under atomic snapshotting. The row is overwritten and the mutation proceeds, healing the record.

The distinction is a pure version-number comparison against the live record, never a comparison of snapshot content. The tempting "same payload ⇒ treat as success" shortcut is unsound: it would let two genuinely concurrent writers both proceed and corrupt history.

History access

History is the mutation/recovery surface, not a read surface. Under ScopedStack, getVersions()/getVersion() require the same access patchContent()/associate() require — a write-holder, or the owner/creator (or a Group's admin, for a _group's own history) — not plain read access. Gating history on current read access would make a Record's entire past exactly as public as its present: share a Record after editing out something sensitive, and every current reader would see the pre-edit revisions too, as an automatic side effect of an ACL that only describes now. Requiring the mutate surface instead ties history access to "can undo," the same justification the write bit rests on. History is deliberately not time-sliced per reader (a contributor added today sees the same history a contributor added last year would, including pre-their-involvement content); ACLs aren't per-version timestamped, and building that slicing is out of scope. That is exactly why the rule only runs one way: history is narrower than read, never wider, which write implies read is what closes. A denied requester gets StackPermissionError, matching every other write-gated verb. "The same access" means the same permission and grant resolution, not the owner-only fences a few families put on mutation: a _grant Record is owner-write-only, and its history is still readable by a write-holder, since reading it changes nothing and a write-holder who cannot audit the grant they hold has lost the recovery this gate exists to protect.

Every requester who passes that gate sees the same RecordVersion rows. A snapshot carries content, its type, and an actor the live Record already names — nothing a requester holding the mutate surface is denied — so there is no per-requester projection of a version.

A Record's sharing history is the journal's to tell, and it is the one thing history does project per requester: the journal names that a permission set moved to everyone who passes this gate, and who it moved to only to a requester who may reshare the Record. A write-holder never needs the ACL trail to undo content, and a Record's grantees — current and past — are not something delegation is a route to.

Publishing history is a projection, not a history read. An app that wants to expose a Record's revisions publicly (e.g. a static-site generator's changelog) does not do so by relaxing the rule above — it materializes the chosen revisions as first-class, app-defined Records (their own content, their own permissions, decoupled from RecordVersion's audit shape), built at the point the app decides to publish. That's a deliberate, curatable act — the embarrassing draft or the edited-out paragraph never leaks unless the owner chooses to include it.

Restore semantics

Restore always creates a new version with the old content and typeId — it never rewrites history. The act of restoring is itself part of the version history.

Snapshot content is validated against the type it claims (typeId as stored in the snapshot), not the Record's current type — a snapshot taken before a migration is @1-shaped, and validating it against a since-migrated @2 schema would wrongly reject a legitimate restore. A snapshot that fails validation against its own claimed type — in-place schema drift, or a corrupted/buggy adapter — throws StackValidationError instead of being written back; restore is a recovery path, not a backdoor around content validation.

Restoring a pre-migration snapshot therefore also restores its old typeId, leaving the Record legitimately stale rather than mislabeled. No forward-migration happens at restore time — migrations are app code (see Type migrations), so restore behaves the same locally as through the server-side restore endpoint, which cannot run them either. A stale restored Record self-heals the same way any other stale Record does: on the owning app's next migrateAll() sweep.

A restore never moves the Record. Containment is the journal's to keep, not the snapshot's, so a restore settles nothing about it: the Record comes back in whatever container it is in now, whichever one it sat in when the snapshot was taken. Undoing a move is a change set's parentId, reading the entry's previousParentId for where it came from. That is what keeps a restore off every containment rule: it adds no edge, so there is no ancestor chain to walk and no destination to gate, and a container purged since the snapshot was taken costs the Record nothing. Listing state is settled the same way — which is to say not at all. A restore never lists or unlists a Record; unlist/list is a change set's unlisted.

A restore can put back a reference to bytes that are gone. Version history is not a reference, so a file deleted while a snapshot names it, and then restored, leaves a dangling file reference — the only dangling reference a restore can produce. That is the stance restore takes everywhere: history is put back, not re-litigated, the same way it honors the snapshot's own typeId rather than validating against the current one. Content's file refs are the only reference a restore can create at all, which is why they are the only thing the gate below asks about.

A restore never rolls back any Record's associations, _group or otherwise. content rolls back; a Record's associations are left exactly as they stand — there is no snapshot's list to put back, since snapshots don't capture associations in the first place, and the current ones are not taken away either. Associations are invertible, so they never needed the recovery a rollback offers content (see Version history). On a _group a rollback would be positively harmful: a Group's member and admin entries are what group ACLs and group-targeted grants resolve against, so putting an old roster back would silently re-grant access — management to an admin who had been deliberately removed, reach to a member who had been dropped — as a side effect of a verb the caller asked for its content. A roster is authority, not data. Recovering a former roster — or any other association set — is a deliberate associate(), which is the point.

It is also what lets the at-least-one-admin invariant hold without a check here: StackRecordAdapter.restoreVersion() never receives an association list to write, so no version of a _group is ever unrestorable on that ground.

restoreVersion() re-runs reference-creation checks against the snapshot, for non-owner requesters. Restoring re-attaches whatever file-ref content fields the target snapshot carried. For a write-holder who is not the owner, that could re-convey access to a file they can no longer reach today — the reference was legitimate when created, but access has since moved on. ScopedStack.restoreVersion() closes this by applying the same reference-creation gating associate()/create() apply, against the snapshot's file-ref fields: a file-ref field the requester couldn't currently attach fresh is rejected (StackPermissionError). There is no containment or associations case to gate: a snapshot carries neither, so a restore introduces neither. The owner acting as itself is exempt, per the same recoverability principle that exempts them from every other write-path gate — but an owner principal acting for someone else is not, since the gate resolves against the subject and it is the subject's reach a restore would widen.

Restoring an _app or _entity Record is fenced for the same reason, by the rules that govern their binding fields directly: a card's did (and, on _app, its appId) is immutable once set, so a rollback that would move or clear one is refused no matter which verb reaches for it — see Identity § DID bindings. Content rollback is a route to a write, not an exemption from what that write is allowed to be.

Optimistic concurrency (ifVersion)

version is not conflict detection by itself — it exists to power rollback and soft-delete recovery. Nothing reads or writes it unless a caller opts in. Every version-bumping mutating method (mutate, patchContent, delete, undelete, restoreVersion, commitMigration) accepts an optional ifVersion:

await stack.patchContent(id, { title: 'New' }, { ifVersion: 5 });
// throws StackVersionConflictError if the record's current version ≠ 5, and changes nothing
  • Omitting ifVersion keeps last-writer-wins — the unconditional default. Apps that don't care about races don't pay for this.
  • On a mismatch, the call throws StackVersionConflictError carrying recordId, expectedVersion, and actualVersion, so a caller can re-fetch, inspect what actually won the race, and decide whether to retry. It is a distinct error type from StackConflictError — the two have different recovery stories (fix your input vs. retry after re-reading) and different HTTP statuses.
  • The check is atomic at the adapter, not a read-then-write in Stack: adapters implement it as part of the same write (e.g. UPDATE ... WHERE id = ? AND version = ?, inspecting the affected-row count). Doing the check in Stack alone would just move the race down a layer.
  • This covers every version-bumping mutation path: a lost race on a delete, undelete, restore or migration commit is caught exactly like a lost race on content.
  • StackRecordAdapter takes the same ifVersion option (IfVersionOptions) on the same methods — one name from app code through the adapter to the wire.
  • Over the wire, this is the If-Match header (see Wire format § Records) — local and remote behave identically.

associate()/dissociate()/amendAssociations() and grantAccess()/revokeAccess()/amendAccess() take no ifVersion. They never bump version, so there is nothing for a precondition on it to guard, and they can't offer one that would silently do nothing. This isn't a gap: each is a set add/remove that composes correctly regardless of order, unlike arbitrary content patches — the strong OCC guarantee content needs isn't needed here. mutate() follows the same line, read off the keys a change set names rather than what it turns out to move. A set naming only the no-bump keys — associations, permissions, parentId and/or unlisted — carries no precondition either, for the same reason: none of them moves the number a precondition would name, so an ifVersion passed alongside one is not checked. Name any other aspect and ifVersion guards the whole call, the same as any other change set — including where that aspect restates what the record already holds and the call writes nothing: a precondition is a claim about the record's version, not about what the write moved, so a caller holding a stale one is told so rather than handed a silent success.

Collisions are never silently dropped. Two writers racing past the same version — without ifVersion, or through a server race that outpaces it — can still collide on the same snapshot version number. Adapters reject the loser with StackConflictError rather than discarding its snapshot; see Snapshot atomicity.

Storage per adapter

  • JSON (planned): sibling files {id}.versions.json and {id}.journal.json
  • SQLite: versions and journal tables
  • API: the server is the only writer of both tiers — saveVersion() is a deliberate no-op over APIAdapter and a journal entry travels on no request, so a server implementing anything less than the full list of version-bumping endpoints silently loses rollback history for the ones it skipped. Both tiers are read back over the wire: Wire format § Versions for that list, and § Journal for the endpoint serving this one.

Deletion

Records are never purged by default. Two levels of deletion are supported:

Soft delete — the default. A deleted Record is flagged with a deletedAt timestamp and excluded from normal queries, but remains recoverable. Version history is preserved. A soft-deleted Record is a tombstone — its current state is gone but its history is not. A query opts back in with:

stack.query({ filter: { includeDeleted: true } });
stack.get(id, { includeDeleted: true });

get() follows the same rule as query(): a soft-deleted Record is hidden by default, and get(id) on one answers null — "not here", as far as a normal read is concerned. Any reader may pass includeDeleted; the Record's own permissions still decide whether they see it, so the flag discloses nothing. Writes stay explicit: mutating a tombstone throws, because a refusal that explains itself beats a null that does not.

The tombstone is literal

Under ScopedStack, a soft-deleted Record is presented as a tombstone rather than merely described as one. get(id, { includeDeleted: true }) and query({ filter: { includeDeleted: true } }) return:

{
  id, typeId, createdAt, updatedAt, version,
  deletedAt,        // what makes it a tombstone
  permissions,      // retained — see below
  content: {},      // empty: the current state is what a soft delete removes
}

associations, parentId and the authorship fields (createdBy, appId, updatedBy) are absent. The change feed carries the same projection, so no channel serves more of a deleted Record than a fetch by ID does.

permissions is retained because it is what decides whether the caller may undelete(). A write-holder who could not see the bit would have to attempt the verb to discover it. It discloses nothing further: a tombstone only ever reaches a requester who passed the same read check the live Record required, and one who fails it gets null, exactly as a missing ID gives.

It applies to every requester, the owner included. A Record's state is a property of the Record, not of who is asking — the owner wanting the content back reads history, or holds the unscoped Stack, which is trusted by definition and projects nothing.

History is deliberately exempt. getVersions() still serves the content the tombstone withholds, because reviewing what a Record held is how a caller decides whether to restore it. "Its current state is gone" is the whole of the claim; the history sentence beside it is the other half of the same rule, not an oversight.

Mutations are refused, not applied to a tombstone

A soft-deleted Record has no current state to edit, so mutate(), patchContent(), associate(), dissociate(), amendAssociations(), grantAccess(), revokeAccess(), amendAccess() and restoreVersion() throw StackConflictError (409) on Stack and ScopedStack alike. The rule is about the Record's state, not the surface the call came through, so it is enforced in Stack and every surface inherits it. A change set is refused whole, whichever of its keys would otherwise have applied. undelete() is the way back, and it is deliberately not gated this way.

The refusal is asked after the authority decision, never before: ScopedStack authorizes first and only then calls into Stack. It names a state, so a requester who may not read the Record must still hear what a missing ID sounds like — otherwise "exists but deleted" becomes a probe a stranger can run against guessed IDs, the same information-exposure rule that governs every other refusal.

commitMigration() is exempt: migration deliberately sweeps soft-deleted Records so one can come back current on undelete (see Undelete), and it is owner-acting-alone, save for an installed app within its own families.

Purge

Purge — permanent and explicit. Removes the Record, all its version history and its journal. Requires deliberate intent via a flag. The escape hatch for sensitive, secret, or harmful content the Record itself holds — see the caveat below for what it does not reach.

stack.delete(recordId); // soft delete — reversible
stack.delete(recordId, { purge: true }); // purge — permanent

It does not reach attachment bytes, and erasing those is a second step. delete() reports the files the purged Record referenced as referencedFileIds, and the caller passes each to deleteAttachment(); a purge for harmful content that skips that step leaves the bytes reachable to whoever can already name the hash. See Attachments § A purge strands the bytes it referenced.

Purge is owner-only under ScopedStack. Neither the record-level write bit nor delete-own/delete-any grants reach it — a non-owner requesting { purge: true } gets StackPermissionError, regardless of what would otherwise authorize a delete. It's irreversible and destroys version history, so it stays outside every delegated-access vocabulary. Non-owners are always limited to soft delete. (Plain Stack is unscoped and trusted-by-definition, so this restriction applies only to ScopedStack.)

Undelete

Undelete reverses a soft delete. It's idempotent — calling it on a Record that isn't deleted succeeds and returns the Record unchanged, so a retried call after a network blip never fails. A missing Record throws StackNotFoundError; a purged Record is simply missing, so it throws the same way.

const record = await stack.undelete(recordId); // clears deletedAt, returns the record

Under ScopedStack, undelete() is gated the same way as delete() — the write bit or a delete-own/delete-any grant. Undelete is the inverse of soft delete, so the same capability governs both directions; granting one without the other would be backwards. (Purge's owner-only carve-out is unaffected — it has no inverse.)

Undelete does not re-run migrations. If a soft-deleted Record's schema fell behind while it was deleted, it comes back stale — a legal state, self-healing on the owning app's next migrateAll() sweep (a content patch never migrates; see Type migrations). migrateAll() includes soft-deleted Records in its sweep, so a Record can be migrated while deleted and come back current on undelete.

unlistedAt is a sibling mechanism, not a variant of this one. It withholds a Record from enumeration rather than from access — the Record stays fully readable and mutable throughout — and its own opt-in flag, ownership rule, and feed behavior differ from soft delete's in ways worth reading directly rather than assuming symmetric. See Unlisted records.