Skip to main content

Services

access-router-client exposes two service classes:

  • ModelService<T> for model routers
  • DataService<T> for data routers

They share the same response normalization and lazy-request behavior, but their method sets are different.

ModelService<T>​

Use ModelService<T> against a server-side model router.

Model reads usually return data as Model<T> wrappers. That means read results are both typed data and persistence-aware editing objects.

Standard model methods​

  • list(args?, options?, axiosRequestConfig?) or list(options?, axiosRequestConfig?)
  • read(id, args?, options?, axiosRequestConfig?) or read(id, options?, axiosRequestConfig?)
  • new(axiosRequestConfig?)
  • create(data, options?, axiosRequestConfig?)
  • update(id, data, args?, options?, axiosRequestConfig?) or update(id, data, options?, axiosRequestConfig?)
  • upsert(data, options?, axiosRequestConfig?)
  • delete(id, axiosRequestConfig?)
  • distinct(field, axiosRequestConfig?)
  • distinctAdvanced(field, filter, axiosRequestConfig?)
  • count(axiosRequestConfig?)
  • countAdvanced(filter, axiosRequestConfig?)

distinct and distinctAdvanced return Response<unknown[]>: the sibling server returns raw distinct values without string conversion, so numeric and boolean values arrive as-is. Narrow elements (for example with typeof v === 'string') before calling string methods.

These methods map closely to the server-side model router operations.

Basic model list/read/update accept select in their args and send it as a GET query parameter (update sends it as a PATCH ?select= value). List args also support sorting and pagination. Skip args with list({ includeCount: true }, config), read(id, { tryList: false }, config), or update(id, data, { returningAll: false }, config); stored listArgs / readArgs / updateArgs defaults still apply. Keep args and options in separate objects: mixed keys throw TypeError. Empty ambiguous objects retain the original form (list args-first, read/update options-first); supplying the final config slot selects the full form, including read(id, {}, {}, config) and update(id, data, {}, {}, config). Both forms return typed lazy requests with grouping and $include() support. Basic projections retain the existing T/explicit TData response types.

Basic list sort accepts signed-field strings ('name -createdAt'), field/order objects ({ name: 1, createdAt: -1 }), and ordered tuples ([['name', 'asc'], ['createdAt', 'desc']]). The client sends one space-separated GET sort query value; grouped queries and correlated includes keep the original sort form. listArgs.sort defaults apply when omitted, and empty sorts send sort= to disable backend default sorting. GET sorting uses the same server field authorization, sortableFields, and stripDisallowedSort as advanced lists.

create(...) and createAdvanced(...) preserve input cardinality: one object returns ModelResponse<T>, while an array returns ArrayModelResponse<T>. One-item arrays still return arrays.

In broad terms:

  • list(...) is the simple GET-based list route
  • listAdvanced(...) is the richer POST-based query route
  • read(...) is the simple GET-by-id route
  • readAdvanced(...) and readAdvancedFilter(...) are richer POST-based read routes
  • create(...) / update(...) / upsert(...) are the simpler mutation helpers
  • createAdvanced(...) / updateAdvanced(...) / upsertAdvanced(...) expose richer mutation arguments such as select, populate, and task execution

Advanced query and mutation methods​

  • listAdvanced(filter, args?, options?, axiosRequestConfig?)
  • readAdvanced(id, args?, options?, axiosRequestConfig?)
  • readAdvancedFilter(filter, args?, options?, axiosRequestConfig?)
  • createAdvanced(data, args?, options?, axiosRequestConfig?)
  • updateAdvanced(id, data, args?, options?, axiosRequestConfig?)
  • upsertAdvanced(data, args?, options?, axiosRequestConfig?)

Common advanced args and options​

Advanced methods can use combinations of:

  • select
  • populate
  • include
  • sort
  • skip
  • limit
  • page
  • pageSize
  • tasks
  • skim
  • includePermissions
  • includeCount
  • includeExtraHeaders
  • populateAccess
  • tryList
  • ignoreCache

The exact shape depends on the specific method, but the names mirror the access-router request contract.

