@molecule/api-resource-version-history
API resource · version-history · API (Node) · v1.0.5 · Apache-2.0
Append-only versions table with diff and restore — generic versioning helper for any resource.
npm install @molecule/api-resource-version-historyHow it works
@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
Reference
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.
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.
Quick Start
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',
})
Type
resource
Installation
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
API
Interfaces
VersionOwnershipContext
The 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
}
Version
A 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
}
VersionFieldChange
Per-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
}
CreateVersionInput
Input 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
}
VersionDiff
Result 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
}
PaginationOptions
Options for paginated queries.
interface PaginationOptions {
/** Maximum number of results to return. */
limit?: number
/** Number of results to skip. */
offset?: number
}
PaginatedResult
A 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
}
Types
VersionOwnershipResolver
Resolves 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>
context— TheVersionOwnershipContextto authorize.
Returns: true when the caller owns / may access the parent resource.
JSONValue
A JSON-serializable snapshot value. Constrained by what the underlying
JSONB column can accept.
type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }
VersionChanges
Shallow per-field diff between two snapshots, keyed by field name.
type VersionChanges = Record<string, VersionFieldChange>
Functions
isVersionAuthorized(res, context)
Authorizes whether the authenticated caller may access versions of a parent
resource. Admins widened by versionHistoryAdmin are always allowed;
otherwise the app-registered 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 whoselocals.session/locals.versionHistoryAdminis inspected.context— TheVersionOwnershipContext(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 whoselocals.sessionis inspected.
Returns: true when the session is an authorized version-history admin.
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.
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.
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, ornullif this is the first version.after— The new snapshot.
Returns: A VersionChanges record. Empty when the snapshots are equal.
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 withresourceTypeandresourceIdparams.res— The response object (readslocals.session/locals.versionHistoryAdmin).
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 withresourceType/resourceIdparams and a snapshot body.res— The response object (readslocals.session/locals.versionHistoryAdmin).
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 versionHistoryAdmin may
diff any versions.
function diff(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req— The request withfromVersionIdandtoVersionIdparams.res— The response object (readslocals.session/locals.versionHistoryAdmin).
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 versionHistoryAdmin) sees its versions.
function list(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req— The request withresourceTypeandresourceIdparams.res— The response object (readslocals.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 versionHistoryAdmin may read
any version.
function read(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req— The request withversionIdparam.res— The response object (readslocals.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 withresourceType,resourceId, andversionparams.res— The response object (readslocals.session/locals.versionHistoryAdmin).
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 versionHistoryAdmin may restore any version.
function restore(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req— The request withversionIdparam.res— The response object (readslocals.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.
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.
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.
clearOwnershipResolvers()
Clears all registered ownership resolvers. Primarily useful in tests.
function clearOwnershipResolvers(): void
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.
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.
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.
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.
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 VersionDiff, or null if either version is missing or the two versions belong to different resources.
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, ornullfor system.reason— Optional human-readable reason. When omitted, a defaultRestored from version <n>reason is recorded.
Returns: The newly-appended version, or null if versionId was not found.
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.
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.
Constants
VERSION_HISTORY_PERMISSION_ACTION
Permission 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_RESOURCE
Permission resource describing version-history administration, e.g. for an
app's own @molecule/api-permissions wiring.
const VERSION_HISTORY_PERMISSION_RESOURCE: 'versionHistory'
VERSION_HISTORY_ADMIN_PERMISSION
Session-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'
i18nRegistered
The i18n registered.
const i18nRegistered: true
requestHandlerMap
Handler 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
}
routes
Routes 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']
},
]
createVersionSchema
Schema 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
>
restoreVersionSchema
Schema 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
>
Injection Notes
Requirements
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
Runtime Dependencies
-
@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 offresult.data(server). On the client,unwrapList(res)from@molecule/app-httpnormalizes this envelope (pass it the whole HttpResponse), so the rows come back; reading the response as a bare array — orres.dataalone (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 fromres.locals.session.userIdand authorizes access to the parent resource viaisVersionAuthorized. 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 aVersionOwnershipResolverper resource type at startup viaregisterOwnershipResolver— 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 theversionHistoryAdminmiddleware. 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 on migrate (a CREATE TABLE IF NOT EXISTS, so a table the app's own migrations already create keeps only its own columns: ALTER in the ones it lacks); 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.
E2E Tests
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:
- Editing a versioned resource RECORDS a new version: after a save the
history count grows by exactly one, the prior version is RETAINED (its
snapshot unchanged — not overwritten), and the new row carries the next
1-based
versionnumber (previous + 1), the acting user asuserId(author), and a freshcreatedAt. The very first save haschanges: null; later saves record achangesshallow diff (before/after per field) against the prior snapshot. - Listing history returns versions newest-first (by
versiondescending) withtotalreflecting every version, each row showing its number, author (userId),reason, andcreatedAt— and the count only ever GROWS across saves (append-only: no save shrinks or rewrites history). - Viewing an old version shows that version's full
snapshot; diffing two versions renders the per-fieldchangesas a forward delta (the lower-numbered version isfrom, the higher isto), and a diff across two different resources is rejected (no cross-resource diff). - Reverting/restoring an old version makes it current AND itself APPENDS a
new version whose snapshot equals the target's — the prior current version
and the restored-from version both still exist afterward, the new version's
number is the next in sequence, its author is the acting user, and its
reasonrecords the restore (defaultRestored from version <n>). History is never lost by a revert. - Retention is append-only and unbounded — nothing prunes: there is no UI or endpoint to edit or delete an individual version (no UPDATE/DELETE route), so a resource's history count never decreases. (If the app layers its own retention policy on top, verify it prunes oldest-first per that policy and never drops the current version.)
- AUTHORIZATION — a resource's version history is reachable only by a user
who can access that resource: a different user id-guessing the
(resourceType, resourceId)or aversionIdgets 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-inversionHistoryAdminmiddleware crosses tenants.
Translations
Translation strings are provided by @molecule/api-locales-resource-version-history.