# `@web-ts-toolkit/access-router-client`

Typed client utilities for `@web-ts-toolkit/access-router` APIs.

## Supported Runtimes

The package ships at an `es2022` bundle target and is officially supported in
**Node 22+** (declared via `engines.node`) and **modern evergreen browsers**
(Chrome 94+, Edge 94+, Firefox 93+, Safari 16+, declared via `browserslist`).
See [Browser And Node Support](#browser-and-node-support) below for the
authentication contract, what Node-only and browser-only paths can and
cannot do, and the smoke-test coverage that catches Node built-in leaks.

## Installation

```sh
pnpm add @web-ts-toolkit/access-router-client
```

## Highlights

- typed model and data services
- lazy requests that can be grouped into one batch call
- `Model<T>` wrappers with dirty tracking and `save()`
- normalized response and error handling around Axios

## Unreleased Migration

This remediation release tightens several public runtime and TypeScript
contracts. When upgrading from the previous client contract:

| Area                                 | Before                                                                                                                         | After / required migration                                                                                                                                                                                                                                                                                                                                                                                                               |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Subdocuments                         | Results could expose parent-backed `Model<S>` values and `save()`.                                                             | Results are plain objects/arrays. Persist with the parent-scoped `subs(...)` helper's `update`, `create`, `bulkUpdate`, or `delete` methods.                                                                                                                                                                                                                                                                                             |
| Create and list counts               | Subdocument create looked scalar and subdocument lists used `totalCount`; model create was scalar-only.                        | Subdocument create accepts one or many and always returns the post-create array with `count`. Model create preserves input cardinality: object -> `ModelResponse`, array -> `ArrayModelResponse`. Model/data lists retain `totalCount`.                                                                                                                                                                                                  |
| Responses                            | Failure `data` and success fields could not be narrowed reliably.                                                              | Branch on `result.success`. Success has non-null `raw`/`data`; failure has `data: null` and the problem payload in `raw`. Model/data `totalCount` defaults to `0` when metadata is unavailable; read subdocument `count` after success.                                                                                                                                                                                                  |
| Cache                                | An enabled cache could be unbounded and credentialed requests did not require an explicit identity partition.                  | Cache is still off by default (`cacheTTL: 0`). Enabled caches admit supported GETs only, default to a 100-entry LRU, and require `cachePartition` for credentialed requests. Clear on identity changes and dispose on teardown.                                                                                                                                                                                                          |
| Grouping                             | A lazy request could be replayed or moved between direct and grouped execution; batch error policy could drift by entry.       | Each lazy request can be claimed once. Create a new request to execute again. All group members must share one effective `throwOnError` policy. Non-throwing batches return all entries; throwing batches run all callbacks and then reject with the first failure.                                                                                                                                                                      |
| Protocol types                       | Data permission options, object/tuple data sorts, and a count access argument were accepted. Filters were broadly permissive.  | Remove `includePermissions` from data calls, use string data sorts, call `countAdvanced(filter, config?)`, and fix invalid `FilterQuery<T>` values. Use `DottedPathFilter<T>` or `ServerSideCast<T>` only as explicit escape hatches.                                                                                                                                                                                                    |
| Paths, config, and model persistence | Dynamic path values were interpolated directly, inputs could be mutated, and projected models could lose persistence identity. | Pass raw path values for one-pass encoding; caller configs stay immutable. ID-based projected reads retain identity, while an existing model with no recoverable identity throws `MissingPersistenceIdentityError`. Use `set()`/`markModified()` for nested edits (dot, bracket-index, and quoted-key forms share one dirty root).                                                                                                       |
| Service defaults and subqueries      | Nested `Date` defaults could serialize as `{}` and accepted `sq` defaults were ignored without per-call `sq`.                  | Service defaults accept null, string, boolean, finite numbers, valid `Date` values (detached per request), plain objects, and arrays; functions, symbols, bigints, non-finite numbers, invalid `Date` values, non-plain instances, and cycles throw `UnsupportedServiceDefaultValueError`. Adapter/service/per-call `sq` precedence applies on `list`, `listAdvanced`, `read`, `readAdvanced`, and `readAdvancedFilter` (per-call wins). |
| Distinct result types                | `distinct`/`distinctAdvanced` were typed `Response<string[]>`, so string methods compiled on numeric/boolean server values.    | Both variants return `Response<unknown[]>` with no server-value stringification. Narrow elements (for example `typeof v === 'string'`) before calling string methods.                                                                                                                                                                                                                                                                    |

Grouped entry `headers` are now `{}` because the root protocol has no
per-operation headers. Structured grouped failure fields remain in `raw`.
The complete release-level before/after record is in the repository
`CHANGELOG.md`.

## Quick Start

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

type User = {
  _id?: string;
  name: string;
  role: string;
};

const adapter = createAdapter({
  baseURL: 'http://localhost:3000/api',
});

const userService = adapter.createModelService<User>({
  modelName: 'User',
  basePath: 'users',
});

const listResponse = await userService.listAdvanced(
  { role: 'admin' },
  { select: ['name', 'role'], limit: 10 },
  { includeCount: true },
);

const user = await userService.read('user-id-1');

if (user.success) {
  user.data.role = 'owner';
  await user.data.save();
}

const grouped = await adapter.group(
  userService.readAdvanced('user-id-1', { select: ['name'] }),
  userService.countAdvanced({ role: 'admin' }),
);
```

## 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)`:

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

type User = { _id?: string; orgId?: string; managerId?: string; name: string };
type Org = { _id?: string; name: string; description?: string; active?: boolean };
type Post = { _id?: string; authorId?: string; reviewerId?: string; title: string };

const adapter = createAdapter({ baseURL: 'http://localhost:3000/api' });
const userService = adapter.createModelService<User>({ modelName: 'User', basePath: 'users' });
const orgService = adapter.createModelService<Org>({ modelName: 'Org', basePath: 'orgs' });
const postService = adapter.createModelService<Post>({ modelName: 'Post', basePath: 'posts' });

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;

const basicPosts = postService.list({ limit: 5 }).$include('posts', {
  filter: { authorId: parentField('_id') },
});
void basicPosts;

const basicCount = postService.count().$include('postCount', {
  filter: { authorId: parentField('_id') },
});
void basicCount;

const filteredOrg = orgService
  .readAdvancedFilter({ _id: parentField('orgId'), active: true }, { select: ['name'] })
  .$include('org');
void filteredOrg;
```

All seven builders compose this way: `read`, `readAdvanced`,
`readAdvancedFilter`, `list` (+ supplemental `{ filter }`), `listAdvanced`,
`count` (+ supplemental `{ filter }`), and `countAdvanced`. 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 (`read` / `readAdvanced`) preserve the target's
  configured identifier behavior (custom id fields included). Lists apply
  `limit`/`page` per parent; 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. Mixing adapters in one include tree is allowed at build
  time and never dispatches to the inner service's transport.
- 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 (see the
  `@web-ts-toolkit/access-router` README "Correlated Includes" section).
  Older servers silently drop the new entries instead of executing them.

## Contract

The full website docs at
https://web-ts-toolkit.pages.dev/docs/packages/access-router-client describe
the same contract the installed package honors. The key points an installed
consumer needs:

- **Adapter defaults** (`createAdapter(axiosConfig?, adapterOptions?)`): the
  adapter applies the following Axios defaults unless overridden by your
  `axiosConfig`: `baseURL: '/api'`, `timeout: 0`, `withCredentials: true`, and
  the response-busting headers `Cache-Control: no-cache`, `Pragma: no-cache`,
  `Expires: 0`. Service `queryPath` defaults to `'__query'` and `mutationPath`
  defaults to `'__mutation'`; `rootRouterPath` defaults to `'root'`.
- **Cache & authentication policy:** credentialed requests are **never**
  cached unless `cachePartition` returns a stable, non-secret identity token.
  Browser cookie credentials, explicit `Authorization`/proxy authorization
  headers, API-key style headers, and Node `Cookie` headers supplied on the
  request config are all treated as credentialed.
  Sensitive headers (`authorization`, `cookie`, `proxy-authorization`,
  `x-api-key`, `x-auth-token`, `x-access-token`, `set-cookie`,
  `www-authenticate`) are excluded from cache keys
  regardless of the partition token. Only GET requests with supported JSON or
  text response semantics are cached; mutations and custom transforms or
  serializers always bypass caching. `cacheTTL` is measured in milliseconds;
  `cacheTTL: 0` (the default) disables the cache entirely, while enabled caches
  retain at most 100 entries by default.
  `clearCache()` drops every cached entry; `disposeCache()`
  drops entries and releases cache timers (call on adapter teardown so timers
  do not keep a Node process alive).
- **Direct vs grouped:** service methods return a lazy `LazyRequest<T>` that
  does not execute until `await`, `.then()`, `.catch()`, `.finally()`, or
  `.exec()`. `adapter.group(...)` batches multiple lazy requests into one
  root-router round trip; it only accepts lazy requests from **this**
  adapter's services, rejects already-started requests, and requires every
  member to share the same `AxiosRequestConfig` and effective `throwOnError`
  policy. Effective policy follows per-call, service, then adapter precedence;
  mixed policies reject before dispatch. Non-throwing groups return every
  normalized entry, including partial failures. Throwing groups run every
  executed entry's callback exactly once, then reject with the first failed
  entry's `ServiceError`. Once you `await` a lazy request it is no longer
  batchable. Group results preserve input order. Group entry `headers` are
  empty because the root protocol supplies only outer batch headers, not
  per-operation headers.
- **Response narrowing:** `Response<TRaw, TData = TRaw, TError = unknown>` is a discriminated
  union of `SuccessResult<TRaw, TData>` and `FailureResult<TError>`. Branch on
  `result.success` — on the `true` branch both `raw` and `data` are non-null;
  on the `false` branch `data` is always `null` (the server error payload
  lives in `raw`, when one was received). Pass a third generic to opt into a
  known error payload. List responses carry `totalCount`
  on `ListModelResponse<T>`; subdocument list responses carry `count` (the
  server's field) on `SubDocumentListResponse<S>`, never `totalCount`.
- **Subdocument shape:** `ModelService<T>.id(id).subs(sub)` helpers return
  **plain data**, not `Model<S>` instances. `list(...)`, `listAdvanced(...)`,
  `create(...)`, and `bulkUpdate(...)` return `SubDocumentListResponse<S>`.
  `read(...)` and `readAdvanced(...)` return `SubDocumentResponse<S>`.
  `create(...)` accepts a single object **or** an array and always returns the
  post-create subdocument array. Persist a subdocument by calling the
  parent-scoped helper explicitly — there is no subdocument `save()`.
- **Model create cardinality:** `create(...)` and `createAdvanced(...)` accept
  either one object or an array. Scalar input returns `ModelResponse<T>`;
  array input returns `ArrayModelResponse<T>`, including for a one-item array.
- **Mutation input types:** model create/update/upsert payloads default to
  `ModelMutationInput<T>` (`Partial<T>`), so object literals are checked for
  known field names and scalar types without pretending the server can infer
  required create fields. Pass `createModelService<T, TCreateInput,
TUpdateInput, TUpsertInput>(...)` when request schemas differ from the
  response model. Subdocument create/update helpers use
  `SubDocumentMutationInput<S>` (`Partial<S>` for object subdocuments) by
  default and can be customized through `subs<S, K, TCreateInput,
TUpdateInput>(...)`.
- **Nested model edits:** `Model<T>` tracks modified top-level paths and
  reconciles writes against the last loaded/saved snapshot. Direct mutation
  of nested objects/arrays (`obj.arr.push(...)`, `obj.sub.field = x`) is
  **not** tracked. Use `set('path.to.field', value)` (applies + reconciles)
  or `markModified('topLevelField')` after a direct mutation (forces dirty
  without reconciling). Reverting a value to its snapshot clears the dirty
  flag. `save()` persists only tracked modified top-level fields; if `_id`
  exists it calls `update(...)`, otherwise it calls `create(...)`. Multiple
  overlapping `save()` calls on the same wrapper are serialized in call order.
  Document fields named like model methods (`save`, `reset`, `set`, `get`,
  `assign`, `toJSON`, etc.) are reserved for the wrapper API on direct
  property access; use `get(...)`, `set(...)`, `assign(...)`, or `toObject()`
  for those data fields. The exported `ModelData<T>` helper reflects that
  reserved-name contract in response and `Model.create(...)` types.
- **Supported runtimes:** Node 22+ and modern evergreen browsers (see
  [Supported Runtimes](#supported-runtimes) and
  [Browser And Node Support](#browser-and-node-support) above).

## Primary Exports

The package is named-export-only (no default export). Import every public
symbol from the package root:

```ts
import {
  // Adapter factory — the primary entry point.
  createAdapter,
  // Correlated includes — explicit parent references and include composition.
  parentField,
  // Thrown for reference/descriptor misuse (malformed markers, forbidden
  // positions, `$include()` validation, grouping a descriptor).
  CorrelatedIncludeError,
  // Service classes. `ModelService` and `DataService` are what
  // `createAdapter(...)` constructs; `Service` is an advanced base class
  // for callers that need a bespoke service shape.
  ModelService,
  DataService,
  Service,
  // Dirty-tracking model wrapper.
  Model,
  // Thrown when `throwOnError` is enabled and a request resolves to a
  // `{ success: false }` result.
  ServiceError,
  // Thrown instead of creating a duplicate when an existing projected model
  // has no recoverable persistence identity.
  MissingPersistenceIdentityError,
  // Low-level lazy-promise wrapper with a single shared execution. Service
  // methods add private adapter metadata required by `adapter.group(...)`;
  // consumer-created wrappers execute directly and are not groupable.
  wrapLazyPromise,
  // Normalized response-count / pagination header names.
  CustomHeaders,
  // Generic list helpers used internally by model list methods; useful for
  // callers that manipulate `Model<T>[]` directly.
  replaceItemById,
  removeItemById,
} from '@web-ts-toolkit/access-router-client';

import type {
  // Adapter and per-factory option types.
  AdapterOptions,
  ModelServiceOptions,
  DataServiceOptions,
  // Cache policy types referenced by `AdapterOptions`.
  CacheController,
  CachePartitioner,
  // Discriminated response union and success/failure members.
  Response,
  SuccessResult,
  FailureResult,
  // Model and data response aliases.
  ModelResponse,
  ArrayModelResponse,
  ListModelResponse,
  ModelData,
  DataResponse,
  ArrayDataResponse,
  ListDataResponse,
  SubDocumentResponse,
  SubDocumentListResponse,
  // Per-method args and options for both `ModelService<T>` and `DataService<T>`.
  // (See the "TypeScript And Errors" doc page for the full list.)
  Defaults,
  DataDefaults,
  // Filter, projection, populate, sort, and request-meta primitives.
  FilterQuery,
  ModelMutationInput,
  SubDocumentMutationInput,
  DottedPathFilter,
  ServerSideCast,
  Projection,
  Populate,
  Sort,
  Document,
  // Correlated includes — wire payload, reference, filter, and output types.
  ParentRef,
  CorrelatedInclude,
  CorrelatedIncludeOp,
  CorrelatedIncludeArgs,
  CorrelatedFilterQuery,
  SupplementalIncludeOptions,
  WithCorrelatedOutputs,
} from '@web-ts-toolkit/access-router-client';

void [
  createAdapter,
  parentField,
  CorrelatedIncludeError,
  ModelService,
  DataService,
  Service,
  Model,
  ServiceError,
  MissingPersistenceIdentityError,
  wrapLazyPromise,
  CustomHeaders,
  replaceItemById,
  removeItemById,
];

type StablePublicTypes = [
  AdapterOptions,
  ModelServiceOptions,
  DataServiceOptions,
  CacheController,
  CachePartitioner,
  Response<unknown>,
  SuccessResult<unknown>,
  FailureResult,
  ModelResponse<Document>,
  ArrayModelResponse<Document>,
  ListModelResponse<Document>,
  ModelData<Document>,
  DataResponse<unknown>,
  ArrayDataResponse<unknown>,
  ListDataResponse<unknown>,
  SubDocumentResponse<unknown>,
  SubDocumentListResponse<unknown>,
  Defaults,
  DataDefaults,
  FilterQuery<Document>,
  ModelMutationInput<Document>,
  SubDocumentMutationInput<{ label: string }>,
  DottedPathFilter<Document>,
  ServerSideCast<Document>,
  Projection,
  Populate,
  Sort,
  Document,
  ParentRef,
  CorrelatedInclude<string, unknown, CorrelatedIncludeOp>,
  CorrelatedIncludeOp,
  CorrelatedIncludeArgs,
  CorrelatedFilterQuery<Document>,
  SupplementalIncludeOptions<Document>,
  WithCorrelatedOutputs<Document, []>,
];
void (null as unknown as StablePublicTypes);
```

The names above are the primary public imports most consumers need. Invalid
service defaults throw `UnsupportedServiceDefaultValueError` (defined in
`src/services/shared.ts`; not re-exported from the package root, so it is not
part of the import example above). The full
root export inventory is locked by `access-router-client.exports.unit.test.ts`
and mirrored in `llms.txt`, so implementation internals
such as `useCacheInterceptors`, `cloneConfigWithCacheBypass`,
`finalizeRootEntry`, `applyGroupCallbacks`, `makeRequest`, `createWrapHelper`,
`ADAPTER_ID_KEY`, `STARTED_KEY`, `CACHE_HEADER`, `CachePolicy`, and `RootEntry`
are intentionally not exported. Configure caching through `AdapterOptions`
(`cacheTTL`, `cachePartition`, `cacheCapacity`); control an existing cache
through the returned adapter's `clearCache()` and `disposeCache()` methods.

## Browser And Node Support

- **Bundle target:** `es2022` (see `tsup.config.ts`). The single shared target
  runs in Node 22+ and the documented evergreen browser floor without
  transpilation; the source imports no Node built-ins.
- **Runtime metadata:** `engines.node: ">=22"` (npm/pnpm warn or refuse on
  older Node) and `browserslist: ["chrome >= 94", "edge >= 94", "firefox >= 93", "safari >= 16"]`.
  `pnpm exec browserslist` resolves this package config without error.
- **Authentication contract:** `withCredentials: true` is the adapter
  default, so browser requests may include cookies when CORS and cookie policy
  allow them. `Authorization`, proxy authorization, API-key style headers, and
  Node `Cookie` headers are explicit Axios config values; `withCredentials`
  does not create them. Credentialed caching still requires an explicit
  `cachePartition` token so one identity cannot receive another's cached
  response.
- **Cache timers:** the in-memory cache uses `setTimeout`/`clearTimeout`
  (available in both runtimes). The optional Node `unref()` guard is
  feature-detected and is a no-op in browsers, so `clearCache()` and
  `disposeCache()` are safe to call in either runtime.
- **Smoke test:** `pnpm --filter @web-ts-toolkit/access-router-client
test:browser-smoke` (powered by Vite + jsdom) imports the _built_
  `dist/index.mjs` under a browser-like environment and exercises the public
  runtime surface. It is a smoke check for Node built-in leaks and basic ESM
  browser bundling, not a real-browser engine/version compatibility gate. This
  smoke test also runs as part of the default `pnpm test` for the package.

## Documentation

Full package documentation lives online (the website sources are not packed
into the npm tarball, so the links below point to the published website rather
than repository-relative paths that would not resolve after install):

- overview: https://web-ts-toolkit.pages.dev/docs/packages/access-router-client
- adapter: https://web-ts-toolkit.pages.dev/docs/packages/access-router-client/adapter
- services: https://web-ts-toolkit.pages.dev/docs/packages/access-router-client/services
- model wrapper: https://web-ts-toolkit.pages.dev/docs/packages/access-router-client/model
- typing and errors: https://web-ts-toolkit.pages.dev/docs/packages/access-router-client/typescript-and-errors