Rules of thumb:

  • use non-advanced methods for straightforward CRUD by id
  • use advanced methods when you need populate, includes, server tasks, or filter-based reads
  • use includeCount only when you actually need total counts, since it may add work on the server side
  • use includeExtraHeaders when the server exposes count metadata through headers rather than body metadata
  • ignoreCache always lives on the options argument (never on args); use { ignoreCache: true } to bypass an existing cache entry
  • includePermissions is honored by ModelService<T> advanced and mutation methods (including update(...) and upsert(...), which transmit it as the include_permissions query parameter). DataService<T> does not advertise includePermissions at all — the server data routers do not parse include_permissions, so the client removed it from DataListOptions, DataListAdvancedOptions, and DataReadOptions; data records are returned without _permissions. See the DataService<T> section below for details.

Example​

const users = await userService.listAdvanced(
{ public: true },
{
select: ['name', 'role'],
sort: { name: 1 },
limit: 20,
},
{
includeCount: true,
includePermissions: true,
},
{
headers: { user: 'admin' },
},
);

Subqueries​

Many model methods accept sq in their options. sq is also accepted in adapter and service defaults for list, listAdvanced, read, readAdvanced, and readAdvancedFilter, with per-call, service, then adapter precedence (a per-call sq wins over defaults).

That is a client-side way to embed another lazy request into a filter, so the server can resolve it as an access-router subquery.

const orgs = await orgService.listAdvanced(
{
_id: userService.readAdvancedFilter(
{ name: 'lucy2' },
undefined,
{ sq: { path: 'orgs', compact: true } },
),
},
{ select: ['name'] },
);

This works because the client replaces embedded lazy requests with the special $$sq root-query metadata expected by the server.

That gives you a way to express server-side dependent queries without manually constructing the low-level root-router payload.

Correlated Includes​

A correlated include runs a target query per parent document using values from that parent. Build the inner query with the familiar service methods, reference parent fields explicitly with parentField(), and attach the result with $include(path):

import { parentField } from '@web-ts-toolkit/access-router-client';

const userWithIncludes = await userService.readAdvanced('user-id-1', {
include: [
orgService
.readAdvanced(parentField('orgId'), { select: ['name', 'description'] })
.$include('org'),
postService
.listAdvanced(
{ authorId: parentField('_id'), reviewerId: parentField('managerId'), title: '$special' },
{ select: ['title'], sort: { createdAt: -1 }, limit: 5 },
)
.$include('posts'),
postService.countAdvanced({ authorId: parentField('_id') }).$include('postCount'),
],
});
void userWithIncludes;

All seven builders compose this way:

MethodReference positionInner operation
read(id)identifier argumentread
readAdvanced(id, args)identifier argumentread
readAdvancedFilter(filter, args)filter argumentread
list(args) + $include(path, { filter })supplemental filterlist
listAdvanced(filter, args)filter argumentlist
count() + $include(path, { filter })supplemental filtercount
countAdvanced(filter)filter argumentcount
const basicPosts = postService.list({ limit: 5 }).$include('posts', {
filter: { authorId: parentField('_id') },
});
void basicPosts;

