33. Migrating to v2
Version 2 changes setup, resource calls and transaction ownership. Upgrade each application’s source and dependency versions together. Old option spellings are rejected; there is no compatibility mode. This guide describes the required port; it does not imply that any consuming application or seed generator is migrated. Before v2 is published, install the intended tarball or pin an exact reviewed Git commit in place of the registry package. Verify the installed artifact; changing the version declaration alone does not migrate an application.
Replace the API host
Remove the direct hooked-api dependency from the application:
npm uninstall hooked-api
Import the constructor from this package:
import { JsonRestApi, RestApiPlugin, RestApiKnexPlugin } from 'json-rest-api'
const api = new JsonRestApi({ name: 'app', logger: applicationLogger })
await api.use(RestApiPlugin, { format: 'jsonapi' })
await api.use(RestApiKnexPlugin, { knex })
await api.addResource('books', bookOptions)
api.resources.books.get(...), the other resource methods and api.transaction(...)
keep their documented call shapes. Update the setup and extension code:
| Previous API | Replacement |
|---|---|
Api imported from hooked-api |
JsonRestApi imported from json-rest-api |
api.scopes |
api.resources |
api.addScope(...) |
api.addResource(...) |
Injected addScopeMethod |
Injected addResourceMethod |
Setup hook scope:added |
resource:added |
Resource option scopeMethods |
Resource option methods |
customize({ scopeMethods }) |
customize({ methods }) |
Dependency alternative 'a\|b' |
dependencies: [['a', 'b']] |
Constructor log / logging options |
Injected logger object |
HookedApiError base class |
RestApiError from json-rest-api |
Logging is silent by default. Supply the logger’s level methods directly; configure
formatting and filtering in that logger. Plugin installation preserves the original
error, so stop matching hooked-api’s wrapping message. There is no compatibility mode.
Generic scope aliases, customize({ apiMethods }) and method-registration lifecycle
hooks have been removed. Install API methods through a plugin’s addApiMethod instead.
Hooks support named beforeFunction or afterFunction placement, with one placement
per handler. Numeric ordering and per-plugin placement are unsupported. Returning
false stops that hook list; throw an appropriate error to reject a request.
Resources are ordinary objects. Call shared methods with their resource receiver;
use api.resources.books.get.bind(api.resources.books) if you need a detached callback.
Resource vars and helpers inherit live API defaults. Local writes override them;
deleting an override reveals the default again. Object.keys(resource.vars) lists
local values only, rather than combining inherited defaults.
Await plugin and resource setup sequentially. Discard an instance after a setup failure; plugin installation is not transactional. Finish setup before serving requests.
TypeScript applications can keep an explicit JsonRestApi resource map using the
existing resource, relationship and bulk method interfaces. Ordinary literal
schemas also infer attribute types on the handle returned by addResource();
see resource and hook typing.
Remove the S3 demo adapter
S3Storage and S3StorageOptions have been removed, including the direct
plugins/storage/s3-storage.js import. The old adapter generated mock URLs;
it did not upload to S3. There is no replacement export or compatibility mode.
Use the application’s existing storage integration through the FileStorage
upload/delete contract, or use LocalStorage for local files. Remote-provider
credentials, clients and storage policy belong to the application. See
file handling for the adapter contract.
Start with the common changes
For an ordinary repository/app migration, work through these first:
- Replace the API host, then update Node and rename the two response options.
- Replace
inputRecordwithdataordocumentand select input and output independently. - Update configuration and consumers together, then check result shapes and request context.
- Review relationship writes and transaction/error handling where your app uses them.
- Verify the port against the intended package set.
The remaining sections are feature-specific references. In particular, check storage naming, schema migration helpers and temporal storage before changing stored data. Connector/plugin sections apply only to the integrations your app uses.
| If your app customizes… | Review |
|---|---|
| Stored values or table names | Structured attributes, temporal values, schema helpers, storage naming |
| Queries and output | Fieldsets, sort fields, pagination, includes |
| Hooks and plugins | Read failures, schema enrichment, write responses, query builders |
| Transports and files | CORS, uploads, Socket.IO, bulk writes |
| Logging | Logger enhancement, error causes, field redaction |
| Conditional writes | Stored revisions, HTTP conditions |
Runtime requirement
Use Node 24 or newer and reinstall dependencies after upgrading Node so native
database modules match its ABI. This library’s development and CI checks run on
Node 24 only; .nvmrc pins 24.6.0. Update application runtime configuration when
performing its migration.
Declare searchable relationship fields explicitly
The library no longer guesses that a resource is a pivot table from its number
of belongs-to fields. That heuristic used to enable search automatically, even
overriding search: false.
If an application filters a pivot resource by a relationship, declare the filter
on the field or in searchSchema, just as for any other resource:
schema: {
groupId: { type: 'id', belongsTo: 'groups', as: 'group', search: true },
itemId: { type: 'id', belongsTo: 'items', as: 'item' }
}
Here filters: { group: groupId } is supported. Filtering by item is rejected
unless separately declared. Omitting search or setting it to false does not
generate a filter; an explicit searchSchema entry or schema enrichment hook
can still declare one. Relationship add, replace, remove, related-resource reads
and includes work without enabling public search on pivot fields.
No database migration is needed. Review application calls that directly filter pivot resources and add only the search declarations those calls require.
What stays familiar
Keep resource declarations and calls through api.resources.books. The familiar
get, query, post, put, patch, and delete operations remain. JSON:API
documents and plain records both remain useful formats. This migration does not
require replacing ordinary repository code with a new ORM.
Rename the two response options
| Old option | Replacement |
|---|---|
simplified: true |
format: 'plain' |
simplified: false |
format: 'jsonapi' |
returnFullRecord: 'full' or true |
returning: 'full' |
returnFullRecord: 'minimal' |
returning: 'minimal' |
returnFullRecord: 'no' or false |
returning: 'none' |
format chooses output representation. returning chooses how much a write returns.
Boolean aliases and the old option names are removed, not deprecated aliases.
// Before
const book = await api.resources.books.get({
id: '42',
simplified: true
})
// After
const book = await api.resources.books.get({
id: '42',
format: 'plain'
})
For a JSON:API response, use format: 'jsonapi'. Keep the existing queryParams
container for filters, sort, sparse fields, includes, and pagination unless a
separately documented query-contract change is made.
const document = await api.resources.books.query({
format: 'jsonapi',
queryParams: { fields: { books: 'title' }, page: { size: 20 } }
})
Replace inputRecord with data or document
Resource POST/PUT/PATCH accept exactly one input key: data for plain resource
values, or document for a JSON:API document. The old inputRecord parameter is
removed. Both keys together are rejected, including a key set to undefined.
Fields inside data are resource values; options outside it control the operation.
// Before: plain resource values were inside inputRecord
await api.resources.books.post({
inputRecord: { title: 'Dune' },
format: 'plain',
returning: 'full'
})
// After: plain input remains convenient and has a clear boundary
await api.resources.books.post({
data: { title: 'Dune' },
format: 'plain',
returning: 'full'
})
Fields named format, returning, queryParams, document, or data belong
inside data; they are ordinary data there. Plain input is never autodetected as
a JSON:API document. data.id is the record identity: on PATCH/PUT it must
match the target id, and cannot redirect the operation to another record. Keep the target ID outside the
patch data:
await api.resources.books.patch({
id: '42',
data: { title: 'Updated title' },
format: 'plain',
returning: 'none'
})
When using JSON:API input, rename the parameter to document and retain its
document structure. format independently selects the response:
await api.resources.books.patch({
id: '42',
document: {
data: {
type: 'books',
id: '42',
attributes: { title: 'Updated title' }
}
},
format: 'jsonapi',
returning: 'full'
})
Port calls that mixed JSON:API input with plain output
Older versions detected JSON:API documents inside inputRecord; the intermediate
v2 contract instead coupled input to format. Both are replaced by explicit
input keys. format now selects only output, so a JSON:API document can return
plain output and plain data can return a JSON:API document.
Ordinary repositories can remove JSON:API builders entirely:
// Before: JSON:API input was autodetected under the plain default.
const profile = await profiles.patch({
inputRecord: createJsonApiInputRecord('userProfiles', { displayName }, { id: userId }),
transaction: trx
}, requestContext)
// After: record data, target ID, and transaction are explicit.
const profile = await profiles.patch({
id: userId,
data: { displayName },
format: 'plain',
transaction: trx
}, requestContext)
For a belongs-to relationship, plain input uses the relationship name and ID,
for example data: { name: 'Team', owner: userId }. Keep existing domain
normalization. A repository intentionally consuming JSON:API documents can keep
its application-owned document builder instead. The createJsonApiInputRecord
name below represents that application’s function; the old JSKIT export with
that name has been removed.
const profile = await profiles.patch({
id: userId,
document: createJsonApiInputRecord('userProfiles', { displayName }, { id: userId }),
format: 'plain',
transaction: trx
}, requestContext)
There is one resource method implementation, not a separate api.jsonapi API.
HTTP connectors submit their request body through document and request JSON:API
output. Validation, permissions, hooks and transaction behavior are shared.
Full-response preparation still happens before commit.
Write documents accept optional object-valued top-level meta, links and
jsonapi members. They remain available to hooks on context.inputRecord and
are not stored attributes. errors and included are rejected: accepting
documents does not enable compound writes or JSON:API extensions.
The hook field context.inputRecord remains the normalized operation document
for both input kinds. Do not mechanically rename that field when migrating
public call parameters. See the hook context contract.
Update configuration and consumers together
Use the same scalar option names for plugin defaults, resource overrides, and
individual calls. Precedence is call → resource → plugin → built-in default.
The built-in defaults are format: 'plain' and returning: 'full'.
// Before
await api.use(RestApiPlugin, {
simplifiedApi: true,
simplifiedTransport: false,
returnRecordApi: { post: 'full', put: 'full', patch: 'full' }
})
// After
await api.use(RestApiPlugin, { format: 'plain', returning: 'full' })
// A resource can override these defaults.
await api.addResource('books', {
schema: bookSchema,
format: 'jsonapi',
returning: 'minimal'
})
Per-method return maps are removed. Choose one resource default and set
returning on calls needing a different result. For example, port a former
{ post: 'full', put: 'none', patch: 'none' } policy by keeping the full default
and passing returning: 'none' on PUT/PATCH. Invalid strings, booleans, maps,
and the removed option names raise REST_API_VALIDATION; they never silently
select a fallback.
The library’s Express/Fastify resource routes explicitly use format: 'jsonapi'
and returning: 'full', regardless of programmatic defaults. Remove
simplifiedTransport, returnRecordTransport, and simulated isTransport
parameters. Standard resource POST responses contain a document with status
201; updates return their document with status 200. DELETE and relationship
mutations return no content. An application owning its HTTP handlers should
select its response contract explicitly.
For jskit-ai, start with packages/json-rest-api-core/src/server/jsonRestApiHost.js.
Then update the shared JSON:API CRUD repository and the plain-record repositories
in users-core and workspaces-core. Update generated-code templates before
regenerating app code. Each app with custom calls or hooks needs its own review.
For applications using the library’s Fastify connector, register resources before starting the server. Connector routes and JSON parsers now occupy their own Fastify scope, leaving host parsers intact. The route validator uses the existing request contract without Ajv stripping fields or adding coercion rules. Both connectors return 422 for invalid resource documents, 400 for malformed JSON, and 413 for an oversized body. Bodyless resource DELETE works with either accepted JSON content type.
Check HTTP clients and tests for these boundary changes:
- Send
Content-Type: application/vnd.api+jsonwithoutcharset. Ordinaryapplication/jsonrequests remain accepted. Unsupported types and JSON:API extensions receive 415; unknown profiles are ignored. - Allow
application/vnd.api+jsoninAccept, or use an appropriate wildcard. Incompatible headers receive 406 before writing. Responses use the exact JSON:API media type and preserveVary: Acceptalongside other vary values. - Remove retired options from query strings as well as resource calls. They now receive 422. Fractional page sizes/numbers also receive 422 instead of being truncated; opaque cursor strings retain their spelling.
- Correct unknown resource types in sparse fieldsets. They now receive the
typed
REST_API_FIELDSET_INVALIDerror (HTTP 400), including prototype-like names, rather than being silently ignored.
The Fastify guide documents body limits, prefixed 404s and
the host’s onBadUrl setting for malformed URLs. Fastify’s default malformed-URL
response occurs before connector hooks and retains its native 400 shape unless
the host configures that setting. Express maps malformed route parameters to
JSON:API errors. These transport changes require no stored-data migration.
Result shapes
| Operation | format: 'plain' |
format: 'jsonapi' |
|---|---|---|
get |
Plain record | { data: resource, ... } |
query |
{ data: [record, ...], meta?, links? } |
JSON:API collection document |
Resource write, returning: 'full' |
Plain record | Full resource document |
Resource write, returning: 'minimal' |
{ type, id } |
{ data: { type, id } } |
Resource write, returning: 'none' |
undefined |
undefined |
getRelated, to one |
Plain record or null |
Document with resource or data: null |
getRelated, to many |
Same envelope as query |
JSON:API collection document |
An empty collection has data: []. Plain collections retain their envelope so
pagination links and metadata remain available. Plain includes become nested
records; JSON:API includes stay in the document’s included array with
relationship identifiers in data.relationships. Missing primary resources
raise the existing typed not_found error.
To-one related reads (belongsTo, polymorphic belongs-to, and hasOne) now
run the target resource’s GET lifecycle whether or not fields or includes are
requested. Previously, a read without those options could bypass target
checkPermissions, checkDataPermissions, checkDataPermissionsGet, and
GET finish hooks by returning an included record directly. Check permission
hooks that previously only ran when callers supplied a fieldset. Target GET
errors now propagate consistently; SQL-hidden or absent linkage still returns
null. Parent GET permission checks retain the parent’s full attributes.
The caller’s transaction is used for both reads and remains caller-owned.
getRelationship also retains parent attributes for its internal GET permission
checks, instead of imposing an ID-only parent fieldset. Its response still
contains only linkage and links. A parent data-permission hook that denies a
normal GET can now deny the linkage endpoint using the same attributes.
getRelated and getRelationship set the current resource’s scopeName before
permission hooks run. Reusing a context from another resource cannot leave those
hooks checking its stale resource name.
Included resources and relationship identifiers now require the target resource’s
query permission, including nested includes and default linkage without
include. The library applies this check at the shared query-filtering boundary
before loading related rows. A permission hook receives method: 'query', the
target scope/schema, no parent id or parent filters, and the caller’s auth and
transaction. Explicit permission errors reject the read; row policies continue
to filter individual rows or null hidden to-one linkage. GET-only data hooks
remain part of the single-resource GET lifecycle, not collection/include policy.
Review applications that grant a primary read while denying query permission on
its related resources. Their includes/linkage previously bypassed that denial.
To-one getRelated also resolves authorized linkage before running target GET.
A full write response that fails this permission check rolls back its owned
write; returning: 'minimal' or 'none' does not fetch that full response.
Regular-storage many-to-many includes and linkage now enforce the declared pivot resource’s query permission and row filters, before include limits. This applies to standard and windowed includes with mapped pivot keys. AnyAPI continues to store these associations as tenant-scoped canonical links rather than rows in a declared pivot resource; do not expect a pivot-row policy to govern those links.
Canonical many-to-many linkage now applies target query permission and row filters
in SQL before loading link rows. Its relationship-identifiers filter query is
built once per relationship read, including an empty relationship, rather than
once per 100 already-loaded target IDs. The returned builder may be used inside
several paged SQL statements. Keep these hooks as query transformations; do not
depend on their call count for per-record work. Parent GETs may still filter
other declared relationships independently. Target auth, transaction and scoped
metadata remain available, and parent filters are not applied to the target.
Public resource arguments and response shapes need no change for this fix.
Plain responses now expand all available included levels. Previously the
converter discarded the remaining included document after the first level and
looked up the wrong nested schema property, losing nested belongs-to identifiers.
For example, include: ['authors.publisher.country'] now produces
book.authors[0].publisher.country. Cycles end with { id } (and _type for
polymorphic references), so the result remains JSON-serializable. Repeated sibling
references expand independently; relationships missing from included keep their
identifier instead of being dropped. This changes response shape only; stored
records and relationships do not require a data migration.
Plain conversion also shares one type/ID lookup across the included document. This removes repeated searches for large responses without changing call arguments or the expansion rules above. It does not impose a new response limit; large or repeatedly expanded graphs still need memory for their complete output.
For a hasMany related-resource read, the parent relationship no longer needs
search: true on the child foreign key or polymorphic discriminator fields.
Parent membership is applied internally alongside your permitted child filters,
before pagination and counts. A caller filter on a searchable parent relationship
is intersected with membership instead of being overwritten. Non-searchable
fields remain unavailable as public filters. Follow the returned pagination
links: they now retain the related-resource route, requested fields, filters,
and pagination parameters.
Many-to-many related reads now use the target resource’s collection query too.
Pass target filters, fields, includes, sort and pagination exactly as you would
to api.resources.books.query(...); the library intersects that query with
the parent’s membership. Results and counts describe distinct target records,
including when storage contains duplicate links. Default limits and sort now
come from the target resource. Follow returned links to traverse the collection.
Target query permissions and query-filter hooks run on these reads. Regular
storage also checks the declared pivot resource’s query permission and filters
its membership subquery. AnyAPI stores membership in tenant-scoped canonical
links. The former per-member GET calls and formatted pivot queries are removed;
move any custom behavior that relied on their GET/finish hooks to the target
query hooks or the appropriate row policy. No scoped consumer source currently
uses getRelated or those read hooks.
Resource GET/query and related reads now reject undeclared include paths with
REST_API_INCLUDE_INVALID (HTTP 400, source.parameter: 'include'), including
empty collections and absent to-one targets. Use declared relationship names,
not attribute names, and remove obsolete paths instead of relying on them being
ignored. Finite cycles remain valid; a polymorphic nested path may apply to a
subset of its declared target types. This follows the
JSON:API include contract.
Two filter corrections can change results: != null selects non-null values,
and oneOf with splitBy and matchAll: false matches any split term as
documented. A between filter now requires an array of exactly two non-null
bounds. Use an equality filter for one value; omit an optional range filter
when no bounds are supplied. Empty or oversized arrays no longer silently
remove the range condition. enablePaginationCounts: false at plugin level
now disables offset count queries. These changes require no stored-data migration.
Offset totals also count distinct primary resources when a search joins multiple
matching children, so duplicate join rows no longer inflate page counts.
To-one relationship identifiers now obey the target resource’s row policy and
autofilters even when you do not request an include. This applies to belongsTo
and polymorphic linkage on primary and included resources, and to full write
responses. A hidden target produces JSON:API data: null; plain output omits
that relationship field, following the existing null-belongsTo convention.
Stored foreign keys remain unchanged. Do not use a previously exposed hidden
target ID as an authorization shortcut. A borrowed transaction also governs
these visibility reads. Lookups are grouped by target resource type rather
than performed separately for each record.
AnyAPI now omits belongs-to foreign keys and polymorphic type/ID backing fields
from response attributes, matching regular storage. This also applies to sparse
responses, included resources and plain output. Read the declared relationship:
for example, use data.relationships.subject.data in JSON:API or record.subject
in plain output instead of attributes.subject_id and attributes.subject_type.
Hidden targets retain the null/omitted behavior above. Storage columns and write
linkage are unchanged; ordinary ID attributes that do not back a relationship
remain attributes.
Cross-resource and polymorphic search paths now check each joined resource’s
query permission and apply its row policy/autofilters with
queryPurpose: 'search-join'. Move mandatory predicates out of branches that only
handle collection. Related-only matches against hidden attributes disappear
from results and counts; a primary attribute can still match an OR search.
Explicit target permission failures reject the query, even if no primary rows
match. No per-target GET hooks run. Combined direct/nested polymorphic filters
now work in either argument order, including mapped tables and columns.
PostgreSQL text searches (like, contains, startsWith, endsWith) now work
on numeric relationship IDs by comparing their text representation. A pattern
such as "01" does not become the ID 1, and "1e0" does not become "1".
Existing string validation still applies, including trimming. Equality/range
comparisons retain the field’s type; text matching retains the database’s case
behavior. No call-site or stored-data migration is required for this correction.
The same visibility rule now applies to filters on relationship backing keys:
for example, filters: { author_id: id } and the declared author alias only
match a visible author. Polymorphic ID/type filters check the declared target
types together; identical IDs in different types remain distinct references.
Hidden and missing references both behave as null, including for !=, lists,
ranges and oneOf searches. Independent primary-attribute OR matches remain
eligible. These checks use the caller’s transaction and run before counts and
pagination. They do not change stored relationships.
Sorting by a relationship key or its declared alias now uses the same visibility
rule. This includes polymorphic ID/type fields and resource defaultSort.
Hidden and absent references sort together as null, with later sort fields and
the primary ID breaking ties. Cursor boundaries and generated links use those
same values: a hidden author_id appears as author_id:~null in a cursor,
never as the hidden author’s ID. Visible linkage and includes still work.
The target’s query permission and search-join policy apply to these lookups,
including offset pagination and queries without explicit pagination.
Start a new pagination traversal after upgrading, and follow returned links.
Null and array filters now survive generated pagination links. For manual HTTP requests, the explicit typed form is:
filter[author][json]=null
filter[authorIds][json]=["1","2"]
filter[authorIds][json]=[]
URL-encode these parameters normally. filter[name]=null still searches for the
literal string null; only the [json] suffix requests JSON parsing. Malformed
JSON returns HTTP 400. Generated links use this form for nulls, arrays and object
filter values, preserving embedded commas and empty lists. Programmatic calls
continue to pass JavaScript values in queryParams.filters. Use the returned
links directly instead of reconstructing or dropping these typed parameters.
Collection includes now honor the same limits and ordering on both backends.
AnyAPI previously ignored these settings. strategy: 'window' limits visible
children per parent, including reverse polymorphic collections; standard (the
default) limits the global target set. Under a global many-to-many limit, a shared
target counts once and remains linked to each of its selected parents. Duplicate
links do not consume slots, and selecting query projections no longer switches
away from per-parent limits. orderBy uses mapped target fields, relationship
aliases or SQL query projections, with nulls last and target IDs breaking ties.
Review callers that assumed an include returned every related record: the
included relationship linkage now describes its selected subset on AnyAPI too.
Use the related or relationship endpoint for full visible membership and its
pagination. An omitted limit uses the target query default; limit: 0 selects no
children and limit: null explicitly disables the include limit. Window includes
require database window-function support. The resource method signatures are
unchanged by this fix.
The unused deep helpers buildWindowedIncludeSubquery, buildOrderByClause and
applyStandardIncludeConfig were removed from
plugins/core/lib/querying/knex-window-queries.js. Configure relationships through
their include settings instead of importing storage query builders. No forwarding
exports or compatibility implementation remain. Check application extensions
for imports of the removed helpers.
Other unused deep exports have also been removed: createRequiredIndexes and
analyzeRequiredIndexes from knex-cross-table-search.js, isNonDatabaseField
from knex-field-helpers.js, translateSelectFieldsForAdapter from
storage-adapter.js, and getLogicalFieldName from storage-mapping.js.
Remove imports of these internal utilities. Use resource configuration and the
existing storage adapter when writing custom queries; create indexes through
reviewed schema changes. The automatic cross-table index advice was unused by
query execution and did not reliably identify the required indexes.
The standalone AnyAPI api.helpers.dataQueryCount helper has been removed.
Use resource.query({ queryParams: { page: { number: 1, size: 1 } } }) and read
result.meta.pagination.total when a pagination total is needed. This uses the
same filters and access rules as the resource’s returned records.
AnyAPI also allocates string slots for non-primary scalar fields declared
type: 'id', including polymorphic backing IDs. Such fields previously lacked
storage. Writes and filters bind the normalized ID as text, avoiding SQLite’s
numeric-to-TEXT conversion of 1 to "1.0". Newly supported ID fields are
allocated after the existing ordinary fields so those fields retain their slots
when an unchanged declaration is registered again. If you use an explicit
canonicalFieldsMap, assign each such field a free string slot. Back up and
inspect existing descriptors before migration; values never stored by the older
implementation cannot be recovered automatically. This fix does not provide
general schema-reordering or schema-evolution migration.
returning governs POST/PUT/PATCH resource writes. DELETE and relationship
mutations return undefined. Linkage methods retain one explicit representation:
getRelationship returns { data, links }, with resource identifiers, an array,
or null; relationship writes take relationshipData in JSON:API identifier
form. Their purpose is to manage linkage, so plain formatting does not flatten
that linkage document.
Arguments and request context
Keep controls in the first argument and request context in the second. This convention already matches jskit-ai and avoids moving every context field into every method’s parameter parser. A transaction belongs in the first argument:
await api.resources.books.patch({
id: bookId,
data: { title: 'Updated' },
format: 'plain',
returning: 'full',
transaction: trx,
queryParams: { include: ['author'] }
}, requestContext)
Forward both arguments when wrapping resource calls. HTTP relationship routes now forward the connector context too, and linkage reads forward a caller’s transaction to their nested resource read. No stored-data migration is required by these option and argument changes.
Review relationship writes
Reverse relationships (hasMany, hasOne, and polymorphic via) now update the
existing child records through their resource PATCH operation. Previous hasMany
endpoint calls could either fail on raw foreign-key validation or report success
without changing membership. Check for application workarounds that manually
patched children after one of those calls.
For a to-many relationship, POST adds members, PATCH replaces the complete membership, and DELETE removes only the specified members. Repeated identifiers and repeated successful add/remove calls do not create extra membership or delete child records. These semantics follow the JSON:API relationship update contract.
const relationship = { id: publisherId, relationshipName: 'books' }
const book = { type: 'books', id: bookId }
await api.resources.publishers.postRelationship({
...relationship, relationshipData: [book]
}, requestContext)
// Keep exactly this book; unlink other members.
await api.resources.publishers.patchRelationship({
...relationship, relationshipData: [book]
}, requestContext)
await api.resources.publishers.deleteRelationship({
...relationship, relationshipData: [book]
}, requestContext)
Use relationshipData: [] to clear a to-many relationship with PATCH. A to-one
PATCH takes one { type, id } identifier or null. Unlinking requires the child
foreign key to allow null explicitly (nullable: true); an optional field is
not automatically nullable. Target types and cardinality are validated on the
relationship routes as well as normal resource writes.
Normal resource POST/PATCH/PUT payloads can also supply reverse relationships.
PATCH changes only supplied relationships. For PUT, omitting the JSON:API
relationships object preserves reverse and many-to-many membership; supplying
that object clears any omitted reverse or many-to-many relationships. Stored
attributes and belongs-to foreign keys still follow PUT’s complete replacement
validation. Include every relationship you intend to retain when supplying a
PUT relationships object.
Child PATCH hooks and permissions now run for actual reverse-membership changes,
with the caller context and transaction. A replacement that cannot unlink an
inaccessible existing child fails rather than leaving a partial relationship.
A transaction owned by the operation rolls back earlier changes on failure.
The library leaves completion of a caller-supplied transaction to its owner,
which must now be api.transaction for library writes. A rejected participating
write aborts that unit even when caught; a native database error may already have
aborted its SQL transaction. Concurrent replacements now serialize on the parent, and
many-to-many additions lock related records too, including additions through
opposite endpoints. This prevents merged replacements, duplicate pivot rows,
and multiple children left behind by competing hasOne replacements. Belongs-to
and polymorphic writes also lock and recheck their targets after validation;
a target deleted in that interval causes the write to fail.
Reverse additions and replacements now use the same target-identity locking helper. Equivalent ID spellings under the database’s equality rules identify one child: retaining it does not invoke PATCH again, and adding it through multiple equivalent spellings writes it once. A removal still ignores missing or unrelated children. No method arguments change.
Reverse membership reads now use batches of at most 100 requested IDs. A replacement walks removed children in database ID order, reading at most 101 identifiers at a time and unlinking them before any additions. Keep child hooks independent of a database’s former incidental row order. Hooks and permissions still run for each changed child, and the enclosing transaction owns rollback; this does not turn child writes into a bulk SQL update.
Canonical many-to-many attachment also pages existing physical links: at most 100 requested target IDs and 101 returned edge rows per query. Pre-existing duplicate edges and their payloads remain intact, and inverse metadata is still repaired on the first retained match. A duplicate-heavy batch can issue more SQL statements while keeping read memory bounded. Arguments, resource hooks, target/edge locks and transaction ownership are unchanged; no data migration is required for this paging change.
Relationship mutations can issue an UPDATE that preserves the parent’s ID even
when no attribute changed. That row version change makes stale PostgreSQL
repeatable-read transactions fail instead of applying an old membership snapshot.
Database UPDATE triggers on the parent may therefore run for relationship-only
writes. Resource hooks still follow their existing operation lifecycle; these
internal locks do not invoke a second resource PATCH hook.
If you use per-table database grants, account for parent UPDATEs and target
SELECT ... FOR UPDATE statements when configuring the application’s database
role.
Treat native deadlock, lock-timeout and serialization errors as failed transaction
attempts. Let api.transaction roll back, then retry the complete callback in a
new unit only when its side effects can safely repeat. SQLite permits one active
writer and can reject a stale read snapshot’s write upgrade. The library does
not automatically retry or change the caller’s isolation level. The core managed
helper owns completion; migrate application transaction wrappers accordingly.
Existing duplicate pivot/link rows are not automatically removed. Inspect any
previously concurrent relationship workloads and reconcile duplicates while
preserving their pivot metadata. For a regular pivot, group by its two mapped
foreign-key columns; for canonical any_links, group by tenant_id,
relationship, left_resource, left_id, right_resource and right_id.
Groups with more than one row need reconciliation. This fix requires no column
migration and does not replace database constraints used by direct SQL writers.
Canonical many-to-many removal and replacement now delete the selected links in
scoped SQL batches instead of reading the complete old membership and deleting
each link individually. Both stored directions are recognized, including links
created before an inverse was declared. Retained links keep their row IDs and
payloads; attachment repairs missing inverse metadata in place. Replacement
with relationshipData: [] also clears matching links whose inverse metadata
is missing. Tenant, relationship and target resource type all constrain the
write, so another resource that reuses a target ID is left intact. No call-site
or schema migration is needed for this batching change. If you instrument SQL,
expect fewer DELETE statements and additional target locks for retained links
during replacement; transaction ownership and the resource hook lifecycle stay
the same.
Transactions and errors
A failed call can represent a committed write. When commit acknowledgement is
lost, unknown does not tell you whether the database stored the change. Keep
an application operation identity and reconcile persisted state before replaying
the write or deleting tracked uploads. No commit/rollback side-effect hooks run
for an unknown outcome. See the commit uncertainty contract
for the evidence used and the limits of failure-injection coverage.
Migrate transaction wrappers to api.transaction(callback, context?). Its callback
receives a real Knex transaction for resource calls and raw SQL; the library owns
completion and deferred hooks. Pass that handle through related operations,
using a separate context for each enlisted operation. Remove overlapping
transaction ownership from application wrappers.
Unmanaged raw transactions and child savepoints cannot own library writes. Raw Knex transactions still support direct SQL, low-level storage helpers and read-only API operations. See the managed transaction contract.
// Before: a Knex callback owned library writes.
await knex.transaction(transaction => changeBooks(transaction))
// After: the library owns writes, raw SQL and deferred completion hooks.
await api.transaction(async transaction => {
const book = await api.resources.books.patch({
id: '42', data: { title: 'Updated title' }, format: 'plain', transaction
})
await transaction('audit_entries').insert({ book_id: book.id })
return book
})
Await every operation and let the callback return normally to commit. Throw to
roll back. A caught participating write or observed SQL failure still aborts
the unit. Do not call commit() or rollback() yourself inside the callback.
Pass the received handle through helper functions to compose work; calling
api.transaction again starts a separate top-level unit.
Completion ownership is private to the library. Remove any hook code that sets
context.shouldCommit to take over or suppress completion; that diagnostic flag
does not control it. Each operation’s completion hooks receive its own context.
Every accepted resource/relationship operation runs its completion chain once,
including nested PATCH calls made by relationship replacement and reverse-child
updates. Update observers that assumed one completion hook meant one database
commit. Transaction and bulk owners do not add a synthetic resource hook chain.
On rollback, failed operations retain their original error, while successful
operations receive the owner’s failure. Inspect secondary completion errors on
the outer helper’s context.
Reusing a context after a successful write no longer carries the previous commit flag into a later operation. A rejected write rolls back its own new transaction, while an operation borrowing a caller’s transaction leaves its completion to the caller. This also fixes connection exhaustion after denied entries in non-atomic bulk calls that reuse authenticated context. PUT validation also uses the current payload and record rather than attributes retained from a previous call. Omitting populated attributes still fails a replacement; creating a new ID does not inherit the old record’s requirements.
Write failures now use the root export RestApiWriteError. Read failures retain
their existing contract. The wrapper’s cause is the original thrown value and
its read-only transactionOutcome is none, pending, committed, rolledBack
or unknown. Existing code, subtype and other ordinary diagnostic fields
remain readable directly. Code that compares original error identity or checks
the original class with instanceof must inspect the cause instead.
import { RestApiWriteError } from 'json-rest-api'
try {
await api.resources.books.post({ document, format: 'jsonapi' })
} catch (error) {
if (!(error instanceof RestApiWriteError)) throw error
console.error(error.code, error.transactionOutcome, error.cause)
throw error
}
A nested write can be another RestApiWriteError in the cause chain. For example,
an atomic batch preserves its child’s pending snapshot while reporting the
outer transaction as rolledBack. HTTP JSON:API errors add
meta.transactionOutcome; non-atomic bulk errors add
error.transactionOutcome per entry. Status codes alone do not establish whether
a write committed. A committed or unknown failure must not trigger blind
replay, and pending leaves the decision to the transaction owner. Even confirmed
database rollback says nothing about external effects already performed.
For example, a POST using a generated ID can commit a new book, then reject
because an afterCommit notification fails. Repeating that POST creates another
book. The first error reports committed; it is a failed call with a stored
write. A lost commit acknowledgement instead reports unknown, which also
cannot authorize a blind retry. Handle the failed side effect or reconcile the
write using application identifiers rather than replaying it automatically.
Errors before transaction setup—including malformed bodies, oversized requests
and explicit request rejection—report none. A response-hook failure after a
successful write reports committed; adding that failure does not erase an
earlier write’s confirmed outcome. Read-only HTTP errors have no write-outcome
metadata.
Owned transaction completion now also checks Knex’s executionPromise. Knex can
resolve commit() or rollback() while reporting a failed control query through
that promise. Such failures now reject the write or become rollback diagnostics;
they no longer run the corresponding completion hooks or publish committed
registry metadata. Existing payloads and stored data need no migration. A rejected
commit still does not prove rollback, including when the driver reports itself
completed. The outcome contract explains
the evidence and remaining verification boundaries.
PostgreSQL can also acknowledge COMMIT by reporting ROLLBACK after an earlier SQL error was caught. Those writes now reject instead of reporting success. The library recognizes the confirmed rollback, runs existing resource rollback cleanup, and avoids commit hooks or cache publication. It does not issue a second rollback. This correction requires no payload or stored-data migration; an application hook that catches a database error can now expose a failure that was previously mistaken for a successful write.
Custom transaction factories must return top-level transactions. Returning a
savepoint previously allowed afterCommit or registry cache publication before
the parent transaction committed. Owned completion now rejects that case and
rolls back the savepoint; it leaves unrelated parent work and parent completion
with the caller. The failure reports rolledBack only when savepoint rollback
is confirmed. Managed acquisition now rejects that factory result before
resource hooks or uploads run. Explicit raw savepoints passed in transaction
also reject before library writes; their raw SQL and completion remain the
caller’s responsibility. No schema or stored-data migration is needed.
Resource and relationship write failures now retain the original error when
rollback, an afterRollback hook, or error logging also fails. The supplied
operation context exposes the original error and cleanupErrors, an ordered array of
{ phase, error } entries. Phases include rollback, afterRollback and logging.
File cleanup adds the phases and field information described under multipart
uploads; diagnostics gathered before rollback are retained.
Managed completion records rejected hook chains with operationIndex,
scopeName and method, using afterCommit or afterRollback. Internal
diagnostic-collection failures use finalization and finalizerIndex.
Later completion work is still attempted after an earlier failure. On confirmed
commit the first completion failure is primary; on rollback the original write
or callback failure remains primary. See the
managed completion contract.
These diagnostic fields are cleared at the start of the next write, including
when its context is reused. The original error object is not mutated; frozen
errors retain their identity as the wrapper’s cause. A failure rejected before
transaction setup has outcome none and its own original context.error.
afterRollback receives the original failure as context.error and runs only
after rollback is confirmed, including PostgreSQL reporting ROLLBACK for COMMIT.
If rollback rejects without confirmation, the library retains
that secondary failure and does not claim rollback succeeded or run rollback
cleanup hooks. A participating write leaves completion to its managed owner. Asynchronous
error logging is awaited, and its rejection is recorded without replacing the
write error. These fields are diagnostics separate from the wrapper’s immutable
transaction outcome. Atomic bulk rollback uses the same diagnostic shape, as described above.
Remaining cleanup and extension-error boundaries are still under review; the
rollback-hook and logging guarantees above describe resource and relationship
methods.
The shared failure handler also checks the driver’s completion state before
attempting rollback. A commit wrapper that rejects after actual completion
cannot trigger rollback hooks that delete committed uploads. Completion alone
does not prove which outcome occurred; without confirmation the error reports
unknown.
Query-projection and include errors retain their original causes, including
null, undefined and other non-Error throws. Typed API errors propagate
unchanged through reads and become the write wrapper’s cause when raised during
a write. Unexpected projection errors have a wrapper with resource/field
context and cause. Include errors retain their context through the cause
chain. Redundant logging in nested include catches has been removed so it cannot
replace the failure. GET/query reject an affected response; full write-response
failures occur before owned commit, and borrowed transactions remain active for
their owner. Callers should classify typed errors and inspect causes rather
than match the wording of unexpected-error messages. Field callbacks follow
the policy below; arbitrary hook-dispatch failures remain under review.
Direct canonical registry reads and explicit descriptor refreshes retain unexpected causes with
context: { tenant, scopeName, phase: 'descriptor' }. Typed API errors keep
their identity. The direct registry lookup returns null for absent persisted
metadata; a required refresh rejects without replacing the published resource.
Canonical resource operations use published descriptors, so a cold or failing registry read no longer blocks an otherwise valid CRUD/relationship request. Required published metadata that is missing still rejects, as do invalid stored relationship targets and data-query failures. Data reads/writes continue using the active transaction. Borrowed transactions remain active for their owner to complete. CRUD arguments and response shapes are unchanged; direct registry changes take effect only through the publication steps above.
POST, PUT and PATCH also reject an unexpected failure while refreshing
context.minimalRecord after writing. Previously this failure could be logged
and ignored, allowing a successful response and completion hooks with stale
state. The cause chain retains the failure and resource context with phase
postWriteRead; typed errors retain their identity within that chain. This refresh runs for
returning: 'full', 'minimal' and 'none'. It happens before finish hooks and
owned commit, so a failure rolls back owned changes, including relationship
updates. Borrowed transactions remain active for the caller to complete.
Callers need no new option. Handle the rejected operation and correct the
metadata/storage failure instead of relying on partial success. Custom
dataGetMinimal helpers must propagate read failures; they cannot rely on the
library ignoring them. Express and Fastify send an error response for failures
in request data reads and writes (500 for unexpected failures, the original
status for typed errors). Configuration-read failures outside a request do not
invalidate an already published resource. These rules do not change post-commit
outcome uncertainty.
Verify the port
- Confirm the library resolves to the intended v2 artifact, and each app resolves to the matching versions of its shared repositories and generated code.
- Run library checks and affected application tests; an old installed dependency passing tests does not verify the port.
- Exercise a create/read/update/delete flow, scoped authorization, and a complete pagination traversal in each relevant app. Check a multi-operation rollback wherever transaction ownership changes.
- Regenerate affected source and confirm it emits the new calls directly.
- Recheck call sites added during the migration, and remove obsolete wrappers and imports once their replacements pass.
Record the verification commands and artifact versions with each application upgrade. Apply these steps to seed generators and generated templates as well as checked-in application code.
TypeScript imports
The package exposes declarations through json-rest-api itself:
import { RestApiPlugin, RestApiWriteError } from 'json-rest-api'
import type { RestApiPluginOptions, ResourceCoreMethods, ResourceVersionOptions } from 'json-rest-api'
const restOptions = {
format: 'plain',
returning: 'minimal'
} satisfies RestApiPluginOptions
// The resource schema must also declare revision as a stored string attribute.
const versionOptions = { versionField: 'revision' } satisfies ResourceVersionOptions
interface Book { title: string }
async function readTitle(books: ResourceCoreMethods<Book>, id: string) {
const book = await books.get({ id })
return book.title // string | undefined: sparse fields can omit title
}
Use normal value imports for plugins, storage classes and errors; use type-only
imports for option and resource interfaces. No runtime files live under the
declaration types/ directory. TypeScript consumers need Node and Knex types for
the current declaration surface. Framework peers are still optional at runtime.
For ordinary literal schemas, retain the handle returned by addResource() to
infer attribute names and value types. ResourceSchema, InferInput and
InferOutput also support reusable definitions. Existing JavaScript setup and
call syntax stay the same. The library’s npm run test:public-types checks these
declarations through a packed consumer.
An explicit JsonRestApi resource map remains the choice for typed central
registries, relationships, dynamic enrichment, method replacements and custom
API-level defaults. Match its format/returning generics to the actual resource
configuration. Plugin installation itself does not infer options or methods;
use satisfies with the plugin’s option interface. Literal resource registration
checks built-in field and resource options. Custom plugin configuration uses an
explicit intersection with its own interface. See the
typing contract and limits.
Known hooks now describe phase-specific input and library-owned fields. This
does not change mutable context at runtime. If a hook receives an ownership type
error, inspect the documented mutation phase rather than casting it away.
Use HookHandler<Event, AppContext> for known hooks and
RuntimeHook<PluginContext> for arbitrary plugin events; see the
hook guide.
Object and array attributes
Declare structured fields using their public JSON type:
schema: {
metadata: { type: 'object', nullable: true },
tags: { type: 'array', nullable: true }
}
Built-in storage writes JSON text and reads it back as objects or arrays, in both plain and JSON:API responses. Nested JSON values, empty objects/arrays and SQL null are preserved. Canonical storage now accepts arrays in its existing JSON slots; adding this support does not alter existing tables.
Remove setters whose only purpose was JSON.stringify. A setter for an object
or array must return that logical type; it can still prepare or transform its
contents. Reads now return structured values, so remove application-level
JSON.parse calls that expected these fields to be strings.
// Before: storage conversion was done in a setter.
metadata: { type: 'object', setter: value => JSON.stringify(value) }
// After: built-in storage owns JSON conversion.
metadata: { type: 'object' }
If a custom storage representation is necessary, keep its conversion in
storage.serialize and decode it with a getter. The getter receives the driver’s
stored value: native JSON columns may return an object, while text columns
return a string. For example:
metadata: {
type: 'object',
nullable: true,
storage: {
serialize: value => value == null ? null : JSON.stringify({ wrapped: value })
},
getter: value => value == null
? null
: (typeof value === 'string' ? JSON.parse(value) : value).wrapped
}
Getters decode custom representations before the final declared shape check.
Built-in structured values are decoded before getters on dependent fields and
computed callbacks run. A stored value with the wrong declared shape fails the
read. Built-in writes reject wrong-shaped setter results, circular references
and BigInt values before SQL, including with returning: 'none'. Supply JSON
compatible contents; the default serializer uses JavaScript JSON.stringify.
Querying structured attributes
Whole-object/array equality and ordering are not generic library operations.
Remove search: true, generic search aliases/oneOf references, explicit
sortableFields, and default sorts targeting those fields. A structured
projection cannot declare sortable: true. Local invalid declarations reject
schema compilation; relationship paths reject when resolved, allowing forward
resource declarations. A request to sort a structured field returns a validation
error (HTTP 422) before SQL. Direct adapter comparison conversion also rejects
structured fields, including null and empty arrays, before invoking serializers.
Array operands for in and between on scalar stored fields remain supported.
Expose the JSON key that gives the application a meaningful order as a scalar
projection. For example, this selects an integer metadata.value:
queryFields: {
metadataValue: {
type: 'integer',
sortable: true,
select: ({ knex, column }) => {
const field = column('metadata')
if (knex.client.config.client === 'pg') {
return knex.raw('CAST(CAST(?? AS json) #>> ?::text[] AS integer)', [field, '{value}'])
}
if (knex.client.config.client === 'mysql2') {
return knex.raw('CAST(JSON_UNQUOTE(JSON_EXTRACT(??, ?)) AS SIGNED)', [field, '$.value'])
}
return knex.raw('CAST(json_extract(??, ?) AS INTEGER)', [field, '$.value'])
}
}
}
Then use queryParams: { sort: ['metadataValue'], page: { size: 20 } }.
Scalar projections support sparse output and forward/backward cursor traversal.
Sorting and cursor comparisons operate on the selected SQL value; output getters
format that value afterwards. Custom storage serializers must produce values
compatible with the physical column, and custom scalar cursors must satisfy the
declared scalar type. A getter does not make an incompatible stored cursor valid.
For application-specific JSON filters, use searchSchema.applyFilter with an
explicit SQL predicate and bound values. This callback owns the predicate’s
meaning and value encoding. For example, a null-presence filter for built-in JSON
storage can resolve the physical column without hard-coding canonical slots:
searchSchema: {
hasMetadata: {
type: 'boolean',
applyFilter: (query, present) => {
const column = api.knex.helpers.getStorageAdapter('books').translateColumn('metadata')
return present ? query.whereNotNull(column) : query.whereNull(column)
}
}
}
The example belongs to the books resource. Use the query hook’s adapter and
alias when a custom filter introduces joins. Whole-document custom comparisons
must account for the chosen database’s JSON semantics.
PostgreSQL JSON selections now use to_jsonb so relationship filters can remove
duplicate parents with DISTINCT. This applies to stored structured fields and
structured projections. Public object/array values are preserved, and existing
tables are not altered. Custom structured storage should use JSON-compatible SQL
representations, such as JSON columns or JSON-encoded text.
Temporal values and storage serializers
Use JSON values in both plain and JSON:API write records:
| Field type | Input example | Public read value |
|---|---|---|
date |
'2024-02-29' |
'2024-02-29' |
dateTime |
'2024-03-01T07:59:59.987+08:00' |
'2024-02-29T23:59:59.987Z' |
time |
'09:30' or '09:30:00.123' |
'09:30:00' or '09:30:00.123' without a declared precision |
epochMilliseconds |
1709251199987 or '1709251199987' |
1709251199987 |
epochSeconds |
1709251199 or '1709251199' |
1709251199 |
Client date/dateTime fields take strings, not JavaScript Date objects or SQL
datetime text. Use null to clear a nullable field. Epochs must be integers:
zero and negative epochs are valid; the absolute limits are 8640000000000000
milliseconds and 8640000000000 seconds. Calendar dates must exist. DateTime
offsets must normalize to a UTC year between 0000 and 9999; values outside that
range now fail validation before built-in storage, including writes requesting no record.
temporalPrecision limits fractional digits. Built-in dateTime conversion uses
JavaScript Date and supports milliseconds. It rejects nonzero submillisecond
digits; declaring precision 6 alone does not enable six-digit writes. A value
ending in .123000Z can be stored as .123Z. Database strings can retain finer
precision when read. Time fields remain strings and do not require a Date
conversion. Getters, computed fields and response hooks may return native Date
or BigInt values; the declared temporal field type normalizes the final response
and rejects invalid values.
Keep storage.serialize(value, details) functions on stored attributes.
Both storage modes honor them for writes and filters.
The function receives the logical fieldName, actual columnName, definition,
schemaInfo, write context, and operation (post, put, patch or filter).
Comparisons use operation: 'filter' and a null context. Return the same storage
representation for equivalent values on writes and comparisons. The callback
must be synchronous; returning a Promise now fails before the SQL operation,
including when no write response is requested. Use field setters for asynchronous preparation. Custom temporal
serializers must preserve the required precision, normalize offsets consistently,
and return values supported by the chosen SQL column/driver and read normalizer.
Temporal formatting and native databases
Time reads now always include seconds. With temporalPrecision: 3, both
'09:30' and '09:30:00.000' read as '09:30:00.000' and compare equally.
Without a declared precision, redundant fractional zeros are removed:
'09:30:00.100000' reads as '09:30:00.1'. Update assertions that depended on
the submitted spelling. DateTime reads use UTC, retain meaningful microseconds,
and keep at least millisecond digits unless a smaller precision is declared;
.100000Z reads as .100Z, while .123456Z remains unchanged.
Library reads request temporal strings from PostgreSQL/MySQL so driver Date
conversion cannot discard microseconds or shift calendar dates. These options
apply to library queries; the supplied Knex instance’s connection configuration
and ordinary caller queries are unchanged. PostgreSQL uses its normal ISO date
output. Built-in PostgreSQL writes/comparisons bind explicit UTC values and map
public year 0000 to PostgreSQL’s 1 BC. MySQL native DATE/DATETIME writes reject
years below 1000 with storage_range, following its documented supported range.
Use a custom text column/serializer if the application needs earlier MySQL dates.
See node-postgres temporal parsing
and MySQL date ranges.
New table creation and generated migrations use fractional precision 6 for
dateTime/time columns when no precision is declared. An explicit precision
still takes precedence. This avoids MySQL’s default precision 0 rounding a
timestamp into the next second or day. Existing regular tables need an ordinary
schema migration to the intended precision; table creation does not alter them.
Preserve each column’s nullability, defaults and other modifiers in that migration.
The regular-table diff now detects temporal precision changes. PostgreSQL time
columns created through older Knex helpers may report precision 6 even when the
resource declared precision 3: the previous time builder ignored that argument.
New table creation and migration code declare time(n) explicitly. Inspect live
precision before changing existing columns; reducing it can round stored values.
Native setValues columns require an explicit MySQL target when generating a
migration, for example { dialect: 'mysql2' }. Runtime create/add/alter helpers
reject SET columns on other clients before SQL. A cross-dialect migration diff
continues to warn and omit unsupported SET changes; review those warnings rather
than treating the generated supported subset as a complete schema migration.
Schema helpers now reject invalid temporalPrecision before executing SQL.
Native PostgreSQL/MySQL time/dateTime columns require an integer from 0 through 6.
For finer precision, use a text column and an appropriate storage mapping. When
intentionally generating a higher-precision SQLite definition, pass an explicit
dialect: 'better-sqlite3'; an unspecified migration target is rejected for
precision above 6. Migration diffs use their explicit or introspected dialect.
Runtime create/add/alter helpers use the actual database client, regardless of a
supplied migration-dialect option. SQLite column declarations remain distinct
from the built-in dateTime converter’s millisecond write limit.
Increasing precision cannot restore digits already discarded by older writes. Native PostgreSQL/MySQL TIME columns reject input with more than six meaningful fractional digits before writing; SQLite and AnyAPI text time slots can retain finer values. MySQL fractional-second behavior also explains why directly injecting extra digits into a lower-precision column can round them before the library reads them.
For custom PostgreSQL serializers, include an offset on timestamp-with-time-zone
values, such as '2024-02-29T23:59:59.123456Z'. Offset-free timestamp input uses
the server session’s time zone. A custom serializer must make that choice
explicit and use the same representation for comparisons.
Query projections compare the SQL expression’s stored value directly, retaining
microsecond precision without a write serializer. Remove storage from
queryFields declarations; it is now rejected during compilation. Keep the
serializer on the underlying stored attribute.
Custom serializer cursors and projection getters
Cursors for serialized attributes and SQL projections now retain the database
value before getters run. Following a cursor binds that value directly instead
of serializing it again. For example, a serializer that stores "alpha" as
"stored:alpha" previously produced a next-page comparison against
"stored:stored:alpha". Numeric scaling had the same problem.
Restart pagination after upgrading and use returned cursors or links unchanged. Do not build a cursor from response attributes: getters can change the value, and database temporal spelling can differ from the JSON representation. Scalar cursor validation still checks the declared type; temporal validation accepts the supported database spelling without changing the bound value. Sort order is the SQL value’s order. A getter does not change that order.
Projection declarations no longer take storage, setter, or runSetterAfter.
For example, this precise timestamp projection needs no serializer:
queryFields: {
preciseTimestamp: {
type: 'dateTime',
temporalPrecision: 6,
sortable: true,
select: ({ knex, column }) => knex.raw('??', [column('recordedAt')])
}
}
Projection getter and runGetterAfter declarations now execute through the
same compiled dependency order as stored-field getters. They were previously
ignored. Getters may be async; computed fields receive their completed values.
Remove a previously ignored getter if its behavior was not intended. Invalid
callbacks/dependencies reject compilation, and getter failures propagate as
read errors before computed output runs.
Resource ID fields and relationship backing ID/type fields cannot declare
storage.serialize, including declarations added by enrichment or canonical
field additions. The previous behavior could change an ordinary foreign key
while canonical storage bypassed the write callback and only serialized filters.
Remove these serializers; use resource normalizeId for ID canonicalization.
Already transformed stored IDs require an explicit application data migration;
removing a callback does not repair existing data automatically.
Existing AnyAPI temporal data
New AnyAPI resources allocate date and time fields to string slots. Calendar
dates and times of day do not need a timestamp column. dateTime fields retain
the date_N slots, now with fractional precision 6. Existing metadata assigning
date/time fields to timestamp slots, or native canonical timestamp columns with
lower precision, causes an explicit migration-required error.
Resource re-registration checks the persisted mapping before replacing it, so
starting the new API cannot silently rebind unmigrated temporal fields to empty
slots. Complete the data migration before registering those resources again.
For disposable development data, recreate the AnyAPI database and run the seeds against the new schema. For data that must be preserved:
- Back up the database and stop application writers for the migration.
- Inventory date/time fields from
any_resource_configs.schema_jsonand their mappings inany_field_configs. Reserve unusedstring_Nslots per resource; the existing limit is ten string slots. Update any explicitcanonicalFieldsMapdeclarations to those allocations. - Copy each old field into its reserved string slot on
any_records, selecting only that metadata row’s tenant and resource. UseYYYY-MM-DDfor dates and the time format above for time-only values. Inspect the raw stored values and the original write time zone: an old API read may already have shifted a date or discarded precision. Keep the old slots until verification is complete. - Update that field’s
slot_typetostring,slot_indexto the reserved index, andslot_columnto itsstring_Nname. Keep logical IDs, tenant/resource identifiers and relationship rows unchanged. Do not recreate metadata for unrelated fields or renumber their slots. - On PostgreSQL/MySQL, migrate
any_records.date_1throughdate_5to timestamp/ datetime precision 6, preserving the PostgreSQL time-zone type and column modifiers. Even an unused canonical timestamp slot must have precision 6. - Restart the library to reload descriptors. Verify row counts, date/time values, nulls, relationships, exact filters and complete cursor traversals against the backup before resuming writers.
The repository includes an executable example at
examples/migrations/anyapi-temporal-v2.js. Its named
migrateAnyApiTemporalFields(transaction, moves) function implements steps 3–4
in batches of 100 records. It requires an explicit source/target mapping and a
conversion callback for each field. It checks the persisted field type and source
mapping, rejects occupied targets (including unassigned slots containing data),
validates converted strings against the declared temporal precision, and leaves
old slots and unrelated metadata intact. Nulls are copied without invoking the
callback. Soft-deleted records are included.
For example, only if the original application stored dates at UTC midnight and times as UTC-anchored timestamps or bare time strings, the conversion can be:
import { migrateAnyApiTemporalFields } from 'json-rest-api/examples/migrations/anyapi-temporal-v2.js'
import { normalizeDateValue } from 'json-rest-api/plugins/core/lib/querying-writing/database-value-normalizers.js'
const moves = [
{
tenant: 'your-tenant', resource: 'events', field: 'day',
from: 'date_1', to: 'string_8',
convert: value => normalizeDateValue(value, 'date')
},
{
tenant: 'your-tenant', resource: 'events', field: 'atTime',
from: 'date_2', to: 'string_10',
convert: value => typeof value === 'string' && /^\d{2}:/.test(value)
? value
: normalizeDateValue(value, 'dateTime').slice(11, -1)
}
]
const report = await knex.transaction(trx => migrateAnyApiTemporalFields(trx, moves))
Replace every name and slot with the inventory from your database. Converters
receive the raw value and { tenant, resource, field, id }, may be asynchronous,
and must return a valid string for every non-null value. PostgreSQL/MySQL reads
retain raw temporal strings so the driver cannot discard microseconds before
conversion. Use a different converter for a local calendar/time convention or
custom serializer; the example does not infer the original time zone. Update
any serializer that still expects the old timestamp representation.
Let a conversion error escape the transaction callback so Knex rolls back all copied records and mapping changes. The example neither commits nor rolls back the caller’s transaction. Rerunning completed moves fails the source-mapping check; inspect metadata instead of treating that error as success.
For step 5, the following is the tested precision change for standard, nullable canonical columns with no defaults and Knex’s default timezone type:
// PostgreSQL/MySQL only; run separately from the data transaction.
await knex.schema.alterTable('any_records', table => {
for (let index = 1; index <= 5; index++) {
table.dateTime(`date_${index}`, { precision: 6 }).nullable().alter()
}
})
Inspect column definitions first and adapt this DDL if their type, nullability, default or other modifiers differ. MySQL DDL can commit implicitly; the copy transaction and this precision change are separate operations. Keep writers stopped if either fails. Increasing precision preserves existing values but cannot recover fractional digits lost in earlier writes.
In the restarted resource declarations, preserve all existing allocations in
canonicalFieldsMap, changing only the migrated fields. A supplied map must
include every stored non-ID field. For the test resource, the complete map is:
canonicalFieldsMap: {
name: 'string_1', day: 'string_8', atTime: 'string_10',
occurredAt: 'date_3', personId: 'rel_1_id'
}
tests/anyapi-temporal-migration.test.js executes this example and the standard
precision DDL on SQLite, PostgreSQL and MySQL, with fresh API initialization,
overlapping logical IDs in two tenants, relationships, nulls, exact filters,
forward/backward cursors, failed conversions and caller rollback. These are
isolated synthetic migrations. Verify the conversion against a copy of each
application database and update its seed generators before deployment.
AnyAPI’s duplicate private filter pass and its unused deep helper exports
coerceValueForDefinition, normalizeFilterValues and ensureFilterableField
were removed. Declare filters in the resource/search schema and use ordinary
resource queries. Remove direct imports of these private helpers if your
application has any.
Schema migration helpers
generateKnexMigrationDiff() now preserves undeclared indexes and foreign keys
by default, as it already did for columns. A resource declaration need not
describe every constraint in an application’s database. Check warnings for
preserved metadata. For an intentional removal, pass the corresponding boolean
in options: allowDropColumns, allowDropIndexes or allowDropForeignKeys.
These options reject non-boolean values. Explicit changes to a named index or
foreign key can still produce a replacement, with a warning.
Column drops reject when their plan retains a dependent index or foreign key. Full snapshots also reject generated columns and SQLite partial/expression indexes, instead of reporting an incomplete comparison of those features. Use an authored migration for database features outside the snapshot model. Review warnings about required-column backfills and narrowing integer ranges. Generation does not execute DDL; keep migration execution/history in the app’s Knex workflow and write recovery steps deliberately. See diff migrations.
Regular addKnexFields and alterKnexFields now honor resource-level
storage.naming. Under naming: 'exact', loginCount addresses that exact
column. Inspect tables previously changed through these helpers for unintended
snake-case columns; this fix does not rename or move existing data. Retain
explicit storage.column mappings in supplied field definitions.
AnyAPI addKnexFields({ fields, canonicalFieldsMap?, searchSchema? }) now persists
added definitions and refreshes the compiled schema, including defaults,
transforms and cached request contracts. A multi-field call uses one metadata
transaction. Existing records remain unchanged; backfill old rows separately.
Missing fields, duplicate fields and invalid maps now throw instead of silently
succeeding or leaving unusable metadata. See
AnyAPI field additions.
Explicit maps take effect during registration, before createKnexTable().
Repeating that method no longer re-registers the initial schema and discards
additions. Keep the expanded declaration and complete map in app source for
restart, including functions that JSON metadata cannot reconstruct.
Deep AnyapiRegistry writes must receive a transaction from api.transaction
when joining an existing unit. addKnexFields() uses this owner for its metadata
batch internally. Direct registry callers must invalidate the registry descriptor
cache after confirmed commit. Transaction-local reads use that
transaction and bypass the global cache, preventing rolled-back metadata from
leaking through cached descriptors. This does not publish new resource metadata.
Resource operations use resource.vars.schemaInfo.descriptor, the same published
mapping as their storage adapter. CRUD, counts, includes and relationship calls
do not query configuration tables or apply direct registry changes implicitly.
Register related resources on the API before querying them; persisted registry
entries alone do not supply runtime resource declarations.
Use addKnexFields() for supported field additions; it commits metadata before
publishing the new compiled schema and adapter. After a direct, committed mapping
migration with the same declarations, invalidate the registry cache and call
createKnexTable() to refresh the descriptor. A failed refresh retains the
previous published resource. Structural schema changes also need updated source
declarations and compilation; invalidation alone cannot reconstruct callbacks.
Perform configuration/mapping changes while application operations are stopped.
Arbitrary in-place metadata edits or concurrent reconfiguration are not a
supported publication mechanism. No automatic data migration is performed.
The internal preloadRelatedDescriptors helper now accepts
{ descriptor, scopes } and returns its map synchronously; scopes is the
resource collection. It no longer accepts registry/transaction arguments.
AnyapiQueryAdapter no longer stores its unused registry argument. Deep callers
must use the published resource metadata when building related query maps.
Owned registry registration/allocation now preserves the original failure when
rollback also rejects; secondary diagnostics go to the registry error logger.
Failed owned writes invalidate cached descriptors, including when a commit
call rejects after database completion. A completed transaction is not rolled
back again, and its next descriptor read reloads persisted metadata. Rejection
does not by itself establish that a write was rolled back or can safely be
retried. The descriptor cache also isolates tenant/resource pairs containing
::; this cache-key change does not alter persisted data or method arguments.
The registry now retains at most 100 recently used descriptors. This is a cache
bound, not a resource limit: an evicted descriptor reloads on the next explicit
registry lookup, while normal resource operations continue using their published
metadata. Direct registry callers must pass { transaction } when borrowing
one; do not rely on a warm cache to avoid acquiring another database connection.
An explicit { bypassCache: true } read that confirms removal now discards its
stale cache entry. It does not remove a running resource or publish a new schema.
No data migration or resource declaration change is needed for cache eviction.
Request-contract reuse now includes the resource name in its key. Deep callers sharing compiled field definitions across resource names receive the correct JSON:API type validation. Temporal normalization still accepts its supported precision declarations; unusual precision simply builds an uncached validator. Neither correction adds a caller option or requires a payload migration.
Schema inspection now supports PostgreSQL’s current schema. Generated migrations
preserve BigInt defaults as BigInt literals, and unchanged effective defaults or
implicit size metadata no longer cause repeated alterations. Diff plans now
include dropCheckConstraints for PostgreSQL inline-enum replacements. Generated
enum diffs execute on PostgreSQL/MySQL; SQLite diffs report a required table rebuild
and retain the existing enum definition. This is distinct from changing only an
enum default, which is supported on all three databases. See the
schema and migration guide for execution
and transaction limits. These helpers do not automatically migrate existing
application databases.
Direct alterKnexFields now replaces PostgreSQL inline enum checks correctly,
with rollback on failure. SQLite changes that require replacing an enum check
throw before the field batch starts; changing only its default still works.
For deep-helper callers, PostgreSQL transactions retain ownership through a
savepoint. MySQL caller transactions are rejected before their implicit DDL
commit, and SQLite borrowed rebuilds require foreign-key enforcement disabled
before the transaction starts, with validation/restoration owned by the caller.
Schema DDL now preserves apostrophes, backslashes and question marks in enum
values and static text/JSON/array defaults. Inspect previously generated or
applied definitions containing those characters; regenerate faulty migration
code and migrate stored data explicitly where necessary. Standalone generators
must specify options.dialect for values needing dialect-specific SQL escaping.
Resource generators infer the dialect from their Knex client.
MySQL diffs replacing a foreign-key-supporting index now include the dependent
key in dropForeignKeys and addForeignKeys, even if its definition is unchanged.
Review the whole plan and pause application writes for these multiple-statement
DDL migrations; MySQL does not roll them back as one transaction.
Sparse fieldsets include relationships
Fieldsets now restrict attributes and relationships in both formats. Earlier
versions rejected valid relationship names yet returned unrequested relationships.
Update requests that use both include and fields to select the relationships
their callers read:
// Before: relied on an unrequested author relationship being returned.
queryParams: { include: ['author'], fields: { books: 'title' } }
// After: asks for both the attribute and the relationship.
queryParams: {
include: ['author'],
fields: { books: 'title,author', authors: 'name' }
}
Use the declared relationship name (author), not its storage field (author_id).
An empty fieldset (books: '') returns no attributes or relationships; omit
books from fields for the normal visible response. IDs remain present.
Included resource types obey their own fieldsets, and unrequested computed
fields are not evaluated. Full write responses follow the same rules without
changing the fields stored by the write.
JSON:API still permits requested included resources when the primary fieldset
omits their relationship linkage. Plain output reaches related resources through
selected relationship properties, so select each relationship along a nested
path. No stored-data migration is required. See the
JSON:API sparse-fieldset rules.
The internal findRelationshipDefinition helper moved from
rest-api-plugin-methods/common.js to
lib/querying-writing/relationship-contracts.js under plugins/core, so fieldset
validation and relationship methods use the same resolver. Update any deep
imports; the former module does not forward that export.
Generated labels and computed visibility
LabelPlugin now preserves an explicitly declared stored or computed label.
Automatic labels use public stored values, resolve a global-search alias through
its local actualField, retain null fallbacks in sparse reads, and fall back to
the current resource’s logical ID. Hidden, normally hidden and virtual fields
are excluded as automatic sources. Applications that need a custom display rule
can declare their own computed label; see the label guide.
Computed callbacks now receive { id, attributes, ... }, with the record’s public
logical ID supplied on GET, query and included-resource reads. Existing callbacks
can keep their current arguments. Use id instead of looking for a custom ID
column in attributes or using the parent request’s ID.
Declare a computed field’s inputs in dependencies, especially normally hidden
stored fields. Sparse reads no longer fetch every normally hidden field just
because one computed field was requested. That fallback could select nonexistent
virtual columns and made sparse/full behavior disagree. Declared virtual inputs
are not selected as SQL columns.
Hidden computed fields are no longer returned. Normally hidden computed fields require an explicit sparse-fieldset request. Visibility also applies after virtual input values are added to a full write response. Review callers that accidentally relied on these values being exposed.
Automatic labels require no data migration. If an older LabelPlugin installation
removed an authored stored label from the compiled schema, check whether its
column/canonical mapping exists and use the normal schema migration helpers to
add it if needed. Values that were never persisted cannot be recovered by this
code change.
Cursor validation and registered schema handlers
Cursor validation now uses the type handlers and validators captured by the resource’s compiled attribute schema. Previously each cursor field created a schema from the current global registry, so changing a registered handler could make cursor validation disagree with the already-compiled resource.
Register schema extensions before adding resources. Existing compiled resources retain those handler functions; a new resource initialization or supported recompilation captures the current registrations. This does not introduce general live schema editing. Normal cursor spelling, numeric conversion, opaque IDs, null ordering, temporal precision and HTTP validation errors are unchanged. Temporal output conversion keeps its existing separate contracts and precision behavior; cursor validation does not truncate an invalid over-precise token.
Applications using resource methods need no argument changes. Direct importers
of the internal validateCursorValues helper must pass the owning schema as its
fourth argument:
validateCursorValues(descriptors, values, 'after', scope.vars.schemaInfo.schemaInstance)
The helper has no fallback to the global registry. Contract reuse is scoped to that schema instance, and values/errors remain local to each call.
Field dependencies and projection metadata
Getter/setter declarations keep their callback signatures and use
runGetterAfter/runSetterAfter. Computed fields keep dependencies and
compute({ attributes, id, ... }). Dependencies now support transitive chains
and fields without their own callback. Missing names, cycles, non-array
declarations and supplied non-function getters/setters fail registration.
Remove placeholder null/false callbacks; omit the callback and its ordering
property when the field needs no transformation.
List every local input a callback reads. Sparse reads fetch the entire dependency
chain, run getters before computations, and compute prerequisites once. A getter
cannot depend on a computed field; move that calculation to a computed field.
Setters see available validated input and earlier setter results; they do not
load omitted PATCH fields from the database. Read logical IDs from callback
id. Remove read dependencies on raw relationship backing fields or relationship
aliases: these are not attribute values at callback time. The
dependency contract
includes a chained example and visibility rules.
Full write responses now apply virtual getters before computations. Virtual input belongs to the written type/ID and is no longer copied onto included or unrelated records. Update calculations that expected raw virtual input after a getter, and remove any reliance on a parent’s input appearing on its includes.
For custom plugins, replace writes to scope.vars.queryFields in resource:added
with declarations in context.queryFields during schema:enrich. Read the final
map from scope.vars.schemaInfo.queryFields. There is no compatibility copy at
the former path. Core normalizes the declarations and checks collisions against
the final attribute/computed/relationship/ID namespace before publication.
Canonical additions rebuild this map together with dependencies.
See the plugin example.
Ordinary applications using QueryProjectionsPlugin and resource queryFields
options keep that setup unchanged.
The unused internal sortFieldsByDependencies wrapper was removed. Internal
code uses the existing topologicalSort through compileFieldDependencies;
applications should declare dependencies in their resource schemas.
Response dependency metadata
JSON:API resource objects no longer expose the internal
__$jsonrestapi_computed_deps$__ member. Update snapshots or application code
that consumed it. Computed fields still receive their dependencies during
enrichment; cleanup happens at the final response boundary, including full and
minimal write responses after finish hooks.
Use declared dependencies and callback arguments for computations. Put
application metadata in meta or declared attributes. Cleanup removes only the
reserved resource-level member; keys inside user attributes and metadata retain
their values. Method arguments and stored records need no migration for this fix.
Resource IDs and links
Write documents and relationship identifiers accept strings or finite numbers,
then apply the resource’s ID normalizer and schema. Plain inputs now preserve
those values for the same validation as JSON:API inputs. They no longer silently
drop zero, turn false into an unlinked relationship, or stringify objects into
IDs. Use null to clear a to-one relationship. Convert a BigInt to a string
before placing it in a write document or relationship identifier.
The default normalizer trims strings and accepts numeric path IDs (including
BigInt); it no longer accepts booleans, arrays or objects through implicit
toString(). A configured normalizeId remains the explicit customization
point. Resource-specific normalizers still override the plugin default.
PATCH and PUT use the path ID only when the body ID is absent or undefined.
An explicit zero must match the path after normalization. Explicit null, empty
or invalid body IDs fail validation instead of being replaced by the path ID.
For example, this now rejects without changing record 42:
await api.resources.books.patch({
id: '42',
format: 'plain',
data: { id: 0, title: 'Wrong target' }
})
The schema still controls valid keys: type: 'id' requires positive values;
an integer or string key can permit zero. Public IDs remain strings. Leading
zeroes, case, punctuation and large numeric strings remain intact for opaque
string keys. Use a string to avoid JavaScript numeric precision loss.
SQL bigint primary and relationship IDs now retain their exact decimal values
on library reads and writes. For example, 9223372036854775806 and
9223372036854775807 remain distinct through includes, relationship changes
and cursor pages. For explicitly supplied keys this large, use a string ID
declaration and pass decimal strings; keep SQL primary and reference column
types compatible. Existing text keys retain leading zeroes.
Storage hooks can now receive an exact string where SQLite or MySQL previously
returned a rounded Number. Keep identifiers as strings instead of converting
them with Number() or parseInt(). This correction needs no new API option or
data migration. It applies to library queries without changing the caller’s
Knex connection configuration. A Number already rounded in application code
cannot be repaired by the library.
PostgreSQL seed/import jobs that write explicit IDs into sequence-backed keys must synchronize the sequence before later generated-ID inserts. Explicit IDs do not advance that sequence. Keep synchronization in the seed/import workflow. Both ordinary and custom ID columns are exercised with actual PostgreSQL/MySQL generated inserts and all return modes.
Resource and relationship URLs, including POST’s Location header, now encode
the ID as one path segment. Pass the original ID to resource methods; follow
returned URLs as supplied, without encoding them again. For example, ID
part/one?x produces /books/part%2Fone%3Fx.
Table creation and migration generation now map type: 'integer' to integer
columns; previously it fell through to string columns. Existing tables are not
altered automatically. If you used these helpers with integer declarations,
inspect the stored values and review the generated migration before applying
it. Determine whether existing application tables need conversion before
applying the changed schema helpers.
Storage naming configuration
Use a string for storage.naming: snake_case (also snake or snakeCase) or
exact (also field or verbatim). Omit it for snake_case. Coercible objects
and inherited property names such as constructor are invalid naming modes
and now reject at the normalization boundary. Replace an object with its
intended string value; applications using the documented strings need no change.
Canonical row conversion also preserves a field literally named undefined
when a relationship lacks a reverse attribute mapping. An absent mapping no
longer causes that unrelated attribute to be removed.
Public field names and physical columns
Declared fields named constructor, prototype, toString, valueOf,
hasOwnProperty, isPrototypeOf, propertyIsEnumerable and toLocaleString
are handled as own fields. Stored values, generated filters, sparse responses,
cursor sorting and field callbacks must not resolve to inherited JavaScript
properties. This applies to plain records and JSON:API attributes.
Ordinary Knex storage rejects physical columns literally named constructor,
prototype or __proto__. Knex’s update compiler drops these names; a write
could previously leave an old value unchanged. Validation now rejects these
mappings during resource registration, adapter creation and table/migration
generation. Unsupported idProperty names are also rejected. Use an explicit
physical mapping while keeping the public name:
schema: {
constructor: {
type: 'string',
storage: { column: 'constructor_value' }
}
}
If the old physical column already contains data, first rename it in your normal database migration, then change the declaration. For example:
await knex.schema.alterTable('books', table => {
table.renameColumn('constructor', 'constructor_value')
})
Review dependent indexes, constraints and raw SQL, and verify row counts and
values on the application’s database before enabling the new declaration.
Registration and createKnexTable() do not rename existing columns for you.
Canonical storage uses allocated slots and does not need this physical-column
mapping for public constructor or prototype fields.
Minimal reads used by write authorization and PUT completeness checks now translate physical columns to logical fields once. If a physical column name also names another logical field, hooks now receive the correct original attributes. Update any hook that compensated for the previous double mapping.
Declaration dictionaries and reserved names
Use plain objects or null-prototype dictionaries with own declarations for
schemas, search fields, projections and relationships. Definitions inherited
from a custom prototype now reject instead of disappearing during compilation.
This includes an object literal such as { __proto__: definition }, which
changes an object’s prototype rather than declaring an ordinary property.
__proto__ is not a supported field, filter, projection, relationship alias or
relationship backing-field name. These declarations reject before compilation
can lose the key. The same checks apply after enrichment hooks, during field
additions, in direct storage/table helpers and at the canonical registry’s write
and descriptor-loading boundaries. Rename such a declaration. Preserve the
existing physical column or canonical slot through an explicit mapping when it
contains data; do not re-register over old metadata to erase migration evidence.
These checks do not recursively strip user data. An object-valued attribute can
contain JSON keys named __proto__, constructor or prototype; strings such
as __proto__ also remain valid resource IDs. Their existing validation rules
still apply.
Declare a belongs-to relationship alias as a string, for example
ownerId: { type: 'id', belongsTo: 'users', as: 'owner' }. Numbers, booleans,
arrays and objects in as now reject during schema validation, including values
introduced by schema:enrich. Replace as: ['owner'] with as: 'owner'; do not
depend on JavaScript coercing a configuration value into a property name.
The relationship mapping options target, through and via also require
strings when supplied. For example, change target: ['users'] to
target: 'users', and through: ['memberships'] to through: 'memberships'.
They check the declaration’s type; they do not require a forward-referenced
target or pivot resource to be registered earlier.
Use strings for schema belongsTo targets and relationship foreignKey and
otherKey names too. In particular, enrichment hooks cannot bypass the
backing-name type checks: change foreignKey: ['ownerId'] to
foreignKey: 'ownerId'. A schema field uses belongsTo: 'users', not
belongsTo: ['users'].
Polymorphic relationship declarations require types to be a nonempty array of
nonempty strings, and typeField and idField must be nonempty strings.
For example, use { types: ['users'], typeField: 'subjectType', idField:
'subjectId' } as the belongsToPolymorphic definition. The authored
polymorphic registration check still requires its target resources to be
registered; these shape checks do not establish backing-column existence.
For reverse relationships, hasOne requires target and foreignKey; hasMany
requires target and either foreignKey or its reverse-polymorphic via;
manyToMany requires through, foreignKey and otherKey.
Many-to-many target remains optional where the
existing relationship-name default applies.
Declare these relationships in the resource’s relationships option.
computedSchema:enrich.schemaRelationships remains an inspection input, as
described in computed enrichment. Defensive
validation now catches malformed relationship mappings if a hook mutates that
input; this does not make mutation or replacement of the inspection map a
supported relationship declaration API. Move such declarations into the
resource configuration when migrating.
Resource names must be JavaScript
identifiers starting with a letter, _ or $, excluding __proto__,
constructor and prototype. Other inherited names such as toString,
valueOf and hasOwnProperty now work with omitted or explicit fieldsets.
This resource-name restriction does not prohibit constructor or prototype
as attribute or relationship names. Valid relationship aliases also work in
include trees and polymorphic include grouping.
Null relationship representation is unchanged: JSON:API contains data: null;
plain conversion omits that relationship property. Use Object.hasOwn(record,
name) when checking for presence of a potentially inherited name.
This establishes the own-property and reserved-name boundary. Full JSON:API member-name validation and other namespace/capability conflicts remain part of the wider compiled-metadata work; arbitrary punctuation is not guaranteed to work in every query syntax.
Sort fields and defaults
Use a string or an array of strings for resource defaultSort:
// Replace this inconsistent configuration:
defaultSort: { field: 'name', direction: 'desc' }
// With:
defaultSort: ['-name']
The object form previously passed registration but failed through the public
query method. Invalid non-string entries now fail during schema compilation.
null and [] leave the default as ascending resource ID; explicit query sorting
overrides the resource default. Keep programmatic queryParams.sort as an array.
The resource ID remains the stable tie-breaker unless already included.
Sorting by id always means the resource ID. Sorting by a stored attribute or
query projection uses that field, even if searchSchema defines a filter with
the same name and a different actualField. For example:
searchSchema: {
name: { type: 'string', actualField: 'shortName' },
byShortName: { type: 'string', actualField: 'shortName' }
},
sortableFields: ['id', 'name', 'byShortName']
Here filters: { name: 'Ada' } still filters shortName; sort: ['name']
orders the stored name. Use sort: ['byShortName'] to order by the distinct
alias. An explicit sortable alias must resolve to a scalar stored field;
relationship aliases retain their visibility checks. Whole-document JSON and
cross-resource scalar ordering are not introduced by this change.
The same resolution applies to collection include orderBy. Cursor comparisons
use the stored field’s type and selected value, independently of the filter’s
input type. Sort fields needed internally are selected even when sparse output
omits them. Ordinary and canonical storage both follow these rules.
Restart existing cursor traversals after upgrading if a filter alias overlapped
a sort field; the old cursor may describe a different ordering.
Hidden sort fields
Fields marked hidden: true cannot be sort keys. This includes stored fields,
their search/relationship aliases, query projections, resource defaultSort,
explicit sortableFields, and collection include orderBy. Previously, an
explicit hidden stored sort could produce an incomplete cursor; a hidden
projection marked sortable could expose its SQL value in cursors and links.
Remove hidden fields and their aliases from sort configuration and choose a public scalar sort field instead:
schema: {
secret: { type: 'string', hidden: true },
displayOrder: { type: 'integer', normallyHidden: true }
},
sortableFields: ['id', 'displayOrder'],
defaultSort: ['displayOrder']
Use normallyHidden only for publicly readable values that should be omitted
by default. They remain sortable and their values can appear in cursors even
when omitted by a sparse fieldset. Keep sensitive values hidden.
Invalid local declarations fail during schema compilation with
REST_API_VALIDATION and violation rule hidden_sort. Include ordering is
checked when the target resource is resolved, preserving forward declarations.
Request validation excludes hidden sort keys, and query hooks cannot reintroduce
them. Hidden fields/projections still work as declared getter/computed
dependencies; their raw values remain omitted from resource output.
Pagination modes and counts
SQLite ordering now uses native NULLS FIRST/LAST, preserving the public null
placement and cursor results while allowing existing ordering indexes to help.
The verified engine is SQLite 3.49.2; SQLite engines older than 3.30 are no longer
supported by these ordering helpers. PostgreSQL/MySQL ordering is unchanged.
No payload or cursor migration is needed. Optional index choices and the existing
migration paths belong in reviewed schema migrations.
The existing page options keep their meanings in both storage modes:
| Query page | Behavior |
|---|---|
Omitted or {} |
Apply the resource’s capped default limit; omit pagination metadata |
{ number: 2, size: 25 } |
Offset page 2, using the capped size for the offset and links |
{ number: 2 } |
Offset page 2 with the resource’s default size |
{ size: 25 } |
First cursor page |
{ after: cursor } or { before: cursor } |
Adjacent forward/backward cursor page with the resource’s default size |
Add size to a cursor boundary to choose a different page size. Page sizes are
capped by queryMaxLimit. Use one of number, after or before; combining
them, supplying non-positive/fractional page numbers/sizes, or supplying an
empty cursor rejects before SQL. Follow generated links to retain filters,
fieldsets and the effective size.
enablePaginationCounts controls totals for offset pages. With counts disabled,
offset metadata contains page and pageSize; it does not promise totals or
hasMore. Cursor pages use one extra row for hasMore and never issue a count
query, regardless of this setting. Ordinary storage previously issued a count
on the first cursor page and discarded its result; that extra query and its
count-filter hook invocation have been removed. Collection predicates still
run for every cursor page. Keep mandatory filters in the collection path.
Malformed cursor syntax now has the same error message in either storage mode,
with code REST_API_VALIDATION, a page.after or page.before field, and rule
invalid_cursor. Prefer the typed code/details when handling errors instead of
matching a former backend-specific message. No call-site migration is needed
for applications already using the page options above.
CORS and HTTP response hooks
Install CorsPlugin after Express or Fastify. Installation without a connector
now fails immediately. Pass options directly to api.use(CorsPlugin, options).
Origin predicates may be async: their boolean result is now awaited, including
inside origin arrays. A rejected predicate becomes a 500 response without CORS
permission. Do not rely on the old behavior that treated a Promise as permission.
origin: false disables origin permission, and maxAge: 0 disables preflight
caching. These values are no longer replaced by defaults.
Denied OPTIONS responses now use the JSON:API error envelope (errors[0],
status 403) instead of { error: 'CORS origin not allowed' }. Other requests
retain their actual resource/status behavior; CORS does not authorize writes.
Existing credential/origin defaults remain permissive, so use explicit allowed
origins for credentialed applications. See the CORS guide.
transport:response now runs for early parser/media/Accept errors and explicit
context.reject(...) responses. Its request body can therefore be undefined;
avoid assuming authentication, parsing or a resource method has completed.
The hook chain is attempted once. If it throws, the connector maps the error
without rerunning response hooks. A response-stage failure cannot roll back an
already committed write.
Vary headers from the host, custom route, CORS and response hooks are merged case-insensitively, including on rejected requests; compare fields rather than their order. Fastify’s native malformed-URL boundary still precedes connector hooks and has no connector CORS headers. Raw responses sent by host middleware or a hook that takes over the response need their own headers. No stored-data migration is required.
Multipart uploads
Unexpected file detector/parser/storage errors now retain their cause and operation context. Typed errors still propagate unchanged. Unexpected storage failures return HTTP 500 rather than being mislabeled as validation errors; declared MIME/size/schema violations remain 422 and typed access denials remain
- Update callers that classified every upload failure as invalid input.
File cleanup keeps attempting later files after a cleanup or warning-log failure.
Inspect context.cleanupErrors for temporaryFileCleanup, uploadedFileCleanup
and associated logging entries. Failed deletions retain their tracking entries
for reconciliation. Tracking belongs to the creating transaction, preventing a
reused context from deleting files committed by an earlier transaction. Managed
rollback invokes enlisted cleanup hooks after confirmed completion.
After commit, uploaded objects belong to the application. Replacing or deleting
a record does not delete its previously committed files, including when no
references remain in that resource. URLs may be shared across records and
external consumers. If your application needs garbage collection, establish
ownership and absence of references before calling storage.delete(url); do
not attach unconditional file deletion to a record’s DELETE or replacement.
Rollback cleans only newly uploaded objects belonging to the failed transaction.
See the file ownership contract.
LocalStorage now reserves filenames exclusively, so concurrent original/custom
names cannot overwrite another upload or make rollback delete its bytes.
Existing files and symlinks are occupied names. Filename inspection now rejects
permission and other I/O failures instead of returning an available name.
Failed uploads remove only their reserved destination. If removal or closing
also fails, inspect the returned AggregateError’s cause and errors for the
original and cleanup failures. Its destination may need reconciliation. Buffer
and temporary-file inputs retain their existing signatures; see
storage behavior.
Install FileHandlingPlugin before ExpressPlugin and install the selected
optional peer (busboy or formidable). Missing parsers and invalid factories
now fail setup. The Express connector accepts multipart only when a detector is
registered and uploads are enabled; Fastify continues to reject multipart.
The corrected parsers use Busboy 1.x and Formidable 3.x. Both return buffered
files with filename, mimetype, size and data. If a custom storage adapter
used Formidable’s old file.filepath or file.cleanup, use file.data instead:
the detector now removes its private request directory before returning.
Custom detectors’ cleanup callbacks are still supported.
Repeated file fields now produce 400. Upload one file for each distinct declared file field. Repeated text fields become arrays consistently across parsers; bracketed array suffixes group values in arrival order. Unknown text/file fields are validated, including prototype-like names that could previously disappear. Parser limits produce 413, and declared file MIME/size violations produce 422. Review the documented bounded defaults and set parser limits explicitly when larger uploads are needed.
Multipart POST/PUT/PATCH use the route’s resource type; PUT takes its ID from
the URL. Required file fields follow the normal schema rules, so PATCH can omit
an existing file. Numeric maxSize now works, including zero for empty files.
Plain programmatic calls with custom detectors update the canonical input record
correctly. Detector errors propagate instead of falling through to another
parser or silently continuing the write.
Owned and managed rollback cleanup, multiple uploads, replacement and deletion are verified with real local storage. Previously committed files remain application-owned: replacing a URL or deleting its resource does not delete the old object, which may have other references. Confirmed rollback cleans new uploads; confirmed commit releases their tracking. Review file ownership after commit when migrating application cleanup code. No stored-data migration is required by this parser correction.
Socket.IO startup and shutdown
Keep the existing await api.startSocketServer(server, options) call, and await
it before accepting subscription traffic. Calling it while another start is
pending or a Socket.IO server is active now rejects; remove duplicate bootstrap
calls instead of relying on replacement of a live server.
Redis connection failure no longer leaves Socket.IO attached or published in
api.io. Partial connections are closed, the HTTP server and authentication
configuration remain intact, and the application can retry. The supplied Redis
reconnection policy still controls retries. Set
redis.socket.reconnectStrategy: false if your bootstrap should reject on the
first connection failure.
Closing Socket.IO also closes the HTTP server supplied to it. The plugin now
owns shutdown of its two Redis clients when that HTTP server closes and clears
api.io and its server/client references. Remove any subsequent manual quit()
calls on api.vars.socketIORedisClients. Close your application database
separately. No new shutdown method, compatibility wrapper or data migration is
required. The Socket.IO guide lists the verified paths and
the remaining network/failover limits.
Socket.IO subscriptions and notifications
Subscription messages now accept only resource, filters and an optional
nonempty string subscriptionId. Remove include and fields from subscribe
and restore messages; those options never changed the notification data. Keep
them in the subsequent resource GET/query instead. They are also absent from
the acknowledgement’s data. Other unknown options receive UNSUPPORTED_OPTION.
Null, scalar and array filters now receive INVALID_FILTERS. A duplicate active
subscription ID receives SUBSCRIPTION_EXISTS; unsubscribe before replacing it.
Concurrent admissions respect the per-socket limit. A subscription hook receives
{ context }, with context.subscription and context.auth; it may modify
filters but not identity. Its final filters are validated.
Custom SQL search fields use their existing applyFilter(query, value) callback
for subscription matching too. Remove Socket.IO-only filterRecord callbacks;
the earlier draft’s requirement for a parallel JavaScript predicate is withdrawn.
Notification eligibility now runs ordinary resource queries before/after writes
in the same transaction, with subscriber identity and an internal changed-ID
constraint. Query hooks must support these calls.
If row policies or autofilters need context beyond auth, supply it from trusted
server data in the existing hook:
subscriptionFilters: {
functionName: 'subscriber-context',
handler: ({ context: { subscription, auth } }) => {
subscription.context = {
scopeValues: { workspaceId: auth.workspaceId },
visibility: { groups: auth.groups }
}
}
}
The property defaults to {}. Keep it JSON-serializable when using Redis.
Client messages cannot set it. Populate whatever context your own policies
require; the property names above are application examples. Resource query
permissions receive this context on admission and notification as well.
Relationship POST/DELETE now run beforeDataCall and their respective
beforeDataCallPostRelationship / beforeDataCallDeleteRelationship hooks
before mutation. Hooks shared across methods must handle these operations.
Update consumers of change.type:
| Previous value | New value |
|---|---|
resource.postd |
resource.created |
resource.putd |
resource.created for PUT-create, otherwise resource.updated |
resource.patchd |
resource.updated |
resource.deleted |
Unchanged |
The action field retains resource method names (post, put, patch,
delete). IDs, including deletedRecord.id, are strings. Relationship changes
produce parent updates with action patch, and changed reverse children also
produce their resource updates. Each matching subscription gets its own event;
use subscriptionId when one connection holds multiple views. Both entering and
leaving a filtered or authorized result trigger invalidation when the corresponding
query succeeds. Deletion uses the before-write query.
Query permissions are checked again before notification. socket.data.subscriptions
is now a serializable array for adapter transport; applications that inspected
the old internal Map must update that code. No stored-data migration is needed.
The Socket.IO guide separates verified local behavior from
remaining Redis failover/network uncertainty. Atomic bulk enlists each child’s
completion hooks with its owner. A failed deferred notice now leaves later
queued notices eligible for delivery. The draining operation’s context records
socketioBroadcast cleanup diagnostics with a zero-based broadcastIndex;
the call retains its committed outcome and first failure. Failed notices are
not automatically replayed. See the Socket.IO guide for partial-delivery limits.
Managed callbacks defer repeated changes to the same resource until completion.
In-memory notices do not guarantee exactly-once delivery or recovery after
process failure. Verify subscription and notification handling in each application
that uses this plugin.
Bulk writes
Remove batchSize and enableOptimizations from plugin configuration, and pass
the remaining options directly:
await api.use(BulkOperationsPlugin, {
maxBulkOperations: 100,
defaultAtomic: true
})
maxBulkOperations must be a positive safe integer and defaultAtomic a boolean.
Unknown keys now fail installation, including a nested 'bulk-operations'
wrapper. The removed settings did not batch SQL: batchSize only divided a
sequential loop, and the optimization hook never ran. Removing the loop setting
also removes its invalid-value hang/skip paths. Bulk operations still execute
the normal resource methods, including validation, permission checks and hooks.
Atomic requests use one transaction; non-atomic requests complete each record’s
transaction separately. No stored-data migration is needed.
Bulk POST replaces inputRecords with either data: plainRecords or
document: { data: jsonApiResources }. Bulk PATCH entries use either
{ id, data: plainValues } or { id, document: { data: jsonApiResource } }.
Do not put a JSON:API resource directly inside an operation’s data field.
Each entry explicitly selects its input; neither bulk method infers it from
format. The documented bulk HTTP bodies keep their wire shapes: POST uses
{ data: [resourceObject, ...] } and PATCH uses { operations: [...] }.
The connector translates them into these method arguments. Remove raw-array
bulk POST shorthand and arrays of individually wrapped documents; send one
document containing the resource array instead.
Bulk POST/PATCH honor output-only format and returning, including resource
defaults. Bulk results retain their meta/errors envelope:
returning: 'full' supplies complete records in data, minimal supplies
identifiers, and none omits data. Bulk PATCH no longer performs an extra GET
to override a minimal/none return choice.
await api.resources.books.bulkPatch({
operations: [{ id: '42', data: { title: 'Updated title' } }],
format: 'plain',
returning: 'none',
atomic: true
}, requestContext)
// { meta: { total: 1, succeeded: 1, failed: 0, atomic: true }, ... }
atomic must be a boolean in direct calls. HTTP accepts atomic=true or
atomic=false; misspellings now fail with 422 before writing. Removed response
options also fail clearly. Numeric zero is a valid bulk PATCH ID when the
resource’s declared ID schema accepts zero.
Install the connector and bulk plugin before declaring resources. Bulk routes
now register through the active scope hook and retain authenticated request
context. They explicitly select full JSON:API records, regardless of
programmatic defaults. POST responds with 201; PATCH and DELETE respond with
200 and the batch summary. The optional plugin reserves the bulk path segment
for these methods; do not use it as an opaque resource ID with this plugin.
These endpoints are the library’s bulk protocol, not the JSON:API Atomic
Operations extension. An atomic denial rolls back the batch; a non-atomic batch
retains permitted operations and reports denied entries by index.
An atomic batch now keeps its original child, validation or commit error if
rollback also fails. The outer batch attempts rollback once for an unfinished
transaction it owns. Its supplied context exposes the original error and any secondary
rollback failure in cleanupErrors, using { phase: 'rollback', error } entries.
Both fields are cleared when the context starts another bulk call. A transaction
already completed by the driver is not rolled back; a rejected commit still
does not establish a safe retry. The error wrapper reports the available outcome.
This change does
not add bulk completion hooks or change non-atomic result envelopes.
Child cleanup warnings and failures now survive on the supplied batch context.
cleanupErrors entries add a zero-based bulkIndex to their existing phase,
original error and file information. This includes warnings from successful
entries, rollback/cleanup-hook failures from rejected entries, and diagnostics
collected before an atomic batch rethrows. Managed completion-chain failures
also identify operationIndex, scopeName and method. Secondary errors remain on the
context rather than being added to the batch response.
Remaining child fileHandlingUploads entries also reach the batch context with
bulkIndex, preserving storage and transaction references. Context reuse clears
old error diagnostics, retains unresolved upload tracking and starts each new
child with separate tracking. Do not use retained tracking as a deletion or retry
instruction: files can belong to committed data when an earlier afterCommit
hook prevents the tracking-release hook from running, or to pending outer work.
For non-atomic results, meta.failed counts rejected calls. A post-commit hook
failure can therefore appear as an indexed error even though the write is
committed. Inspect the underlying failure and transaction evidence before
retrying. Each error entry now has error.transactionOutcome; remaining managed
integration is tracked separately.
No payload or stored-data migration is required for this diagnostic correction.
Stored file handles also now read as strings when the ordinary SQL driver returns
their UTF-8 bytes as a Buffer. The shared normalizer decodes declared file
attributes, including byte views, without changing blob fields or database
columns. Invalid encoding fails with its original cause and field context.
Custom getters retain their raw database input. No schema migration or consumer
Buffer-to-string wrapper is needed; use the resulting URL string directly.
Bulk POST/PATCH/DELETE now honor transaction in the first argument; previously
they ignored it and could commit separately from the caller’s transaction.
Select atomic: true when supplying it, including when the plugin’s
defaultAtomic is false. Each child and its relationship changes use the same
transaction. The in-progress managed contract requires api.transaction to
own the unit. Earlier entries and the failing entry’s pending writes roll back
together, including when the callback catches a participating write failure.
Catching the error does not restore those rows or create a savepoint.
await api.transaction(async transaction => {
return api.resources.books.bulkPatch({
operations: [{ id: '42', data: { title: 'Updated title' } }],
format: 'plain',
returning: 'minimal',
atomic: true,
transaction
})
})
atomic: false with a supplied transaction now fails with
REST_API_VALIDATION on transaction, before any child or SQL runs. Independent
per-entry completion requires calls without an outer transaction. A supplied
completed transaction also rejects instead of being silently replaced. Calls
without transaction keep their atomic/non-atomic ownership. Completion hooks,
file cleanup and notifications use the managed owner. No stored-data migration
is needed for this transaction-ownership change. Check any application wrapper
that supplies raw transactions to bulk methods.
Application-supplied primary IDs in table helpers
Ordinary table creation, generated migrations and migration diffs now recognize
the declared primary ID by its mapped column, including integer and string
definitions. Previously only a field with type id was recognized; a declared
integer/string primary key could produce a duplicate generated ID column.
For a caller-supplied opaque key, declare the primary key explicitly:
await api.addResource('records', {
idProperty: 'record_key',
schema: {
id: {
type: 'string', primary: true, required: true,
storage: { column: 'record_key' }
},
name: { type: 'string' }
}
})
The same pattern works with type: 'integer'. primary: true keeps the
authored column instead of adding an implicit auto-incrementing ID. Supply IDs
when writing to a table without database allocation or a default.
An opaque ID without that primary declaration cannot use implicit numeric
allocation: the helper now rejects that configuration before producing DDL.
For lower-level table definitions, autoIncrement: false remains available.
Table snapshots now accept these non-null, single-column integer/string primary keys without requiring auto-increment or a database default. Existing tables are not altered by this change. Regenerate migrations that failed with duplicate ID columns and review the resulting DDL; an unchanged valid table should produce an empty migration diff. Missing, nullable, floating-point and composite resource IDs remain rejected by snapshot validation.
Collection filtering hooks
Collection reads now honor a query builder assigned to
context.knexQuery.query by a knexQueryFiltering hook. Previously, both storage
modes could ignore that replacement and omit restrictions added to it; ordinary
pagination totals could disagree with the returned page, and canonical totals
could omit the restriction too. Related-resource collections use the corrected
path as well.
When a hook needs a separate builder, clone the supplied query and apply further
conditions to that clone. Keep its tenant/resource constraints, relationship
membership and existing visibility predicates. The selected builder governs the
page. Ordinary storage also runs filtering hooks for its separate count query;
canonical storage clones the filtered collection query for that count. Apply the
complete predicate on every filtering call, including count calls. Surrounding
context.knexQuery metadata is restored after filtering succeeds or fails.
Row-policy functions retain their existing
true/false return contract. This correction adds no public option and requires
no change to hooks that already mutate or replace the supplied builder correctly.
Full includes and projected fields
Ordinary-storage belongsTo, hasOne and polymorphic belongsTo includes now select visible SQL projections when no target fieldset is supplied. Previously these paths could omit the projections and pass missing dependencies to computed fields. Full and sparse includes now use the same field-selection rules as collection includes and canonical storage.
No call changes are required. Remove any fieldset used solely to work around
missing projections; keep intentional sparse fieldsets to control response
size. For example, books.get({ id: '1', queryParams: { include: ['author'] } })
now returns the author’s visible projections without also specifying
fields.authors. A projection used only as a computed-field dependency remains
absent from a sparse response unless requested. Projection failures reject the
operation; full write-response failures happen before an owned commit.
Cyclic includes and resource identity
A resource appears once across a JSON:API document’s data and included.
For example, items.get({ id: '1', format: 'jsonapi', queryParams: { include: ['groups.items'] } })
keeps the item in data and its group in included. The group’s relationship
can point back to items/1 without adding another copy of that item. Relationships
discovered through the cycle are retained on the primary resource, and computed
fields run once per represented resource. Explicit collection include limits
remain in effect when another path revisits the same resource.
If your response resolver searches only included, index both primary data
and included by type and ID instead. Update snapshots that expected a primary
resource to be repeated in included. Relationship identifiers and resource URLs
keep their existing shape; this requires no stored-data migration.
Plain responses resolve references against both primary and included resources.
Peers in a primary collection can therefore expand fully. Actual cycles end in
an { id } reference, with _type retained for polymorphic relationships;
independent branches still receive separate objects. Keep custom computed
callbacks free of assumptions that a cyclic path will invoke them twice.
Empty includes and refresh links
JSON:API resource reads and full write responses now retain included: [] when
an explicit include returns no resources. This also applies to empty collections
and related endpoints with data: null. An explicitly empty include is supported:
await api.resources.groups.get({
id: '1',
format: 'jsonapi',
queryParams: { include: [] }
})
// { data: { ... }, included: [], links: { self: '.../groups/1?include=' } }
Omitting include keeps it absent from normalized query parameters and omits
included when there are no included resources. HTTP ?include= parses as an
explicit empty array and survives serialization into refresh/pagination links.
Hooks and direct parser callers that iterate includes should use
context.queryParams?.include ?? []; do not assume an omitted include has been
populated with an empty array. Update response snapshots for explicitly requested
includes that used to omit included.
Top-level links.self on single-resource and related responses now preserves
includes and fieldsets, as collection links do. Follow that link to refresh the
same representation. Resource-level data.links.self remains the resource URL.
Update assertions that assumed those two links were always identical.
Plain results retain their existing shapes. Programmatic returning: 'minimal'
and 'none' retain their selected return contracts; HTTP resource writes select
full JSON:API responses. No method arguments or stored records need migration.
Relationship visibility hooks
Row policies and knexQueryFiltering hooks with
queryPurpose: 'relationship-identifiers' now run once per batch of at most
100 unique target IDs for each resource type. Remove assumptions that this is
one callback per request, and remove flags that skip a visibility predicate
after the first query. Add the full predicate to each supplied builder; cloning
that builder remains supported. Keep ordering, pagination and include limits
in the operation/resource settings, and retain the selected ID and mandatory
scope constraints when customizing a visibility query.
Identifier-visibility selections no longer put every ID into one WHERE IN. These reads
select distinct IDs, preserve the input linkage order and duplicate occurrences,
and retain the caller’s transaction. A later batch failure rejects the whole
read. See the row-policy contract
for the context supplied to these hooks.
Nested belongs-to include batches
Regular and polymorphic belongs-to includes now load at most 100 unique target
IDs per resource type in each query. Large nested includes no longer place all
of those IDs in one SQL statement. Target permission/filter hooks with
queryPurpose: 'include' and SQL projection select callbacks run for every
batch. Apply the complete predicate or projection each time; remove assumptions
that they run once per include path. This does not change computed-field
callbacks into query callbacks.
All target batches are gathered before traversing nested collections. Existing global and per-parent collection limits still apply across that complete parent set. A later batch failure rejects the operation; a failed full write response rolls back an owned transaction, while a borrowed transaction remains the caller’s responsibility. Canonical storage also batches parent-link prefetches. This change adds no method options and does not impose a total response-memory bound or batch every collection include query.
Large collection identifier queries
Collection includes and relationship-metadata reads now deduplicate their ID lists. Above 100 distinct IDs, PostgreSQL receives one array parameter and SQLite receives one JSON array parameter through JSON1. Sorting, global limits, per-parent limits and policy predicates still run in the original SQL query. SQL projection and filtering callbacks retain their collection-query frequency. The separately batched belongs-to loaders retain the behavior described above.
Code that inspects raw Knex statements must allow array predicates and ID
subqueries; use the documented query-purpose/context fields when applying
policies. Small lists keep ordinary IN predicates. MySQL retains its ordinary
IN query through Knex’s text protocol, preserving its existing numeric and
collation comparisons. These changes add no public options. They avoid database
parameter-count overflow, but complete ID lists and responses still consume
memory proportional to their size.
Explicit includes and linkage reads
An explicitly included relationship is now loaded directly by its include loader. The library skips the preliminary identifier read that the loader used to replace afterward. For standard many-to-many includes, authorized target records are selected and limited in SQL before the library reads their parent mappings. Window includes keep their per-parent limit behavior. Result ordering, duplicate-edge handling, sparse fields and transaction ownership remain the same.
Hooks observing relationship-identifiers queries will see fewer calls for
explicitly included relationships. Target and ordinary pivot include permission
checks still run. Mapping reads now occur after the target query, on the same
transaction; a mapping-read failure still fails the operation. Complete unlimited
includes can require additional mapping pages. No API option or data migration
is needed.
Sparse responses and relationship hooks
Sparse fieldsets now avoid database reads for omitted relationship linkage in
both storage modes. For example, queryParams: { fields: { books: 'title' } }
does not load the book’s collection identifiers or check visibility of to-one
linkage that will be removed from the response. Selected linkage retains its
visibility checks. Requests without a fieldset for that resource keep their
existing linkage behavior.
An explicit include still loads its resources even when the corresponding
relationship field is omitted. Nested includes, target permissions, computed
attribute dependencies and transaction ownership retain their existing behavior.
No new option is needed.
If a hook reads collection linkage from context.record, request that
relationship in the resource’s fieldset or load the data explicitly for the
hook’s purpose. Do not use the number of relationship-identifiers filtering
hooks as a request notification: omitted linkage no longer triggers those reads.
Authorization for requested primary/included resources still runs; authorization
must not depend on an incidental query for an omitted relationship.
Canonical parent-link reads
Canonical many-to-many linkage now reads at most 101 physical link rows per query, retaining its existing 100-parent batch size. It still returns complete visible linkage. Raw linkage is assembled in physical link-ID order for each parent; use declared include ordering or related-resource query sorting when application order matters. Configured include limits and ordering still apply to the complete requested parent set.
The prefetch now also enforces the relationship’s declared target resource type, matching the existing related-resource query. A same-key physical link to a different resource no longer appears in this relationship. No method option or data migration is needed. Later page failures reject the operation and preserve the existing owned/borrowed transaction behavior. Raw SQL observers should expect additional reads for large link collections.
Ordinary pivot membership writes
Many-to-many additions and replacements now read existing pivot rows for at most 100 requested target IDs at a time, paging duplicate physical edges in bounded results. New inserts still use batches of 100 rows. Replacement deletes unwanted rows using one predicate for the complete desired set, including large SQLite/PostgreSQL ID lists. Retained matching pivot rows keep their IDs and metadata. Empty replacement removes every link for that owner.
Equivalent requested target IDs, as resolved by the database’s target ID column,
are written once using the first submitted spelling. For example, BETA and
beta referring to the same target no longer create two new pivot rows in one
request. Existing duplicate rows are not merged automatically. Keep target ID
and pivot reference column types/collations consistent; this does not migrate
existing application schemas.
Method arguments, permission hooks and transaction ownership are unchanged. Code that observes raw SQL must allow paged pivot reads and a single exclusion DELETE for replacement. The complete input and keep-list still require memory proportional to their size; the batching does not set a new relationship limit.
Relationship target validation
POST, PUT and PATCH payloads, including relationship endpoints, now use one
target-validation contract. Readable target rows are selected in batches of at
most 100 distinct identifiers; checkPermissions then runs with method: 'get'
for each distinct target within each relationship. Its originalContext
contains that target’s id, scopeName, schemaInfo and minimalRecord, plus
the caller’s auth and transaction. Repeated identifiers do not repeat permission
checks within one relationship. A target referenced through two relationships
is checked for each relationship.
Many-to-many writes no longer run a full target GET after this check. Move any
link-authorization logic from checkDataPermissionsGet, beforeDataGet, getters,
computed fields, enrichment or finishGet into the existing permission hook:
checkPermissions: {
functionName: 'authorize-readable-target',
handler: ({ context }) => {
if (context.method !== 'get') return
const request = context.originalContext
assertCanRead(request.auth, request.minimalRecord)
}
}
The minimal record contains stored attributes and to-one linkage; it has no response getters/computed fields or includes. Response hooks still run for real GETs and requested full write responses. Use write/transaction hooks for side effects of linking. Remove assumptions about one target GET per supplied ID.
Row policies and knexQueryFiltering run with
queryPurpose: 'relationship-validation', empty client filters and no individual
context.id. Add the complete row predicate to every supplied builder. Retain
its selected ID and mandatory scope constraints; the filtered ID subquery
selects the full minimal rows without multiplying them through policy joins.
An alternate-spelling lookup can run an additional scoped query to retain the
database’s ID collation semantics. Per-target decisions belong in the permission
hook, where the individual ID and record are available.
Target locks and storage existence checks remain. A failure rejects before link writes; an owned operation rolls back, while a borrowed transaction stays with its caller. Internal pivot/link storage helpers now assume the resource operation has authorized its payload; applications should use resource methods. No option retains the old target GET lifecycle. Port application hooks that relied on those implicit reads.
Setter, getter and computed-field failures
Field callbacks now propagate failures in both formats and storage modes, including primary resources, included resources and nested includes. There is no additional policy option. The changes are:
| Callback failure | Previous behavior | Current behavior |
|---|---|---|
| Unexpected setter error | Converted to validation error (422), losing its cause | Operation rejects with the original cause; HTTP 500 |
| Unexpected getter error | Logged; response kept the current field value | Operation rejects; HTTP 500 |
| Unexpected computed-field error | Logged; response substituted null | Operation rejects; HTTP 500 |
| Typed API error | Propagated at field callbacks | Unchanged on reads; retained as the write wrapper’s cause, with code/details still readable |
| Relationship target permission failure | Could be replaced with not-found (404) | Original classification/cause retained by target validation; a missing target still produces 404 |
In the cause chain, unexpected callback errors have cause and
context: { scopeName, fieldName, phase }, where phase is setter, getter
or computed. Include processing can add outer wrappers; follow the cause
chain to the field error. Synchronous throws and asynchronous rejections follow
the same rule, including non-Error values such as null or undefined.
If a setter intentionally rejects user input, change its generic throw to the existing validation error class. For example, inside a resource schema:
import { RestApiValidationError } from 'json-rest-api'
const quantityField = {
type: 'number',
setter: value => {
if (!Number.isInteger(value)) {
// Previously: throw new Error('Quantity must be a whole number')
throw new RestApiValidationError('Quantity must be a whole number', {
fields: ['data.attributes.quantity'],
violations: [{
field: 'data.attributes.quantity',
rule: 'integer',
message: 'Quantity must be a whole number'
}]
})
}
return value
}
}
This keeps intentional input rejection at 422. A typed forbidden resource error remains 403. Unexpected programming or service failures should propagate. When a value is intentionally unavailable, return that domain value explicitly:
const helpfulnessField = {
type: 'number',
computed: true,
dependencies: ['helpful_votes', 'total_votes'],
compute: ({ attributes }) => attributes.total_votes === 0
? null
: 100 * attributes.helpful_votes / attributes.total_votes
}
A callback’s own intentional fallback still works. Review existing generic validation throws and code that depended on the library substituting null or returning an untransformed value. Handle rejected reads and writes instead of treating an incomplete response as success.
GET/query return no successful partial response after an executed callback fails. POST/PUT/PATCH full-response enrichment runs before owned commit, so its failure rolls back owned record and relationship changes. Borrowed transactions remain active for their owner to complete. Minimal/none responses do not run callbacks solely to enrich an unused full response; callbacks needed for other work, such as validating related resources, still run and can fail the write. Sparse fieldsets likewise avoid callbacks that are neither selected nor needed as dependencies. These rules do not undo a write whose commit was already acknowledged; post-commit side effects and cleanup retain their own semantics.
Stored relationship metadata
Canonical reverse relationship linkage now selects only child identity and
parent-reference columns. Large target attributes are no longer fetched merely
to construct linkage. Returned IDs, filters and transaction ownership are
unchanged. Query hooks for queryPurpose: 'relationship-identifiers' should
preserve these selections when replacing the builder; add predicates against
the underlying columns as usual. No payload or data migration is needed.
Polymorphic linkage read from storage must use a target type declared by its
relationship. For example, with
subject: { belongsToPolymorphic: { types: ['groups'], ... } }, a stored
subjectType: 'items' is invalid even if an items resource exists. Previously
ordinary includes could silently omit that target, while canonical storage
could include it. Both modes now reject with HTTP 500 and programmatic error
context { scopeName, relationshipName, phase: 'relationshipData' }.
This also applies to linkage returned without an include. A disallowed target
submitted through the write API still produces a validation error (422).
Repair inconsistent stored type/ID pairs or update the declared relationship when the target type is intentional. Nullable, intentionally empty relationships retain their existing representation. Sparse fieldsets do not guarantee bypassing stored-linkage checks: preliminary reads and relationship selection can still require those columns.
Required reverse-relationship metadata must also exist: a via relationship
must refer to a polymorphic relationship, and canonical foreign-key mappings
must resolve to a stored field. Missing required metadata now rejects instead
of returning empty linkage. Correct the resource declaration or stored
canonical metadata, then retry. Schema-read failures during field selection
and record conversion retain their original cause and resource context with
phase relationshipMetadata; they are no longer ignored as if the resource
had no relationships.
Nested polymorphic include paths can still apply to only some declared target
types. If subject allows writers and books, and only writers have a
profile, include=subject.profile remains valid; books simply have no profile
branch. A path supported by none of the declared target types is rejected by
request validation.
These read checks also apply while preparing write responses. A failure before owned commit rolls back the owned write; a borrowed transaction remains its owner’s responsibility. An acknowledged commit cannot be undone by a later response or side-effect error.
Schema enrichment and canonical registration
Use schema:enrich to change attribute definitions. Use searchSchema:enrich
to change filter definitions. Previously the search hook received the attribute
map in context.fields, so edits could affect writes while failing to change
filter validation. It now receives the generated search map, including explicit
search aliases. Adding, editing, deleting or replacing context.fields affects
the search contract. The hook also runs when the initial search map is empty.
await api.customize({
hooks: {
'schema:enrich': {
functionName: 'declare-product-stock',
handler: ({ context }) => {
if (context.scopeName !== 'products') return
context.fields.stock = { type: 'integer', defaultTo: 0 }
}
},
'searchSchema:enrich': {
functionName: 'declare-product-filters',
handler: ({ context }) => {
if (context.scopeName !== 'products') return
context.fields.nameContains = {
type: 'string', actualField: 'name', filterOperator: 'contains'
}
}
}
}
})
// Register resources after installing their schema hooks.
Treat originalFields as inspection input; edit fields. Definitions are
shallow copies, so this is not a guarantee of deep immutability. Search index
hints are applied to the hook’s final result. A search-only alias is not a
writable attribute.
Both storage modes now use the enriched compiled field definitions. Canonical
registration previously persisted raw authored definitions and then overwrote
some enriched metadata. It now allocates storage from compiled definitions and
attaches its descriptor to the same compiled schema. This preserves types,
removed/added fields, search overrides and callbacks without a second schema
compilation. Canonical metadata no longer invents an attribute declaration for
an implicit resource ID; metadata consumers should use schemaInfo.idProperty
to identify the resource ID configuration.
Enrichment runs once at resource registration, and once for each canonical
addKnexFields compilation. Repeated canonical createKnexTable() refreshes
storage metadata without rerunning enrichment or restoring removed search
fields. Deep registry allocation does not update a running resource’s compiled
schema; use the resource helper and retain its expanded declaration in source.
Declare callbacks in source again after restart: persisted JSON cannot restore
functions. Ordinary addKnexFields retains its DDL-only behavior.
Fastify now resolves the current cached request contract when validating a
payload. A belongs-to alias added through canonical addKnexFields can be used
in resource POST/PUT/PATCH documents after server startup, as with Express and
programmatic calls. The JSON Schema published at route registration remains an
initialization snapshot; finalize declarations before generating route-based
API documentation.
Computed-field enrichment
Plugins that derive computed fields from schema metadata should use
computedSchema:enrich instead of adding fields to vars.schemaInfo.computed
in resource:added. The compiler awaits this hook after attribute and search
enrichment, including during canonical field additions. Edit or replace
context.fields; each definition must have computed: true and a type,
and any supplied compute callback must be a function. Computed names cannot
collide with stored attributes.
This validation also applies to authored computed fields. compute: false,
0, null and '' previously registered successfully but silently omitted
the field from output. They now fail registration with invalid compute
function. Supply a function, or omit compute when an enrichAttributes
hook intentionally supplies the value.
await api.customize({
hooks: {
'computedSchema:enrich': {
functionName: 'derive-product-summary',
handler: ({ context }) => {
if (context.scopeName !== 'products') return
context.fields.summary = {
type: 'string',
computed: true,
dependencies: ['name'],
compute: ({ attributes }) => `Product: ${attributes.name}`
}
}
}
}
})
The context also exposes originalFields (authored computed declarations),
schemaStructure, searchSchemaStructure, schemaRelationships, idProperty, scopeName and
scopeOptions for inspection. Change stored fields in schema:enrich and
search fields in searchSchema:enrich; the computed hook’s other structures
are inspection inputs, not an alternative configuration mutation API.
Definitions are shallow copies, not deeply immutable objects.
LabelPlugin now uses this stage. Automatic labels survive canonical field
additions and use the newly compiled search/preference candidates. Explicit
stored or computed labels remain authoritative. If computed enrichment or
validation fails during canonical addKnexFields, the candidate is rejected
before field allocation; existing compiled metadata and records remain usable.
Existing canonical data
Registration now rejects removal or remapping of an existing stored field when
the resource has records. The error starts with Canonical storage migration
required and identifies the field. An owned registration rolls back metadata
changes; a borrowed registry transaction remains the caller’s responsibility.
This guard prevents a changed schema order or newly effective enrichment hook
from silently reading one field’s existing slot as another field.
Before upgrading an app with existing canonical data:
- Inspect its schema hooks and compare their final field types with persisted
field definitions and
canonicalFieldMap. - Keep every existing stored field in the declaration. For changes that retain
the same storage types, preserve existing slot assignments with an explicit
complete
canonicalFieldsMap; allocate unused slots for new fields. - If a field needs a different slot pool, a new value encoding or removal, migrate and verify its data and metadata explicitly before registering that changed layout. A slot map cannot convert values between storage types.
- Restart with the expanded schema, callbacks and slot map, then verify old records, new writes, filters and relationship linkage.
Registration is not a data migration. The layout guard does not verify semantic changes to callbacks or values that retain the same physical slot. Empty resources can change layout without this guard rejecting registration.
Older canonical registrations could also allocate an undeclared numeric field
named after a custom idProperty. That synthetic field is no longer allocated.
If its removal triggers the guard, inspect the old slot and metadata in your
data migration. Remove its field configuration/schema entry only after proving
the slot has no values to preserve, and retain every real field’s slot map.
Do not add a fake attribute merely to retain the unused allocation.
Write hooks and the returned response
POST, PUT and PATCH prepare their response before committing an owned
transaction. finish, then finishPost/finishPut/finishPatch, are the last
hooks that can customize context.responseRecord. The library then normalizes
schema-declared values and reapplies requested sparse fieldsets to full output,
including related records. A finish hook cannot expose an excluded field by
adding it after the nested GET. Minimal responses retain their resource type
and ID; returning: 'none' still returns undefined.
The returned minimal/full response is now a detached snapshot. afterCommit
is awaited for external side effects, but mutations to its context or response
do not rewrite the prepared result. Move response changes from afterCommit
into the appropriate finish hook. For example, JSON:API response metadata can be
added with:
await api.customize({
hooks: {
finishPost: {
functionName: 'add-write-metadata',
handler: ({ context }) => {
if (context.format !== 'jsonapi' || context.returning !== 'full') return
context.responseRecord.meta = { requestId: context.requestId }
}
}
}
})
Use data in response extensions and metadata, not functions or live application
objects. The snapshot uses structuredClone; a non-cloneable extension rejects
before commit, with owned transactions rolled back and borrowed transactions
left to their owner. This does not add validation of arbitrary JSON attributes
or metadata. Schema-declared temporal fields still use their existing conversion
rules. A failing afterCommit hook still rejects after the database has committed;
the error reports committed.
POST, PUT and PATCH clear any ID left by a previous operation before processing
hooks run. POST’s generated ID becomes available as context.id at
afterDataCallPost, and remains available to later hooks and the nested response
GET. Before insertion, a caller-supplied ID is available in
context.inputRecord.data.id after input conversion. PUT and PATCH assign their
validated target ID before beforeSchemaValidate. Early processing hooks can
inspect the current request in context.params; they must not use a previous
operation’s ID.
Direct imports of the setter helper
Resource declarations and setter callback arguments keep their existing shape.
If application code directly imported applyFieldSetters from the internal
resource-method common.js, update its call:
// Before: assign the helper's returned attributes.
context.inputRecord.data.attributes = await applyFieldSetters(
context.inputRecord.data.attributes, context.schemaInfo, context, api, helpers
)
// Now: the helper assigns the transformed attributes to this write context.
await applyFieldSetters(context, api, helpers)
The helper returns no value. It runs setters at the same point, after validation and before storage, without completing the transaction. Applications using public resource calls need no change for this internal simplification. The lifecycle guide documents the retained hooks and their ordering.
Application rollout and experimental features
Coordinate dependency versions, consumer repositories, generated code and seeds before upgrading applications. Run their actual workflows against the intended package set. Library-side verification does not establish consumer compatibility.
JSKIT repositories
JSKIT’s Document / Documents repository methods keep their existing public
names and JSON:API responses. Repository callers continue to pass plain payloads
and { trx, context } options. The shared implementation selects data for
writes, format: 'jsonapi' for documents, and returning: 'full' for returned
write attributes. DELETE explicitly selects returning: 'none'.
Custom repositories calling the library directly need the same changes. Plain
collection reads return an envelope with a data array; plain single-resource
reads and full writes return the record itself. When a foreign-key field has a
relationship alias, plain library input uses that alias. For example,
authorId: { belongsTo: 'authors', as: 'author' } accepts data: { author: id }.
JSKIT’s shared repository maps its domain payload’s authorId to author and
rejects payloads that supply both names.
Copied repositories can reuse the shared CRUD implementation and retain their
application-specific methods. Here lockDocumentById is the application’s
existing scoped lock method, kept in the same module:
import { createCrudJsonApiRepository } from '@jskit-ai/crud-core/server/jsonApiRepository'
const repository = {
...createCrudJsonApiRepository({ api, resource, resourceScopeName }),
lockDocumentById
}
Preserve custom scoping, locks and write normalization when removing copied
code. Standard document writes return full documents; the shared repository
does not forward a returning option. A custom minimal-response path still
needs an explicit resource call with data, format: 'jsonapi' and
returning: 'minimal'.
Use the repository’s transaction owner for operations involving JSON REST:
// Before: a raw Knex owner surrounded JSON REST repository writes.
await knex.transaction(async trx => {
await repository.createDocument(payload, { trx, context })
})
// After: the repository delegates ownership to api.transaction.
await repository.withTransaction(async trx => {
const document = await repository.createDocument(payload, { trx, context })
await trx('audit_entries').insert({ record_id: document.data.id })
})
Pass trx explicitly to every participating repository call. The transaction
remains callable for raw SQL; return normally to commit and throw to roll back.
Keep independent raw Knex transaction helpers for integrations that perform
only SQL. They do not need to become JSON REST consumers.
Put an allocation retry around the complete transaction. For example, the
application-owned allocateCode(trx) below computes a candidate using only SQL;
isCodeAllocationConflict identifies its exact unique constraint from the
underlying driver error. Other uniqueness failures must escape the loop.
import { findDuplicateEntryError } from '@jskit-ai/database-runtime/shared'
async function createWithAllocatedCode(payload, context) {
for (let attempt = 1; attempt <= 3; attempt++) {
try {
return await repository.withTransaction(async trx => {
const code = await allocateCode(trx)
return repository.createDocument({ ...payload, code }, { trx, context })
})
} catch (error) {
const duplicate = findDuplicateEntryError(error)
if (attempt === 3 || error?.transactionOutcome !== 'rolledBack' ||
!duplicate || !isCodeAllocationConflict(duplicate)) {
throw error
}
}
}
}
Each attempt gets a new transaction and recomputes the candidate. Do not catch
and retry standard() inside a failed CRUD lifecycle transaction, or restart a
transaction supplied by another owner. Keep external effects outside this
retrying unit unless the application can safely repeat or reconcile them.
Application-owned uploads need the same outcome check before compensation.
This example creates one new object used only by this operation. uploads.save
and uploads.remove are the application’s storage integration; the caller
supplies its own mutable diagnostics = { cleanupErrors: [] }:
async function createWithUpload(payload, file, context, diagnostics) {
const uploaded = await uploads.save(file)
diagnostics.uploaded = uploaded
try {
return await repository.withTransaction(trx =>
repository.createDocument(
{ ...payload, fileUrl: uploaded.url },
{ trx, context }
)
)
} catch (error) {
if (error?.transactionOutcome === 'rolledBack') {
try {
await uploads.remove(uploaded.key)
} catch (cleanupError) {
diagnostics.cleanupErrors.push({ key: uploaded.key, error: cleanupError })
}
}
throw error
}
}
The original write error and its outcome still reach the caller; secondary cleanup failures and the uploaded object’s identity remain in the application’s diagnostics. Other outcomes retain the object for application reconciliation. In particular, deleting it after a committed or unknown failure could break a stored record. Library-managed file uploads already register rollback cleanup; do not add this second cleanup path for those files. See file ownership.
JSKIT copies the outer context for each library operation; hooks inside that
operation share the resulting object. Its visibilityContext and scopeValues
maps are copied, while other nested application objects retain their references.
Use those maps for user/workspace scoping. A nested operation needs its own
working context and explicit transaction participation. Remove hooks that repair
dates or query results already normalized by v2; keep application-specific
serializers and policies.
Custom HTTP adapters must translate the library’s typed error codes and resource
subtypes into status codes, in addition to preserving transactionOutcome.
Library errors need not carry an HTTP statusCode. Validation is 422, missing
resources 404, forbidden resources 403, and conflicts 409. Client retry policies
must inspect the outcome: pending, committed and unknown are not permission
to replay a write, even if another copied error field resembles a retryable CSRF
failure. An application-defined retry callback owns its own policy.
The JSKIT migration uses Node 24 for verification and requires Node 24 or newer for packages that load v2. Upgrade the framework packages and lockfile together, rebuild the catalog and distributed agent docs from authored source patterns, and check custom app repositories and hooks separately. Passing JSKIT tests does not port Vibe64, its seeds or other applications. Those remain separate migration steps.
The local dispatcher preserves null/undefined throws; see transaction outcomes. Positioning now coordinates writes through database locks; its chapter describes tested concurrency and remaining storage requirements.
Positioning coordination and retired placeholders
Existing resource calls using beforeId, null for append and 'FIRST' continue
to work. PostgreSQL and MySQL writers serialize through a transaction-owned
coordinator row, including empty groups and moves between groups. SQLite can
report busy conflicts; stale transaction snapshots can reject. Applications must
retry a failed move in a fresh operation/transaction when appropriate.
Installation creates json_rest_api_positioning_locks if absent. Provision the
table before deployment when the runtime account lacks DDL permission, and keep
all cooperating writers on the same table. The positioning guide
contains its schema and collation requirements.
- Changing a group without
beforeIdnow places the record at the configured beginning/end of its destination group instead of retaining a potentially duplicate key. Same-group updates preserve their position. - PUT creates receive positions, and PUT replacements preserve or recalculate their managed position. Other persisted fields still follow complete PUT replacement rules.
- Missing/out-of-group targets append; a self-targeted move stays in place. Explicit targets now use normal GET permissions and visibility.
- Default query ordering uses the supported query hook. Bytewise collation on position columns is required for ordinary sorting and cursor pagination; the allocator’s binary comparisons do not change those query semantics.
- Remove
rebalanceThreshold; it never caused automatic rebalancing and now rejects. The no-opapi.positioning.reorder()method has been removed. Use ordinary PATCH calls or atomic bulk PATCH for actual changes. - Existing keys must be valid fractional keys. Key exhaustion rejects before exceeding declared storage length; migrate/rebalance the data deliberately.
- Deep imports of
calculatePosition()now take(previousPosition, nextPosition)string bounds. The former array/ID arguments are no longer supported.
Native query builders and explicit custom-filter translation
Both storage modes now give query hooks a native Knex builder. Canonical storage no longer intercepts Knex methods to guess logical field/table names. This removes the old query proxy, its alias/descriptor traversal and its partial emulation of Knex methods. Keep the supplied scoped builder (or clone it) and use explicit column/value translation. Native Knex joins, callbacks, aliases, aggregates, raw bindings and cloning retain Knex’s own behavior.
Custom search filters now receive a third argument:
customRank: {
type: 'number',
applyFilter(query, input, { column, value }) {
query.where(column('rank'), '>=', value('rank', input))
}
}
Replace query.where('rank', '>=', input) with this form when rank is a logical
resource field. column(field) returns a qualified physical column, including
canonical slots or an explicitly mapped ordinary column. value(field, input)
applies that field’s storage conversion/custom serializer. The validated public
filter input remains the second argument; it is not automatically reinterpreted
as another field’s stored value. The details object also supplies context and
scopeName. The callback’s this remains the same builder as its first argument.
For a declared joined filter such as actualField: 'groups.code', use
column('groups.code') and value('groups.code', input). These helpers resolve
the declared path’s prepared alias; they do not create arbitrary additional
joins. Existing related-query permission checks still run. Custom SQL continues
to own any extra relationships or visibility semantics it introduces. Callbacks
run synchronously inside the current predicate group and must mutate that
builder; returned builders/promises are not awaited or selected.
For knexQueryFiltering hooks, the existing
createStorageAdapterUtilities({ context }) helper supplies
translateColumn(scopeName, field) and translateFilterValue(scopeName, field,
input). Keep using context.knexQuery.tableName as the active SQL alias and
context.knexQuery.storageAdapter for resource storage operations. The legacy
context.knexQuery.adapter now refers to that same storage adapter in both
modes; it is no longer a query-method proxy. Removed private proxy state includes
__adapter__, its related-descriptor/alias maps, and
context.resourceToTableName. Importing the removed internal
anyapi-query-adapter.js module is no longer supported.
A filtering hook can translate a local field without importing another helper:
handler({ context }) {
const { query, tableName, storageAdapter } = context.knexQuery
const rankColumn = `${tableName}.${storageAdapter.translateColumn('rank')}`
query.where(rankColumn, '>=', storageAdapter.translateFilterValue('rank', 10))
}
Apply this predicate to the supplied query, including count invocations. Keep using declared cross-resource search paths where possible; writing a native join does not automatically add the target resource’s permission or visibility checks.
The storage adapter’s buildBaseQuery({ transaction, tableAlias }) constructs a
native builder with the optional alias and retains canonical tenant/resource
predicates qualified by that alias. It borrows the database handle and does not
complete transactions. Translate every logical identifier used in custom SQL;
raw SQL text and binding strings are not rewritten. Consumer repositories have
not been migrated by this library-only change.
Storage helpers consume prepared write attributes
Ordinary PUT/PATCH no longer re-extract belongs-to values from the request’s
relationship object. Core relationship processing and setters already prepare
context.inputRecord.data.attributes before calling storage, as they do for
POST and canonical writes. Resource API calls need no migration for this change.
Custom storage implementations should consume those prepared attributes before
applying physical storage mapping, preserving setter results.
A custom dataPost helper must return a nonempty string, a finite number, or a
bigint ID. The lifecycle converts bigint IDs to strings without losing precision.
Invalid helper results reject before afterDataCall hooks and roll back the
write, including with returning: 'none' or 'minimal'. Resource normalizeId
is not invoked again on the helper’s result.
The unexported internal plugins/core/lib/writing/knex-json-api-transformers-writing.js
module and its processBelongsToRelationships helper have been removed. If you
imported that file directly, remove the second relationship conversion and use
the prepared attributes supplied to your storage callback. Public relationship
validation and authorization remain in the resource lifecycle.
Pass explicit field maps to foreign-key inspection
The internal getForeignKeyFields(fields, relationships) helper accepts a field
map. If an extension passes a schema instance, change the call to
getForeignKeyFields(schema.structure, relationships). Library callers already
pass schemaInfo.schemaStructure or an enrichment field map.
The helper no longer guesses whether a property named structure is a wrapper.
That guess misread a legitimate attribute named structure, hid relationship
backing fields and could bypass the prohibition on identity serializers. Public
resource declarations can continue to use structure as an attribute name.
Table helper schemas use an explicit structure wrapper
Direct calls to createKnexTable, addKnexFields, alterKnexFields,
generateKnexMigration and generateKnexMigrationDiff now require a schema
object whose structure property contains field definitions. A
json-rest-schema schema instance already supplies that property and remains
accepted. Field definitions must be objects.
Change bare field maps at the schema argument:
// Before
await alterKnexFields(knex, 'books', { title: { type: 'string' } })
// After
await alterKnexFields(knex, 'books', {
structure: { title: { type: 'string' } }
})
For helpers that accept table metadata, keep storage, indexes, foreignKeys
and checkConstraints beside structure. Fields with those names belong inside
structure, like any other field. Add/alter helpers still reject top-level table
constraints they cannot apply; use a migration diff for those changes.
Public resource calls such as books.addKnexFields({ fields }),
books.alterKnexFields({ fields }) and books.createKnexTable() keep their current
arguments. The plugin supplies the explicit helper schema internally.
Bare maps are rejected before SQL or migration output is produced. This removes
the ambiguity that could interpret a field named structure as a wrapper and
generate columns from its definition’s type and nullable properties.
Read helpers use compiled field maps
Internal response, include and field-selection helpers now read
scope.vars.schemaInfo.schemaStructure directly. Extensions supplying synthetic
resource metadata must provide that field map alongside schemaRelationships;
schemaInstance is the validation object, not an alternate field-map argument.
Normal registered resources already provide both compiled values.
The internal getSchemaStructure export from knex-constants.js is removed.
Use the resource’s compiled schemaStructure, or explicitly access .structure
on a schema instance you own. No wrapper guessing or compatibility adapter is
needed.
Schema compilation snapshots declarations
Resource schema compilation now copies declaration data on entry and again when
publishing its result. Changing an original field’s nested storage, validation,
relationship or dependency declaration after registration no longer changes the
currently compiled schema. Enrichment hooks may mutate or replace their supplied
fields during compilation; retaining that object and editing it after the hook
pipeline finishes no longer changes published metadata.
Declare changes before registration, or use the supported schema operation such
as canonical addKnexFields() while application operations are stopped. Do not
use mutations of resource.vars.schemaInfo as a configuration API. Each explicit
compilation uses its supplied input; this does not add automatic invalidation,
concurrent reconfiguration, or a general resource-customization API.
Callbacks retain their identity and may still access application state through closures. Plain objects, arrays, dates, regular expressions and nested schema instances are copied; nested schemas retain their registered type/validator handlers and operation definitions. Other class instances remain application-owned references. The library does not freeze caller-owned objects or attempt to clone external services. This boundary applies to compiled declarations; it does not turn all runtime options, hook variables or application state into immutable data.
Canonical registration also keeps an owned copy of declarations for later field
additions. Changing the original registration object, or a previous
addKnexFields({ fields }) input, does not silently change existing fields during
a subsequent addition. Pass new declarations to the supported operation instead.
Initial sortableFields and defaultSort arrays are copied into resource
variables. Editing the original arrays no longer changes query behavior. An
explicit assignment such as resource.vars.defaultSort = ['-name'] still affects
subsequent requests; this is a runtime variable override, not a schema change.
It does not recompile fields or change their query eligibility.
Plugin registries require explicit entries
Named row policies, autofilter resolvers and autofilter presets must be registered
as own entries of their configuration maps. Inherited names such as constructor
or toString no longer count as registered entries; resource registration rejects
them as unknown. Autofilters also require an actual declared schema field rather
than accepting inherited object properties as fields.
Explicitly registered policy, resolver and preset names remain valid, including
prototype-like names. Use computed properties or Object.fromEntries() if a map
key is __proto__, so it is an own entry rather than JavaScript object-literal
prototype syntax. Explicit resolver names are also retained in plugin
introspection. Policy and resolver callbacks still run per request; their return
values and authorization results are not cached by this change.
When configuration changes take effect
| Configuration | Effective boundary | How to change it |
|---|---|---|
| Named policy/resolver maps and autofilter presets | Plugin installation captures entries and preset declarations; resource registration selects them | Supply the intended registry before installing the plugin. Callback closures may still read current application state. |
| Fields, validation, search, relationships, projections and computed declarations | Awaited resource registration compiles and publishes an owned snapshot after enrichment | Declare them before registration. Finish registering forward-referenced resources before serving operations. |
| Enrichment hook fields | Mutable during the corresponding compilation stage | Edit context.fields; context.originalFields remains a separate view of that stage’s original nested declaration data. Install enrichment hooks before registering resources. |
| Runtime hook/helper/variable customization | A subsequent method invocation observes the current customization | Use api.customize() for runtime hooks/helpers/global variables. Resource variable overrides remain explicit; they do not recompile schema facts. |
| Response defaults, query default/max limits, include depth and allowed sort fields | Read for each operation; method parameters override the defaults where that method supports an override | Change runtime variables deliberately. Include-depth and allowed-sort changes replace the retained request contracts; format and page-default changes do not require a new schema or adapter. |
| Canonical field additions | Successful metadata commit followed by publication of the new compiled resource | Use addKnexFields() while application operations are stopped. Existing source declarations and previous addition inputs are owned copies. Failed additions retain the prior published resource. |
| Descriptor-only mapping refresh | Explicit refresh after the committed mapping migration | Follow the earlier canonical migration instructions; registry eviction alone does not republish an active resource. |
api.customize() is not a schema replacement API. Arbitrary writes to
scopeOptions, compiled schemaInfo, descriptors or canonical maps are not
supported configuration operations. Structural changes outside the supported
addition path need updated source declarations, any required data/schema migration,
and resource initialization. Concurrent structural reconfiguration is unsupported.
There is no automatic fingerprinting of mutable metadata or cache of caller
permissions, resolver values or query results.
Compiled output definition indexes
Response normalization now reads resource.vars.schemaInfo.outputFields and
outputRelationships. These are compiled lookup indexes over the existing field,
computed, projection and relationship definitions. They are refreshed with the
compiled resource, including after supported canonical field additions.
Ordinary resource calls need no changes. Code that calls the deep
normalizeRecordAttributes() helper with handcrafted scope metadata must supply
these indexes; the helper no longer rebuilds them from schemaStructure,
computed, queryFields and schemaRelationships for every record. Prefer passing
real api.resources. For a single attributes object with an explicit definition
map, normalizeAttributes(attributes, definitions) remains available unchanged.
Direct mutation of compiled indexes is not a configuration API.
Compiled foreign-key membership
Code constructing synthetic schemaInfo for deep query/conversion helpers must
provide foreignKeyFields, a Set of logical backing-field names for ordinary
and polymorphic relationships. Obtain this metadata from the registered resource
where possible. Compilation publishes the set with the resource; do not mutate
it or add resource IDs to it. Make a local copy when extending identity fields.
Supported canonical field additions publish a replacement set automatically.
The public resource call syntax is unchanged by this optimization.
Conversion no longer reads relationship declarations to reconstruct polymorphic backing-field membership. Supply both type and ID backing fields in the compiled set when constructing synthetic metadata. The conversion error boundary wraps failures while reading that set, retaining typed errors and original causes.
Relationship aliases must be unique
Each relationship name must have exactly one declaration. Two belongsTo fields
cannot share an as name, and a field’s as cannot also appear in the resource’s
relationships map. Previously these configurations were accepted but different
operations could choose different definitions or cardinalities.
Give distinct relationships distinct names, for example authorId with
as: 'author' and reviewerId with as: 'reviewer'. Declare a belongs-to
relationship on its backing field; remove a duplicate declaration from the
relationships map. Update payload and include names if you rename an alias.
The compiler checks enriched declarations too. A rejected canonical field
addition retains the previously published resource and its stored data.
Derived attributes cannot declare relationships
Computed fields and SQL query projections cannot declare belongsTo or
belongsToPolymorphic. These fields produce attribute values and do not provide
the stored backing fields required by relationship operations. Previously the
compiler accepted these options and could produce inconsistent relationship
lookup and output normalization. The same rejection applies after enrichment.
Remove relationship options from derived attributes. Declare an ordinary
relationship on its stored foreign-key field using belongsTo and as; declare
a polymorphic relationship in the resource’s relationships map, identifying its
stored type and ID fields. Give a derived display value its own attribute name.
A rejected canonical addition keeps the previously published schema intact.
Compiled relationship lookup and polymorphic declaration location
findRelationshipDefinition() now reads the registered resource’s
schemaInfo.outputRelationships index. Deep-helper callers constructing synthetic
metadata must supply that index, including both ordinary belongs-to aliases and
declared relationships. Entries reference the existing definitions. Prefer the
compiled metadata on api.resources and do not mutate its lookup index. The
helper returns null for absent names, including inherited object names.
Declare belongsToPolymorphic in the resource’s relationships map, with its
typeField and idField pointing to stored schema fields. Placing it inside an
individual stored field is now rejected, including after schema enrichment and
when adding canonical fields. Previously that placement was accepted but lookup
could not find the relationship and its backing fields were not recognized.
Move the relationship definition into the map; keep its backing fields in
schema. No stored data conversion is needed merely to move the declaration.
Request contracts reuse compiled relationship metadata
Deep callers of getRequestContracts() constructing synthetic schemaInfo must
provide foreignKeyFields and outputRelationships along with their stored and
search schema metadata. Normal registered resources already provide these facts.
The foreign-key set excludes relationship backing fields from writable attribute
contracts; the relationship index supplies linkage cardinalities and target types.
There is no fallback that reconstructs these indexes from raw declarations.
For an ordinary belongs-to relationship, its belongsTo value determines the
allowed resource type, consistent with relationship operations. An additional
target property does not override it. Use target for has-one/has-many and
many-to-many declarations. Existing public call syntax and the one-retained-variant
request-contract cache are unchanged by this refactor.
File upload rules follow compiled resource configuration
File handling now reads the compiled stored fields, including MIME/size rules
produced by schema:enrich. Mutating the original declaration after registration
no longer changes those rules. Installing the file plugin after resource
registration also discovers existing compiled file fields. Supported canonical
field additions refresh the file-field list when the compiled resource changes;
a rejected addition keeps the previous rules, including an empty file-field list.
File storage backends are external handles. A file field’s storage object with
an upload() method retains its identity and method receiver during declaration
snapshotting, so backend state and intentional method replacement continue to
work. MIME and size declarations are still owned snapshots. Ordinary column
mapping objects without upload() remain copied declarations. Directly mutating
published compiled metadata is unsupported.
New compiler hook: schema:compiled
Plugins can derive resource metadata in schema:compiled. It receives
context.scopeName, context.scopeOptions and context.schemaInfo after core
field, relationship, storage, query and dependency compilation. Read the compiled
facts and attach plugin-owned metadata under a distinct key on schemaInfo.
Change stored, search, computed or projected declarations in their existing
enrichment hooks; core compiled facts are read-only at this stage.
The candidate is published only after all handlers succeed, using an owned snapshot. Retained candidate objects cannot mutate the published metadata. Throwing rejects registration or a canonical field-addition candidate before it replaces the resource. This hook runs on compilation, not on ordinary requests or a descriptor-only refresh.
Autofilter uses this hook to validate and derive its configuration on every
compilation. Resolver metadata and write stamping follow enriched relationship
aliases after canonical additions; removing a required autofilter field rejects
the candidate. api.autofilter.getScopeConfig(name) is unchanged. Deep readers of
resource.vars.autofilter must move to resource.vars.schemaInfo.autofilter or
prefer the public inspection helper. The root registry summary remains available
through api.autofilter.getConfig(). No forwarding metadata copy is kept.
Include configuration is validated before publication
Include configuration is now checked from the compiled relationship definitions,
including enrichment, before a resource is published or a canonical field
addition commits. An explicit resource queryMaxLimit takes precedence over the
inherited value for this check. Previously the registration stages could reject
valid higher overrides or miss enriched limits above a lower resource maximum.
include.limit accepts a non-negative integer, null or false; omission keeps
the existing default. Zero is valid, and null/false retain their existing
unbounded-include meaning. Negative, fractional, NaN and nonnumeric values now
fail compilation. include.orderBy retains its array form and validates entry
syntax during compilation. Target-field resolution and database window-function
support remain query-time checks so forward resource references still work.
Query-time target caps and selection behavior are unchanged.
The deep validateIncludeConfigurations helper now has a named export and takes
the schema:compiled candidate context (scopeName, scopeOptions, schemaInfo),
not the former scope-added context. Normal plugin users need no call-site change.
Enhancing an existing logger
The internal enhanceLogger(logger, options) utility now safely replaces methods
on the supplied logger. Its wrappers capture each original writer and receiver
at enhancement time; previously they looked up the replaced method again and
could recurse until the stack overflowed. The utility still returns the same
logger, preserves writer return values and propagates writer failures. Existing
createEnhancedLogger wrappers likewise retain the writers captured at creation.
Install the desired base writers before creating an enhanced logger.
Error causes in diagnostic output
Operation failure reports now include these stable fields:
| Field | Meaning |
|---|---|
method |
Resource method, HTTP verb, or named setup/socket operation; null when unavailable |
scopeName |
Resolved resource name; null before resource resolution or for server-wide operations |
phase |
Reporting boundary, such as writeFailure, httpError, include, admission, or redisShutdown |
backend |
Knex client name, socketio, or redis; null when unavailable |
transactionOutcome |
Existing transaction state at reporting time: none, pending, committed, rolledBack, or unknown |
phase describes where an error is reported. It does not identify every hook
that ran before the failure. Existing nested getter, setter, include and
relationship-metadata errors retain their more specific context and causes.
Cleanup warnings may report pending even when the operation later commits.
Socket notification permission checks run outside a transaction and report
none; subscription-matching reads inside a write report its borrowed transaction
state. Setup/debug traces are informational and need not contain this envelope.
Formatted diagnostics now include native Error.cause and AggregateError.errors
properties, which JavaScript normally makes non-enumerable. Nested errors retain
their names, messages and selected stacks, including errors inside diagnostic
details. Primitive causes are retained. Existing depth and cycle limits apply
to these nested values, and the original errors are not modified. Consumers that
parse diagnostic objects should allow these additional fields. This does not
change HTTP error responses or the errors thrown by resource methods.
Shared filter traces omit payloads
The shared basic-filter trace messages no longer print filter values, entire filter objects or field definitions. They retain scope, table, field and operator information. Filtering behavior and SQL bindings are unchanged. This is a specific trace-output change. See field-aware diagnostics for structured redaction and its limits.
Ordinary storage operation messages also omit query parameters, POST bodies and PATCH attributes. Upload-completion messages omit stored URLs, and Socket.IO subscription messages omit filters. These changes affect diagnostics only; records, upload results and subscription acknowledgements retain their values.
Include-loader diagnostics now report ID counts rather than identifier arrays. They also omit complete relationship/include definitions and selection lists from the affected load messages. Query construction and returned includes are unchanged.
Connector error logs use route templates
Express and Fastify request-error diagnostics now use the registered route
pattern, such as /countries/:id, as path. They no longer include a raw url
field or put query strings and concrete resource IDs in that path metadata.
Express may omit path for errors that occur before a route is matched.
HTTP routing and error responses are unchanged. Error-object details are a
separate diagnostic payload and may still carry resource identifiers.
Both connectors await error diagnostics. A failing writer leaves the original
HTTP response intact and adds the logging failure to context.cleanupErrors
with phase: 'logging' and during: 'httpError'. Express route-registration
diagnostics also preserve the original setup rejection if logging fails.
HTTP diagnostic loggers omit structured body and headers containers,
including on errors raised before a route supplies resource metadata. Known JSON
parser failures (type: 'entity.parse.failed') produce a generic diagnostic
preview with name, message, type and status. Their raw message, stack, body and
cause are omitted because parser messages can quote the request body. Express
uses this parser identifier already; the Fastify JSON parser supplies the same
identifier. The original error still reaches hooks and HTTP error mapping.
A read hook that throws null or undefined produces a generic JSON:API 500
response. Conditional PUT and positioning target reads recognize typed
not-found errors; other hook failures propagate through the transaction’s
normal failure handling.
Formatted errors are bounded previews
formatError and formatErrorString now produce bounded diagnostic previews:
50 entries per container, 256 serialized-value visits, 2,048 characters per
string, and a shared 8,192-character budget for copied text. Existing depth and
cycle handling still applies. [Truncated] markers identify omitted content;
these limits are not a promise of an exact byte length after JSON escaping or
pretty printing. The original error and its attached values are unchanged.
Non-object toJSON() results appear under toJSONResult rather than being
spread into character-index properties. Big integers and symbols in diagnostic
values become text, functions become markers, and object-valued error names or
messages become a non-string-text marker. These are diagnostic representations,
not changes to the errors thrown by the API or the HTTP error contract.
One-line summaries share the bounded formatter and are capped at 2,048
characters, including any truncation marker.
Short summary spelling is unchanged. Summaries inspect only message, code,
violations and fields; they do not invoke custom toJSON() methods.
Enhanced logger events share a preview budget
Standard enhanced log methods now bound the complete argument list, including
message strings and additional diagnostic data. They format at most 50 supplied
arguments and use the existing shared depth/traversal/text limits for the emitted
event. Truncation is explicit; source arguments are not mutated. The internal
formatDiagnosticValue helper exposes that same bounded value conversion.
Validation errors now produce one structured event containing their details.
The former extra Validation error details: event has been removed. Logger
receiver binding, return values and exceptions still come from the original
writer. These bounds do not apply to direct calls that bypass the enhanced
logger, and bounding is not sensitive-field redaction.
The storage, positioning and file plugins use this formatter for their own diagnostics too. Include messages report counts instead of complete path collections. These changes preserve logger binding and query/file behavior; they do not add a resource-field policy to every logger.
Field-aware write diagnostics
The resource write-error logger now derives redacted field names from compiled
outputFields: both hidden: true and normallyHidden: true are redacted when
that metadata is available. Explicitly requesting a normally-hidden field in a
response does not make its diagnostic value visible. Caller input and thrown
errors are unchanged.
The internal enhanced logger and formatter accept redactFields for structured
payload keys. Matching values become [Redacted] before they are read. Validation
objects whose field is a listed name or data.attributes.<name> retain field
and rule metadata with a redacted message; their other payload properties are
omitted. Summaries use the same policy. Enhanced loggers snapshot the configured
field-name list at creation.
When field redaction is configured or includeStack: false is selected,
formatError inspects ordinary error properties without invoking toJSON().
Custom conversion could otherwise read an excluded stack or copy a hidden value
under another key before filtering. Unrestricted diagnostics still use custom
toJSON() results. Nested error-envelope accessors are inspected once through the
bounded value formatter; their returned errors use ordinary property inspection.
This is structured-field redaction, not a scanner for arbitrary secret text in messages. Early failures without compiled metadata cannot infer which application attributes are sensitive. Custom search validators should return general validation messages: their text can become the query error’s top-level message, where field-key redaction cannot recognize a value embedded in a sentence.
The resource-field policy applies to write reports, matched HTTP resource routes,
file cleanup, subscription admission/removal, subscription matching and
notification permission reports. It uses the resolved resource’s compiled fields;
it does not guess a resource from a URL, an authentication error or a client-supplied
subscription name. Authentication and server-wide reports omit raw handshake,
credential and subscription-filter objects. Application properties deliberately
attached to an authentication error still appear as bounded diagnostics: a field
named accessKey there does not inherit an unrelated resource’s field policy.
Buffers and other binary values are represented by type and byte length, including uploaded-file metadata and nested errors. Identifiers, filenames, paths, arbitrary application/driver error text and values copied under unrelated keys are not automatically secret-free. Use general error messages, omit secrets from custom error properties, and apply any broader retention/redaction policy in the application-owned logger. Direct application logger calls do not pass through the library’s formatter. The built-in runtime no longer emits upstream method arguments or plugin-option dumps.
Socket connection/disconnection diagnostics and Redis setup/shutdown diagnostics
contain throwing and rejecting writers. They cannot prevent handler installation,
subscription acknowledgement or later Redis cleanup attempts. A Redis setup
failure retains cleanup failures as secondary AggregateError members. The Redis
adapter is configured before attaching Socket.IO to the caller’s HTTP server, so
an adapter setup failure leaves that server’s listeners intact. If destruction
itself fails, startup reports the failure without waiting indefinitely for the
connection attempt that could not be cancelled.
Opt-in stored revision migration
Resources that select versionField need an explicit stored string field and a
backfill of existing rows before conditional writers are enabled. Follow the
version-field migration guide for
ordinary and canonical storage, a tested backfill example, retry/rollback and
readiness checks. Clients submit expectedVersion outside record attributes;
bulk PATCH/DELETE use aligned expectedVersions arrays. These are revision
conditions. HTTP representation conditions use the separate contract below.
Opt-in HTTP representation conditions
The executable client example is examples/conditional-http-client.js in the
repository. Import readForConditionalUpdate, pass the complete resource URL
(including its query), edit the returned document, then call save with a
JSON:API update document. Supply application headers and credentials: 'include'
when your cross-origin session requires them. Each returned object retains the
original read’s ETag: after saving, read again before another edit. The example
returns the write’s Response so the caller can handle 412 and other errors.
Run node --test tests/http-validator-client-example.test.js on Node 24 to
exercise it against real Express and Fastify HTTP servers.
HTTP representation conditions require no version column. They compare the selected JSON response, including requested includes, fieldsets, computed values and response-hook changes.
Enable it when installing either connector, before registering resources:
await api.use(ExpressPlugin, { mountPath: '/api', httpValidators: true })
// Or use FastifyPlugin with the same httpValidators option.
The default is false. Successful GET/HEAD responses with a body receive a
strong ETag. Preserve the complete quoted header value and send it unchanged in
If-Match when updating or deleting the same resource. Use the same query string
and representation-selecting request headers for the read and write:
const url = '/api/articles/42?include=author'
const selectedHeaders = { Accept: 'application/vnd.api+json' }
const current = await fetch(url, { headers: selectedHeaders })
if (!current.ok) throw new Error(`Read failed: ${current.status}`)
const etag = current.headers.get('ETag')
if (!etag) throw new Error('The selected response has no ETag')
const updated = await fetch(url, {
method: 'PATCH',
headers: {
...selectedHeaders,
'Content-Type': 'application/vnd.api+json',
'If-Match': etag
},
body: JSON.stringify({
data: { type: 'articles', id: '42', attributes: { title: 'Revised title' } }
})
})
if (updated.status === 412) {
// Read again and resolve the competing change before submitting another edit.
} else if (!updated.ok) {
throw new Error(`Update failed: ${updated.status}`)
}
A matching strong tag permits registered resource PUT/PATCH/DELETE. A stale tag
returns 412 with code REST_API_PRECONDITION_FAILED. Weak tags cannot match;
malformed headers return 422. If-Match: * checks visible existence only and
does not detect intervening edits. Omitting the header leaves writes
unconditional. Conditional PUT cannot create a missing resource. Collection
POST, relationship mutations and bulk routes currently reject If-Match with
422; use their documented direct revision conditions where supported. Normal
validation and authorization errors take precedence over comparison. A hidden
PUT target rejected by existing resource checks retains 404; a missing target
that otherwise permits PUT-create returns 412. These HTTP PUT outcomes are not
an identical hidden/missing response contract. This
option does not implement If-None-Match caching. Stored expectedVersion
conflicts remain a distinct 409 error.
For cross-origin browser clients, install CorsPlugin after the connector and
allow If-Match requests and expose ETag responses. These arrays replace the
defaults, so retain other headers your application needs:
await api.use(CorsPlugin, {
origin: 'https://app.example.com',
credentials: true,
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Content-Type', 'Authorization', 'X-Requested-With',
'X-HTTP-Method-Override', 'Accept', 'Origin', 'If-Match'
],
exposedHeaders: ['X-Total-Count', 'X-Page-Count', 'Link', 'Location', 'ETag']
})
Enabling this option makes the connector serialize successful GET/HEAD bodies
once with JSON.stringify after response hooks and send those exact bytes.
Express JSON spacing, replacers and escaping settings, and Fastify custom JSON
serializers, do not process those pre-serialized bodies. Move required body
transformations into the resource output or transport response hook before
enabling it. Middleware that subsequently changes bytes or content encoding
must also maintain the corresponding validator. Successful PUT responses emit
no validator in this mode; fetch the selected representation again for its tag.
A strong conditional write renders a speculative GET inside its managed
serializable transaction before changing the resource. Response hooks see
context.precondition === true and normalized GET request metadata during that
render, then run separately for the actual write response. Keep rendering
deterministic and free of external side effects. Computed database reads must
borrow context.transaction and preserve the caller’s authorization context.
Normalized request headers are copied; native request/reply objects and
application callbacks are still owned by the actual request. Do not use native
response methods while rendering the speculative view. Transaction conflicts
currently retain native error handling (HTTP 500), with no automatic retry.
See the API reference for conditional request options and version-field migrations for database preparation.