← All @molecule/* packages · App templates
@molecule/api-resource-version-historyAPI resource · version-history · API (Node) · v1.0.1 · Apache-2.0
Append-only versions table with diff and restore — generic versioning helper for any resource.
npm install @molecule/api-resource-version-history@molecule/api-resource-version-history is an API resource: the routes, validation and storage for version-history, built on the database and auth cores so it runs on whichever providers your app has bonded.
import {
routes,
requestHandlerMap,
createVersion,
registerOwnershipResolver,
} from '@molecule/api-resource-version-history'
// REQUIRED before mounting the routes: tell version-history how to check
// parent-resource ownership for each resource type you version. Without this
// every read/list/diff/restore fails closed (404) — the routes are never open.
registerOwnershipResolver('document', async ({ resourceId, userId }) => {
const doc = await findById('documents', resourceId)
return doc?.userId === userId
})
// Wire routes via mlcl inject:
// POST /:resourceType/:resourceId/versions
// GET /:resourceType/:resourceId/versions
// GET /:resourceType/:resourceId/versions/count
// GET /:resourceType/:resourceId/versions/:version
// GET /versions/:versionId
// POST /versions/:versionId/restore
// GET /versions/:fromVersionId/diff/:toVersionId
// Or call the service directly from another resource's update handler.
// The acting user ALWAYS comes from the session (res.locals.session) —
// never from the request body:
const userId = (res.locals.session as { userId?: string } | undefined)?.userId
await createVersion({
resourceType: 'document',
resourceId: doc.id,
userId: userId ?? null,
snapshot: doc,
reason: 'autosave',
})Works with: @molecule/api-locales-resource-version-history
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Append-only version history resource for molecule.dev.
Polymorphic, append-only versions table that captures full snapshots of
any resource type, plus a shallow diff against the prior version. Routes
surface list / read / count / diff / restore — there is no UPDATE or
DELETE for individual versions, by design. Restoring a prior version
appends a new version whose snapshot equals the target's; the existing
rows are never mutated.
import {
routes,
requestHandlerMap,
createVersion,
registerOwnershipResolver,
} from '@molecule/api-resource-version-history'
// REQUIRED before mounting the routes: tell version-history how to check
// parent-resource ownership for each resource type you version. Without this
// every read/list/diff/restore fails closed (404) — the routes are never open.
registerOwnershipResolver('document', async ({ resourceId, userId }) => {
const doc = await findById('documents', resourceId)
return doc?.userId === userId
})
// Wire routes via mlcl inject:
// POST /:resourceType/:resourceId/versions
// GET /:resourceType/:resourceId/versions
// GET /:resourceType/:resourceId/versions/count
// GET /:resourceType/:resourceId/versions/:version
// GET /versions/:versionId
// POST /versions/:versionId/restore
// GET /versions/:fromVersionId/diff/:toVersionId
// Or call the service directly from another resource's update handler.
// The acting user ALWAYS comes from the session (res.locals.session) —
// never from the request body:
const userId = (res.locals.session as { userId?: string } | undefined)?.userId
await createVersion({
resourceType: 'document',
resourceId: doc.id,
userId: userId ?? null,
snapshot: doc,
reason: 'autosave',
})
resource
npm install @molecule/api-resource-version-history @molecule/api-database @molecule/api-i18n @molecule/api-locales-resource-version-history @molecule/api-logger @molecule/api-resource zod
CreateVersionInputInput for creating a new version.
interface CreateVersionInput {
/** The type of resource being versioned. */
resourceType: string
/** The ID of the resource being versioned. */
resourceId: string
/** Full snapshot of the resource. */
snapshot: JSONValue
/** ID of the user creating this version, or `null` for system-generated versions. */
userId?: string | null
/** Optional human-readable reason. */
reason?: string | null
}
PaginatedResultA paginated result set.
interface PaginatedResult<T> {
/** The result items for the current page. */
data: T[]
/** Total number of matching items across all pages. */
total: number
/** Maximum number of results per page. */
limit: number
/** Number of results skipped. */
offset: number
}
PaginationOptionsOptions for paginated queries.
interface PaginationOptions {
/** Maximum number of results to return. */
limit?: number
/** Number of results to skip. */
offset?: number
}
VersionA point-in-time snapshot of a resource.
Versions are append-only. A new row is created each time the resource changes; old rows are never mutated.
interface Version {
/** Unique version identifier. */
id: string
/** The type of resource this version belongs to (e.g. `'document'`, `'project'`). */
resourceType: string
/** The ID of the resource this version belongs to. */
resourceId: string
/** The monotonically-increasing version number for this resource (1-based). */
version: number
/** The ID of the user who created this version, or `null` if system-generated. */
userId: string | null
/** Full snapshot of the resource at this point in time. */
snapshot: JSONValue
/** Shallow diff against the previous version, or `null` if this is the first version. */
changes: VersionChanges | null
/** Optional human-readable reason for this version (e.g. commit message). */
reason: string | null
/** When this version was created (ISO 8601). */
createdAt: string
}
VersionDiffResult of comparing two versions of the same resource.
interface VersionDiff {
/** The earlier version. */
from: Version
/** The later version. */
to: Version
/** Per-field changes between `from.snapshot` and `to.snapshot`. */
changes: VersionChanges
}
VersionFieldChangePer-field change record for a single key. before is the previous value,
after is the new value. Either may be undefined for added/removed fields.
interface VersionFieldChange {
/** Previous value (`undefined` if the field was added). */
before?: JSONValue
/** New value (`undefined` if the field was removed). */
after?: JSONValue
}
VersionOwnershipContextThe context an ownership resolver receives to decide access. userId is the
authenticated caller (re-derived from res.locals.session.userId, never
client-supplied).
interface VersionOwnershipContext {
/** The parent resource type (e.g. `'document'`, `'project'`). */
resourceType: string
/** The parent resource id whose versions are being accessed. */
resourceId: string
/** The authenticated caller's user id. */
userId: string
}
JSONValueA JSON-serializable snapshot value. Constrained by what the underlying
JSONB column can accept.
type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }
VersionChangesShallow per-field diff between two snapshots, keyed by field name.
type VersionChanges = Record<string, VersionFieldChange>
VersionOwnershipResolverResolves whether the authenticated caller may read/restore versions of a
given parent resource. Return true to allow, false to deny. May be
async (e.g. it can look the parent resource up in the database).
type VersionOwnershipResolver = (context: VersionOwnershipContext) => boolean | Promise<boolean>
clearOwnershipResolvers()Clears all registered ownership resolvers. Primarily useful in tests.
function clearOwnershipResolvers(): void
create(req, res)Captures a new version of a resource.
Secure by default: returns 401 with no session, and 404 (no existence leak) when the caller is not authorized for the parent resource — a caller can only capture versions of a resource they own, never inject snapshots into another tenant's resource.
function create(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with resourceType / resourceId params and a snapshot body.res — The response object (reads locals.session/locals.versionHistoryAdmin).createVersion(input)Captures a new version of a resource.
Reads the latest existing version for (resourceType, resourceId) to
compute the next version number and the shallow diff against the prior
snapshot. The resulting row is append-only.
function createVersion(input: CreateVersionInput): Promise<Version>
input — The version creation input.Returns: The created version.
deleteVersionsForResource(resourceType, resourceId)Deletes every version for a resource. Intended for cleanup when the parent resource itself is deleted; never call from user-facing handlers without authorization checks.
function deleteVersionsForResource(resourceType: string, resourceId: string): Promise<number>
resourceType — The resource type.resourceId — The resource ID.Returns: The number of versions deleted.
diff(req, res)Returns the shallow diff between two versions of the same resource.
Secure by default: returns 401 with no session, and 404 (no existence leak) when either version is missing OR the caller is not authorized for the parent resource the two versions share. An opt-in {@link versionHistoryAdmin} may diff any versions.
function diff(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with fromVersionId and toVersionId params.res — The response object (reads locals.session/locals.versionHistoryAdmin).diffSnapshots(before, after)Computes a shallow per-field diff between two JSON snapshots.
For object snapshots, every key present in either snapshot is examined;
keys whose values differ (by deep equality) are included in the result.
For non-object snapshots, the entire value is reported under the
synthetic key $value.
function diffSnapshots(before: JSONValue, after: JSONValue): VersionChanges
before — The previous snapshot, or null if this is the first version.after — The new snapshot.Returns: A {@link VersionChanges} record. Empty when the snapshots are equal.
diffVersions(fromVersionId, toVersionId)Compares two versions and returns the shallow diff between their snapshots.
The argument order does not matter — the lower-numbered version is always
treated as from, the higher-numbered as to, so changes describes the
forward delta.
function diffVersions(fromVersionId: string, toVersionId: string): Promise<VersionDiff | null>
fromVersionId — One version ID to compare.toVersionId — The other version ID to compare.Returns: The {@link VersionDiff}, or null if either version is missing or the two versions belong to different resources.
getLatestVersion(resourceType, resourceId)Returns the most recent version for a resource, or null if no versions exist.
function getLatestVersion(resourceType: string, resourceId: string): Promise<Version | null>
resourceType — The resource type.resourceId — The resource ID.Returns: The latest version, or null.
getOwnershipResolver(resourceType)Returns the ownership resolver registered for a resource type, or
undefined if none has been registered.
function getOwnershipResolver(resourceType: string): VersionOwnershipResolver | undefined
resourceType — The resource type to look up.Returns: The resolver, or undefined.
getVersionById(versionId)Retrieves a version by ID.
function getVersionById(versionId: string): Promise<Version | null>
versionId — The version ID.Returns: The version, or null if not found.
getVersionByNumber(resourceType, resourceId, version)Retrieves a version by resource and version number.
function getVersionByNumber(
resourceType: string,
resourceId: string,
version: number,
): Promise<Version | null>
resourceType — The resource type.resourceId — The resource ID.version — The 1-based version number.Returns: The version, or null if not found.
getVersionCount(resourceType, resourceId)Returns the total number of versions for a resource.
function getVersionCount(resourceType: string, resourceId: string): Promise<number>
resourceType — The resource type.resourceId — The resource ID.Returns: The version count.
getVersionsForResource(resourceType, resourceId, options)Retrieves paginated versions for a resource, ordered by version number descending.
function getVersionsForResource(
resourceType: string,
resourceId: string,
options?: PaginationOptions,
): Promise<PaginatedResult<Version>>
resourceType — The resource type.resourceId — The resource ID.options — Pagination options.Returns: A paginated result of versions.
isVersionAuthorized(res, context)Authorizes whether the authenticated caller may access versions of a parent
resource. Admins widened by {@link versionHistoryAdmin} are always allowed;
otherwise the app-registered {@link VersionOwnershipResolver} for the parent
resourceType decides. Fail-closed: when no resolver is registered the
caller is denied, so the polymorphic version store never leaks a snapshot it
cannot prove the caller owns.
function isVersionAuthorized(
res: MoleculeResponse,
context: VersionOwnershipContext,
): Promise<boolean>
res — The response whose locals.session/locals.versionHistoryAdmin is inspected.context — The {@link VersionOwnershipContext} (parent resource + caller).Returns: true when the caller may access the parent resource's versions.
isVersionHistoryAdmin(res)Resolves whether the current request's session belongs to an actor
authorized to administer version history (read/restore any user's versions).
Fail-closed: returns false when there is no authenticated session, and
otherwise only true when the session carries an admin claim — isAdmin === true, role === 'admin', roles containing 'admin', or permissions
containing 'admin' / 'versionHistory:manage'.
function isVersionHistoryAdmin(res: MoleculeResponse): boolean
res — The response whose locals.session is inspected.Returns: true when the session is an authorized version-history admin.
jsonEqual(a, b)Returns true if a and b are deeply equal as JSON values.
function jsonEqual(a: JSONValue | undefined, b: JSONValue | undefined): boolean
a — First value to compare.b — Second value to compare.Returns: Whether the two values are structurally equal.
list(req, res)Lists paginated versions for a resource, newest version first.
Secure by default: returns 401 with no session, and 404 (no existence leak) when the caller is not authorized for the parent resource — only the parent resource's owner (or an opt-in {@link versionHistoryAdmin}) sees its versions.
function list(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with resourceType and resourceId params.res — The response object (reads locals.session/locals.versionHistoryAdmin).read(req, res)Reads a single version by ID.
Secure by default: returns 401 with no session, and 404 (no existence leak) when the version is missing OR the caller is not authorized for its parent resource — a non-owner cannot tell the two apart, so another tenant's snapshot is never disclosed. An opt-in {@link versionHistoryAdmin} may read any version.
function read(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with versionId param.res — The response object (reads locals.session/locals.versionHistoryAdmin).readByNumber(req, res)Reads a single version of a resource by 1-based version number.
Secure by default: returns 401 with no session, and 404 (no existence leak) when the caller is not authorized for the parent resource.
function readByNumber(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with resourceType, resourceId, and version params.res — The response object (reads locals.session/locals.versionHistoryAdmin).registerOwnershipResolver(resourceType, resolver)Registers an ownership resolver for a parent resource type.
Subsequent calls with the same resourceType overwrite the previous
registration.
function registerOwnershipResolver(resourceType: string, resolver: VersionOwnershipResolver): void
resourceType — The parent resource type the resolver authorizes.resolver — The resolver to invoke when authorizing access to versions of this resource type.restore(req, res)Restores a prior version by appending a new version whose snapshot matches it. Append-only — the existing rows are never mutated.
Secure by default: returns 401 with no session, and 404 (no existence leak) when the version is missing OR the caller is not authorized for its parent resource — a non-owner can neither restore another tenant's version nor learn that it exists. An opt-in {@link versionHistoryAdmin} may restore any version.
function restore(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with versionId param.res — The response object (reads locals.session/locals.versionHistoryAdmin).restoreVersion(versionId, userId, reason)Restores a prior version by appending a new version whose snapshot matches it.
Append-only: the existing rows are never mutated. The newly-appended
version has its own monotonically-increasing version number, and its
reason defaults to Restored from version <n> when no reason is supplied.
function restoreVersion(
versionId: string,
userId: string | null,
reason?: string | null,
): Promise<Version | null>
versionId — The version ID to restore.userId — The user performing the restore, or null for system.reason — Optional human-readable reason. When omitted, a default Restored from version <n> reason is recorded.Returns: The newly-appended version, or null if versionId was not found.
unregisterOwnershipResolver(resourceType)Removes any registered ownership resolver for the given resource type.
function unregisterOwnershipResolver(resourceType: string): boolean
resourceType — The resource type whose resolver should be removed.Returns: true if a resolver was removed.
versionCount(req, res)Returns the total number of versions for a resource.
Secure by default: returns 401 with no session, and 404 (no existence leak) when the caller is not authorized for the parent resource.
function versionCount(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with resourceType and resourceId params.res — The response object (reads locals.session/locals.versionHistoryAdmin).versionHistoryAdmin()Opt-in route middleware that widens an authenticated admin to cross-tenant
version access by setting res.locals.versionHistoryAdmin = true. It never
blocks: a non-admin (or anonymous) caller passes through unchanged and
remains subject to the ownership resolver in the handlers, so composing this
onto a route can only widen for admins, never open the endpoint. Wire it onto
a dedicated admin route when a support/compliance console needs to read or
restore every user's versions.
function versionHistoryAdmin(): MoleculeRequestHandler
Returns: An Express-compatible middleware function.
createVersionSchemaSchema for validating create-version input.
const createVersionSchema: z.ZodObject<
{
snapshot: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>
reason: z.ZodNullable<z.ZodOptional<z.ZodString>>
},
z.core.$strip
>
i18nRegisteredThe i18n registered.
const i18nRegistered: true
requestHandlerMapHandler map for version-history routes.
const requestHandlerMap: {
readonly create: typeof create
readonly list: typeof list
readonly versionCount: typeof versionCount
readonly readByNumber: typeof readByNumber
readonly read: typeof read
readonly restore: typeof restore
readonly diff: typeof diff
}
restoreVersionSchemaSchema for validating restore-version input. Body is empty — the version to restore is identified by URL params.
const restoreVersionSchema: z.ZodObject<
{ reason: z.ZodOptional<z.ZodNullable<z.ZodOptional<z.ZodString>>> },
z.core.$strip
>
routesRoutes for the version-history resource. All routes require authenticate;
the handlers additionally authorize every read/mutation against the caller's
ownership of the parent resource.
const routes: readonly [
{
readonly method: 'post'
readonly path: '/:resourceType/:resourceId/versions'
readonly handler: 'create'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/:resourceType/:resourceId/versions'
readonly handler: 'list'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/:resourceType/:resourceId/versions/count'
readonly handler: 'versionCount'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/:resourceType/:resourceId/versions/:version'
readonly handler: 'readByNumber'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/versions/:versionId'
readonly handler: 'read'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'post'
readonly path: '/versions/:versionId/restore'
readonly handler: 'restore'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/versions/:fromVersionId/diff/:toVersionId'
readonly handler: 'diff'
readonly middlewares: readonly ['authenticate']
},
]
VERSION_HISTORY_ADMIN_PERMISSIONSession-claim permission string ('versionHistory:manage') that, when
present in a session's permissions array, marks the caller as a
version-history admin.
const VERSION_HISTORY_ADMIN_PERMISSION: 'versionHistory:manage'
VERSION_HISTORY_PERMISSION_ACTIONPermission action describing version-history administration, e.g. for an
app's own @molecule/api-permissions wiring.
const VERSION_HISTORY_PERMISSION_ACTION: 'manage'
VERSION_HISTORY_PERMISSION_RESOURCEPermission resource describing version-history administration, e.g. for an
app's own @molecule/api-permissions wiring.
const VERSION_HISTORY_PERMISSION_RESOURCE: 'versionHistory'
Peer dependencies:
@molecule/api-database ^1.0.1@molecule/api-i18n ^1.0.1@molecule/api-locales-resource-version-history ^1.0.1@molecule/api-logger ^1.0.1@molecule/api-resource ^1.0.1zod ^4.0.0@molecule/api-database
@molecule/api-i18n
@molecule/api-locales-resource-version-history
@molecule/api-logger
@molecule/api-resource
zod
List endpoints return a PAGINATED envelope { data, total, limit, offset }, not a
bare array — read the rows off result.data (server). On the client, unwrapList(res)
from @molecule/app-http normalizes this envelope (pass it the whole HttpResponse), so
the rows come back; reading the response as a bare array — or res.data alone (which is
the envelope) — yields an EMPTY list.
Security — the raw routes are NOT open. Snapshots can contain any
tenant's data, so every route requires an authenticated session AND each
handler re-derives the caller from res.locals.session.userId and authorizes
access to the parent resource via {@link isVersionAuthorized}. Access is
fail-closed and pluggable: because the store is polymorphic it cannot
know who owns an arbitrary (resourceType, resourceId), so an app mounting
these routes MUST register a {@link VersionOwnershipResolver} per resource
type at startup via {@link registerOwnershipResolver} — until it does, every
read/list/diff/restore returns 404 (no existence leak) rather than exposing
another tenant's snapshots. Cross-tenant admin access is opt-in via the
{@link versionHistoryAdmin} middleware. Do NOT mount the raw routes without
either a registered resolver or your own resource-ownership gate.
Tables: src/__setup__/versions.sql creates versions. An mlcl-scaffolded
API replays __setup__/*.sql automatically on migrate; anywhere else run it
once — nothing at runtime creates them. Handler errors flow through t()
with English defaults; install @molecule/api-locales-resource-version-history
and register it with registerLocaleModule for translations.
Versioning-correctness checklist — drive the real UI (live preview, no mocks) wherever this app surfaces revisions/history, adapt each item to the actual screens, and check every box. A box you can't check is a versioning bug to fix — not a skip. The point is to PROVE versions are recorded, ordered, diffable, restorable, and tamper-evident, not just that CRUD compiles:
version number (previous + 1), the acting user as userId
(author), and a fresh createdAt. The very first save has changes: null;
later saves record a changes shallow diff (before/after per field)
against the prior snapshot.version descending)
with total reflecting every version, each row showing its number, author
(userId), reason, and createdAt — and the count only ever GROWS across
saves (append-only: no save shrinks or rewrites history).snapshot; diffing two
versions renders the per-field changes as a forward delta (the
lower-numbered version is from, the higher is to), and a diff across two
different resources is rejected (no cross-resource diff).reason records the restore (default Restored from version <n>). History
is never lost by a revert.(resourceType, resourceId) or a versionId gets 404 (no existence leak),
never another tenant's history, and with no ownership resolver registered
every read/list/diff/restore fails closed. The version author is always the
session user (res.locals.session.userId), never a body-supplied id — a
caller cannot attribute a change to someone else. A user cannot fabricate or
delete history to hide a change: versions are append-only and a revert
appends rather than rewrites; only the opt-in versionHistoryAdmin
middleware crosses tenants.Translation strings are provided by @molecule/api-locales-resource-version-history.