Reference scope and result-shape notes:

  • References resolve against the immediate parent document on the outer server. Nested includes inside args.include bind to the target doc of the enclosing include, never to the outer parent.
  • Plain strings are never references: title: '$special' keeps its literal query meaning. Match a literal { $parent: 'x' } object with { $escape: { $parent: 'x' } } (use the $eq-wrapped form when the literal sits in a bare field position).
  • Reads attach the doc or null, lists attach arrays, counts attach numbers. A missing or null reference skips the target query and attaches the same no-match shape (null / [] / 0).
  • Identifier reads preserve the target's configured identifier behavior (custom id fields included). Lists apply limit/page per parent (contrast with legacy localField/foreignField includes, whose batch pagination applies across the whole parent set); counts use count semantics, never a capped list count.
  • Conversion is synchronous and performs zero HTTP calls. A call carrying references returns a frozen, non-thenable descriptor — it cannot be awaited into data or grouped; convert it with $include() first. Reference-free calls keep their ordinary lazy/grouped behavior.
  • Descriptors are transport-inert: the inner query always executes on the outer server under the outer request context. Mixing adapters in one include tree is allowed at build time and never dispatches to the inner service's transport.
  • Supported inner args are allowlisted per operation: reads forward select/sort/include, lists additionally forward per-parent pagination (skip/limit/page/pageSize), basic read/count carry no args. Explicit populate, tasks, execution-only options (skim, includePermissions, includeCount, tryList, populateAccess, ignoreCache, includeExtraHeaders, sq), or transport config on a reference-bearing call throws at $include(); the same keys inherited from service/adapter defaults are silently dropped. options on the wire entry must stay empty.
  • Referenced parent fields are loaded internally for resolution even when omitted from select, then trimmed from output unless selected (or override-selected). A policy-forbidden reference still resolves — only its query effects are visible, never the value itself. Sibling include output and decorate/task output never feed references (stable pre-include snapshot).
  • Execution is bounded per request (maxCorrelatedQueries total inner executions, maxCorrelatedDepth nesting depth); exceeding either fails the whole request. Target authorization denials and runtime errors fail the whole parent request — partial per-parent error shapes are never attached.
  • Typed output needs an explicit result generic with the path first: .$include<'org', Org>('org'). Without it the path is still attached but typed unknown; nothing is inferred from partial projections, reads admit null, and nested values stay plain (never Model-wrapped).
  • Requires a server with correlated-include support. Older servers silently drop the new entries instead of executing them — check the server release notes for the minimum version.

Client preparation limits​

Filter scanning, correlated capture/copy, and subquery rewriting now reject cycles, depth over 64 edges (each root is depth 0), or over 10,000 expanded value visits per boundary with synchronous CorrelatedIncludeError. This includes reference-free and supplemental $include() filters. Preparation performs zero HTTP and does not execute lazy requests or freeze caller containers.

Count containers, primitive leaves, array slots (including holes), and each repeated shared-object occurrence. Shared acyclic references are supported. Capture combines supplied id/filter/args/options; undefined optional roots are absent. Conversion combines id or rewritten filter with effective forwarded args, including defaults. Internal wire-envelope keys do not count toward the filter-only budget. A conversion can exceed its aggregate budget even if the individual builder inputs fit, especially after $$sq metadata expansion.

$escape contents stay literal during marker scanning and rewriting but are still structurally checked. Live requests remain opaque until their __query becomes $$sq wire data, which is checked before copying. Reduce oversized filters/args instead of relying on previously unbounded preparation. These plain-object/array structural limits are not byte limits, latency guarantees, or bounds on arbitrary getter/proxy/exotic-instance behavior. Service defaults have a separate normalization budget; server execution limits remain separate as well.

Subdocument Helpers​

ModelService<T> also exposes subdocument helpers from id(id):

const statusHistory = userService.id(userId).subs('statusHistory');

Available methods:

  • list(axiosRequestConfig?)
  • listAdvanced(filter?, args?, axiosRequestConfig?)
  • read(subId, axiosRequestConfig?)
  • readAdvanced(subId, args?, axiosRequestConfig?)
  • create(data | data[], axiosRequestConfig?)
  • update(subId, data, axiosRequestConfig?)
  • bulkUpdate(data[], options?, axiosRequestConfig?)
  • delete(subId, axiosRequestConfig?)

Also available:

  • id(id).fetch(args?, options?, axiosRequestConfig?) as a convenience alias for readAdvanced(id, ...)

These helpers are only as capable as the matching subdocument operations exposed by the server router. If the server did not enable subs.someField.create, the client helper exists but the request will still be rejected by the server.

Return shape​

Subdocument helpers return plain data, not Model<S> instances.

  • list(...), listAdvanced(...), create(...), and bulkUpdate(...) return a SubDocumentListResponse<S>: after narrowing on success, raw and data are the plain array of subdocument objects and count is the array length. The sibling server never emits totalCount on subdocument list results, so SubDocumentListResponse<S> does not carry totalCount (use count on successful results instead).
  • read(...) and readAdvanced(...) return a SubDocumentResponse<S>: raw and data are the plain single subdocument object on success, or null on failure.

