22. Version-field migrations
This explicit schema/data procedure is verified on SQLite, PostgreSQL and MySQL in both storage modes. The library implements revision conditions, membership invalidation and optional HTTP validators. Stored revisions and HTTP representation validators have different scopes; see the API migration guide for their contracts. Apply this procedure to each application database before enabling conditional writes.
Choose the field deliberately
Declare a stored string attribute, such as revision, and later select it with
versionField: 'revision'. The field must be distinct from the primary ID and
cannot have a getter, setter, storage serializer, computed/virtual behavior or
relationship. The library creates opaque UUID tokens. Clients must compare and
submit the entire token, without parsing, trimming or changing its case.
Keep the selected output visibility explicit. A hidden revision remains hidden; enabling versioning does not expose it. Clients using conditional writes need an authorized way to read the revision. Full write responses or a subsequent GET can provide it; minimal and none responses do not promise a new token.
What changes a revision
The revision tracks public resource writes and declared relationship membership. It is not a hash of the complete response and is not an HTTP ETag.
| Operation | Revision behavior |
|---|---|
| POST or unconditional PUT-create | Initializes a fresh UUID. Reusing a deleted ID never reuses its old revision. |
| PATCH or PUT-update | Assigns a fresh UUID, including unconditional writes and writes that submit unchanged attribute values. Clients must not treat tokens as operation counts. |
| Conditional PUT-create | Fails as not found; a condition cannot match a nonexistent row. |
| DELETE | Checks the supplied condition before deletion; surviving versioned relationship parents are invalidated. |
| Public relationship mutation | Rotates the owner’s revision and affected configured inverse revisions. Explicitly retained many-to-many targets may also receive new tokens. |
| Child creation, reparenting, detachment or deletion | Rotates affected configured has-many/has-one parent revisions, including declared polymorphic inverses. |
| Child attribute change with unchanged membership | Does not rotate the parent’s revision merely because included child attributes changed. |
| Ordinary pivot-resource write | Invalidates affected configured parents when either membership key changes. Canonical through-resource records remain separate from canonical links. |
| Bulk PATCH or DELETE | Applies the same rules per child. Optional expectedVersions must contain one token per input entry. |
| Transaction rollback | Restores revisions together with the associated writes and links. |
Submit conditions through expectedVersion, not as an attribute update. Omitting
the condition permits an unconditional write; versioning does not require every
client to submit a token. JSON:API and plain formats use the same stored revision
and preserve the field’s visibility rules. SQL writers, triggers and transitive
database cascades require application-owned revision maintenance; the library
cannot infer their arbitrary effects on other resources or representations.
Each successful write consumes the previous revision even inside one transaction. Use the returned revision for a second conditional write in that transaction; reusing the first token conflicts. Rolling back the transaction restores the revision that existed before its writes.
Prepare existing data
Stop all writers, including old application instances, jobs and raw SQL writers. Take the application’s normal backup. Keep writers stopped through field allocation, backfill, configuration deployment and readiness checks. Do not enable version conditions against partially migrated data.
For ordinary Knex storage, add a nullable string column in an explicit schema migration. Resolve any declared physical column mapping rather than assuming the attribute and column names coincide. Allocate at least 128 characters if using VARCHAR. Do not set a single shared default revision for existing rows. Schema DDL and the data backfill are separate migration steps: transactional DDL support differs by database.
For canonical storage, allocate a stored string field through addKnexFields
on the unversioned resource. This records its canonical slot allocation; do not
choose a string_N slot by inspection of empty record values. Read the resulting
descriptor’s fields.revision.slot and use that actual column for the backfill.
Every canonical record query must include both tenant_id and resource, and
pagination must use physical any_records.id, not tenant-local logical_id.
Backfill SQL NULL revisions with a fresh UUID for each row in a caller-owned transaction. Preserve existing valid string tokens so retries do not invalidate clients unnecessarily. Reject empty or overlong existing values and resolve them explicitly; do not silently overwrite them. If any validation or write fails, let the error escape the transaction callback so the entire data phase rolls back. The migration must not call public PATCH merely to populate tokens: that would run application write hooks and side effects.
The repository’s backfill example (source checkout: examples/migrations/resource-versions.js)
implements this data phase in pages of 100 records. Copy it into the application
migration directory and import it locally; it is an example, not a new library
export. Use an active raw Knex transaction for this one-off SQL migration.
For a dedicated ordinary table, after adding its nullable column:
import { backfillResourceVersions } from './resource-versions.js'
await knex.transaction(transaction => backfillResourceVersions(transaction, {
tableName: 'books',
idColumn: 'id',
versionColumn: 'revision',
scope: {}
}))
Replace the physical names with the application’s actual mapping. An explicit empty scope means the whole dedicated table. For canonical storage, allocate the field and resolve its persisted slot first:
await api.resources.books.addKnexFields({
fields: { revision: { type: 'string', nullable: true } }
})
const tenant = api.anyapi.tenantId
const descriptor = await api.anyapi.registry.getDescriptor(tenant, 'books', {
bypassCache: true
})
await knex.transaction(transaction => backfillResourceVersions(transaction, {
tableName: 'any_records',
idColumn: 'id',
versionColumn: descriptor.fields.revision.slot,
scope: { tenant_id: tenant, resource: 'books' }
}))
Run field allocation once as its own migration step. Rerun only the data phase
to check backfill retry behavior; adding an already allocated field is an error.
The returned scanned and initialized counts let the migration record exactly
what it inspected and initialized. A successful retry preserves all tokens and
returns initialized: 0.
Deploy and check readiness
Persist the field declaration in application source, including canonical field
configuration where applicable, then restart with versionField: 'revision'.
Selecting this option alone does not perform schema migration or backfill.
Restart resource registration against existing tables; do not run
createKnexTable again for an ordinary table that already exists.
Canonical alterKnexFields is not supported; do not use it to enforce SQL
NOT NULL on a shared slot. Application required: true can be enabled in the
new resource declaration after the backfill.
Verify every selected row has a valid string token, unrelated tenants/resources
are unchanged, and retrying the backfill initializes zero additional rows.
Check that a new POST initializes a token, unconditional PATCH rotates it,
a current conditional PATCH succeeds, and a stale one returns
REST_API_VERSION_CONFLICT without changing stored data. Test rollback before
allowing writers to resume.
Clients send expectedVersion alongside the single-write options, or aligned
expectedVersions for bulk PATCH/DELETE. They must not submit the revision among
the record attributes. A conflict requires fetching and reconciling the current
record; blindly retrying with a newly fetched token can overwrite another
client’s edits. These tokens are not HTTP ETags.
If application deployment is rolled back, stop writers first. Old unconditional writers do not maintain the new contract. Before re-enabling conditional clients, rotate revisions for rows that might have changed under those writers, or restore the pre-migration backup as part of the application’s rollback plan. The nullable added column can remain during rollback; dropping it is not necessary to disable conditional writes.
Pivot-resource writes
For ordinary storage, public writes to a declared many-to-many through resource also invalidate affected versioned parent revisions. Both keys matter: changing the member while retaining the parent still changes that parent’s membership.
Canonical through-resource records are separate from canonical relationship links. Use public relationship methods to change canonical memberships; creating a through-resource record does not create a canonical link. Direct SQL and other writers that bypass the public mutation paths still need explicit revision maintenance.
Deleting linked resources
Deleting a resource through the public API removes its many-to-many links in the same transaction. Recreating the same resource ID starts without those old memberships. Surviving versioned parents receive new revisions when their membership changes. A caller rollback restores the deleted record, links and parent revisions together.
Ordinary storage removes remaining declared pivot references after deleting the resource row; canonical storage removes links matching its tenant, resource and ID in either direction. Applications must not depend on deleted IDs retaining memberships for later reuse. This does not provide revision maintenance for arbitrary database triggers or SQL writers outside the public API.
Earlier releases deliberately left ordinary pivot rows for application cleanup. That behavior changes: declared membership rows are removed automatically, including for unversioned resources and custom IDs. Related resource records are preserved; deleting a book’s author links does not delete its authors.
Explicit ordinary SQL foreign keys retain their configured behavior: RESTRICT
can reject deletion, leaving both membership and revisions unchanged; CASCADE
can remove the pivot, with the surviving parent’s revision updated by the public
delete transaction. These two rules are tested on SQLite, PostgreSQL and MySQL.
Verification
The executable example and allocation/restart workflow pass six checks in each
of six database/storage combinations (36 total), with no failures or skips.
Tests cover 103 rows across multiple pages, distinct initialization tokens,
preservation of existing tokens, retry, rollback after late invalid data,
caller-owned rollback, filtered records, overlapping canonical tenants/resources,
and post-migration conditional updates and creation. See
the test suite (source checkout: tests/conformance-version-migration.test.js).