Skip to main content

Artifacts, pointers and rollback

This page holds the reasoning behind the output layer of @nubbin/core: what an artifact guarantees, what an ArtifactStore implementation owes its callers, and how the compatibility and rollback checks read drift. The declarations themselves — every member, parameter, return and throw — are generated from the source that defines them, in the generated reference, and nothing here repeats one. Why artifacts are immutable and addressed by content is Artifacts are immutable and content-addressed; what they may contain is Artifacts contain data, never code.

Artifact

An Artifact is one document version compiled for one route, and its hash is its identity rather than a checksum carried beside it. The address is computed over every other field — the tree, the metadata, the route, the recorded block versions and the compiling @nubbin/core version — with object keys sorted first, so two compiles of the same content land at the same address whatever order they happened to build it in. Nothing sits outside the address, so a change anywhere is a different artifact rather than the same one edited.

blockVersions records the blocks the document uses and no others. A route loads what its artifact lists, so naming unused blocks would load them too — and every name recorded there is a name checkRollback later holds the registry to.

ArtifactNode and Holes

An ArtifactNode is fully resolved: slots hold nested nodes rather than ids, so rendering needs no lookups and no dangling reference is possible. props hold only frozen literal values; each entry in Holes records a field the renderer resolves instead, keyed by schema path and carrying the FieldHintData that says how — see Holes: what a data hint compiles to. How a hole's value is sourced at render is per-adapter. The authorization model for connecting a field to a data source remains open.

checkRollback

checkRollback compares what the artifact was compiled against — its blockVersions — with the registry live now, before a pointer is moved back to it. It returns rather than throws: the caller decides whether drift blocks the rollback. The model is Rollback.

Derived from packages/core/src/checkRollback.test.ts:

import { checkRollback, createRegistry, defineBlock } from "@nubbin/core";
import type { Artifact } from "@nubbin/core";
import { z } from "zod";

const heroAtV2 = defineBlock({
name: "Hero",
schema: z.object({ title: z.string() }),
component: null,
version: 2,
slots: {},
});

const artifact: Artifact = {
hash: "h",
route: "/dispatches",
documentId: "promotions-summer",
documentVersion: 1,
blockVersions: { Hero: 1 },
tree: [],
meta: { title: "Summer promotion" },
compiledWith: "0.0.0",
};

checkRollback(artifact, createRegistry([heroAtV2]));
// { compatible: false, drifted: ["Hero"] }

drifted names every block whose registered version differs from the recorded one — and a block the registry no longer holds at all counts as drift, because a deleted block is exactly the failure a rollback must be warned about.

checkCompatibility

checkCompatibility runs the same comparison over every pointer instead of one artifact, and reports it with the version delta a reader needs to act: which route, which artifact, which block, what the page was compiled against, and what is registered now. A block the registry has lost reads as registered: null. A pointer whose hash the store cannot resolve is incompatible on its own, since that route is broken with no registry change involved.

It takes the pointers and artifacts rather than the store, because passing an ArtifactStore would put IO inside the package whose portability is the point, and would exclude any consumer whose live state does not sit behind that interface. Reading is three lines and belongs to the caller:

const { routes } = await store.manifest();
const live = await Promise.all(
routes.map(async (pointer) => ({ pointer, artifact: await store.read(pointer.hash) })),
);
const report = checkCompatibility(live, registry);

checked is on the report because a run that read no pointers is compatible with everything. A caller asserts on it before trusting the verdict, or a store the check never reached reads as a pass.

formatCompatibilityReport

formatCompatibilityReport renders the report as a log reads it, leading with the count in both directions:

2 of 8 live route pointer(s) are incompatible with this registry:
/ (artifact 4a162726)
Hero: page needs v1, registry has v2
CardGrid: page needs v1, no longer in the registry
/about (artifact 8be9f4fd)
Hero: page needs v1, registry has v2

ArtifactStore

ArtifactStore is the output layer's whole IO surface. core only returns values for it; adapters implement it — @nubbin/store-fs is the reference implementation, and every implementation is proven against one shared suite:

import { runArtifactStoreContract } from "@nubbin/store-fs/testing";

runArtifactStoreContract("postgres", async () => createPostgresArtifactStore(await freshSchema()));

It runs under vitest, which @nubbin/store-fs declares as an optional peer — installing the store needs nothing, and running its contract needs the runner you already have. A store that implements no history is not failed for it; those tests skip. The behaviour the suite pins:

GuaranteeMeaning
read of an unknown hash is nullAbsence is a value, never a throw
write of an already-stored hash is a no-opArtifacts are content-addressed, so rewriting identical content changes nothing
publish parses matchKind from the routeVia parseMatchKind — callers never supply it
publish of a hash that was never written rejectsNo pointer may dangle
publish of the same route and hash twice is a no-opRepublishing is safe
unpublish removes the pointer and keeps the artifactUnpublishing takes a route offline; it destroys nothing, and a missing pointer is tolerated
history, where implemented, lists the route's moves oldest firstOnly published states appear; unpublish erases none of it; republishing the same hash records a second move — content addressing dedupes artifacts, not moves

The ordering is the caller's to keep: write the artifact, then point the route at its hash. A pointer published at a hash nothing was written for is a live 404, which is why an implementation rejects one rather than storing it.

Publishing never mutates an artifact — it writes a new one and moves the route's pointer, which is what makes cache invalidation at the store unnecessary.

RoutePointer and Manifest

The pointer is the single piece of output-layer state that changes in place — each route owns its own record, naming the artifact currently live there. Why per-route pointers beat one mutable manifest is Route pointer. Manifest is the advisory aggregation over every pointer — a route list for an editing surface or CI, derived rather than authoritative.

PointerMove

A PointerMove is one publish at a route, as the optional history(route) hands it back. history is optional because a write-only blob store is still a valid adapter — a caller that needs it degrades with a message rather than assuming it. Why the record lives beside the pointer instead of inside it, and is appended rather than rewritten, is A route remembers what it pointed at.

parseMatchKind

parseMatchKind derives a pointer's matchKind from its route. It lives in core so every adapter shares one implementation — a second parser is free to disagree. It judges the route before classifying it, and that is the last point before a pointer is written: an adapter that never called compile still cannot publish an unaddressable route.