Subdocuments are deliberately not wrapped as Model<S>: a Model<S> returned here would expose save(), which would target the parent model route (/:parentId) with the subdocument _id instead of the correct subdocument route (/:parentId/:sub/:subId). To persist a subdocument, always call the parent-scoped helper explicitly:

await statusHistory.update(subId, { label: 'processed' });
await statusHistory.create({ label: 'queued', flag: 'orange' });
await statusHistory.bulkUpdate([{ _id: subId, label: 'processed' }]);
await statusHistory.delete(subId);

create(...) accepts a single object or an array of objects (the server's subMutationBodySchema is z.union([record, array(record)])). It always returns the post-create subdocument array — the server responds with the full subdocument list, and the client normalizes the count === 1 case to [newDoc] for one consistent shape.

Example​

const statusHistory = userService.id(userId).subs('statusHistory');

// `created.data` is the plain post-create array (e.g. all entries on the
// parent). `created.raw` is the same array; `created.count` is its length.
const created = await statusHistory.create({
label: 'queued',
flag: 'orange',
});
const newDoc = created.data[created.data.length - 1];

// `create(...)` also accepts an array when adding multiple rows at once:
const createdMany = await statusHistory.create([
{ label: 'queued', flag: 'orange' },
{ label: 'in-review', flag: 'blue' },
]);
const added = createdMany.data.slice(-2);

// `listed.data` is the plain array of subdocuments; `listed.count` mirrors
// the server's `count` field (not `totalCount`).
const listed = await statusHistory.list();

// `bulkUpdated.data` is the plain updated array.
const bulkUpdated = await statusHistory.bulkUpdate([
{ _id: 'sub-1', label: 'approved', flag: 'green' },
{ _id: 'sub-2', label: 'rejected', flag: 'red' },
]);

// `read.data` is the plain single subdocument (or null on 404).
const read = await statusHistory.read('sub-1');

DataService<T>​

Use DataService<T> against a server-side data router.

Supported methods:

  • list(args?, options?, axiosRequestConfig?)
  • listAdvanced(filter, args?, options?, axiosRequestConfig?)
  • read(id, options?, axiosRequestConfig?)
  • readAdvanced(id, args?, options?, axiosRequestConfig?)
  • readAdvancedFilter(filter, args?, options?, axiosRequestConfig?)

Unlike ModelService<T>, DataService<T> is read-only from the client’s point of view.

It returns plain data objects rather than Model<T> wrappers.

Use it when the server data source is not a Mongoose model router and does not need client-side persistence helpers.

Advanced read options​

For readAdvanced(...) and readAdvancedFilter(...):

  • ignoreCache lives on the options argument (DataReadAdvancedOptions), not on the args. Use { ignoreCache: true } in the options position to bypass an existing cache entry. This matches the placement used by list, listAdvanced, and read.
  • includePermissions is intentionally absent. The access-router data router body schema for advanced reads rejects the options key, and the root router drops item.options for data operations, so advertising it here was a type-level promise the server cannot honor. Direct and grouped advanced reads compose identical payloads as a result.

Example​

const fruits = await fruitService.listAdvanced(
{ public: true },
{ select: ['id', 'name'], limit: 10 },
{ includeCount: true },
);

const apple = await fruitService.readAdvanced('apple', { select: ['name'] });

// Cache-bypass on an advanced read uses the options position:
const freshApple = await fruitService.readAdvanced(
'apple',
{ select: ['name'] },
{ ignoreCache: true },
);

Request Config And Errors​

Every service method accepts an Axios request config as its last argument.

Common patterns:

  • pass headers for auth or request-scoped permissions
  • pass throwOnError: true to convert a failed normalized response into ServiceError
  • pass ignoreCache: true in supported method options when you need a fresh read

Request-scoped permissions are especially common with access-router setups. For example, if the server derives permissions from req.headers.user, the same header needs to be sent from the client for reads, writes, and grouped requests.

Example:

const user = await userService.read('user-1', undefined, {
headers: { user: 'admin' },
throwOnError: true,
});