CtrlK
BlogDocsLog inGet started
Tessl Logo
SkillPublic

data-client-schema

Model the app's data with @data-client schemas, which normalize responses into one shared cache so an update shows everywhere. Use it whenever an endpoint returning data is added or its response changes (resource, RestEndpoint, GraphQL, Endpoint), when the data's shape changes, when deriving views from loaded data, or when one item looks different in two places. Covers Entity, GQLEntity, Collection, Union and Query.

Quality

89%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Passed

No findings from the security scan

SKILL.md
Quality
Evals
Security

1. Defining Schemas

Compose schemas to match the JSON an endpoint returns. Import them from the protocol package the app uses (@data-client/rest, @data-client/graphql or @data-client/endpoint; both protocol packages re-export endpoint). For GraphQL, extend GQLEntity instead of Entity; it declares the id field used as pk().

Object

List

Map

  • new Collection(Values(Schema)) - mutable/growable maps
  • new Values(Schema) - immutable maps

Lens-dependent entity fields

Derived / selector pattern

  • new Query(Queryable) - memoized programmatic selectors

    const queryRemainingTodos = new Query(
      TodoResource.getList.schema,
      entries => entries.filter(todo => !todo.completed).length,
    );

    Define Query transformations with the data model (e.g. src/resources/) — not inside custom hooks wrapping useSuspense/useQuery, which hides data dependencies and couples data logic to view code.

2. Entity best practices

  • Every Entity subclass defines defaults for all non-optional serialised fields: bare TS field types emit no runtime value, so schema inference breaks.
  • Override pk() only when the primary key ≠ id.
  • static indexes = ['username' as const] to look up by a unique non-pk field with useQuery(User, { username }), instead of building lookup Maps
  • pk() return type is number | string | undefined
  • Override Entity.process(value, parent, key, args) to adapt input before normalizing, e.g. add fields from args/url, or turn an API's stand-in for a missing nested entity (like {}) into undefined (example)
  • static schema (optional) for nested schemas or deserialization functions; it also joins related entities client-side, so never merge or 'enrich' data by hand (relational-data)
    • When designing APIs, prefer nesting entities

3. Entity lifecycle methods

  • Normalize (JSON response → cache): operates on POJOs; output is JSON-serializable plain data stored in the normalized cache. Order: process() → pk() → validate() → visit nested schemas (recurse into schema fields) → if existing: mergeWithStore() which calls shouldUpdate() and maybe shouldReorder() + merge(); metadata via mergeMetaWithStore().
  • Older data arriving after newer (a poll, slow request or embedded copy): override shouldReorder(existingMeta, incomingMeta, existing, incoming) to compare a version field such as updatedAt; that alone keeps the newer fields. shouldUpdate() is for skipping updates to save work.
  • Denormalize (cache → component): creates Entity class instances via fromJS(), restoring prototype chain so getters, methods, and schema processing work. Order: createIfValid() → validate() → fromJS() → unvisit nested schemas (recurse into schema fields).

4. Union Types (Polymorphic Schemas)

To define polymorphic resources (e.g., events), use Union and a discriminator field.

import { Union } from '@data-client/rest';

export abstract class Event extends Entity {
  type: EventType = 'Issue';    // discriminator field is shared
  /* ... */
}
export class PullRequestEvent extends Event { /* ... */ }
export class IssuesEvent extends Event { /* ... */ }

export const EventResource = resource({
  path: '/users/:login/events/public/:id',
  schema: new Union(
    {
      PullRequestEvent,
      IssuesEvent,
      // ...other event types...
    },
    'type', // discriminator field
  ),
});

5. Collections (Mutable Lists & Maps)

Collections wrap Array or Values schemas to enable mutations (add/remove/move).

pk routing

pk() uses nestKey(parent, key) when nested in an Entity and available; otherwise it uses argsKey(...args), then serializes the result. Without options, it defaults to argsKey: params => ({ ...params }), using all endpoint args as the collection key.

  • argsKey — derive pk from endpoint arguments (default)
  • nestKey — derive pk from parent entity for nested shared-state collections

Define both on the same Collection to reuse one definition top-level and nested. When argsKey(args) and nestKey(parent) produce the same object shape, the top-level fetch and the nested read resolve to the same (referentially equal) array/map — push/unshift/assign/move/remove on either updates both:

const userTodos = new Collection([Todo], {
  argsKey: ({ userId }: { userId?: string }) => ({ userId }),
  nestKey: (parent: User) => ({ userId: parent.id }),
});

nonFilterArgumentKeys

Default createCollectionFilter uses nonFilterArgumentKeys (default: keys starting with 'order') to exclude non-filter args when matching collections. This affects which existing collections receive new items from push/unshift/assign/move: pushing with { userId: 1 } reaches the {} and { userId: 1 } lists, not { userId: 2 }, and a list arg left out of nonFilterArgumentKeys (such as a sort) keeps new items out of that list.

Override as function, RegExp, or string[]:

new Collection([Todo], { nonFilterArgumentKeys: /orderBy|sortDir/ })

A plain fetch of a Collection endpoint replaces its contents; only the extenders below and getPage add to it, so load more pages with getPage.

Extenders

Use each as an endpoint's schema (network; resource() exposes them as getList.push etc.) or with ctrl.set() (local-only).

MethodTypeDescription
pushArray | EntityAppend items to end
unshiftArray | EntityPrepend items to start
assignValuesMerge entries into map
removeBothRemove items by value from matching collections
moveBothRemove from collections matching existing state, add to collections matching new state
addWith(merge, filter?)BothCustom creation schema (used internally by push/unshift/assign)
moveWith(merge)BothCustom move schema (control insertion order, e.g., unshift merge for prepending)

6. Supplementary Endpoints (enrich existing entities)

When an endpoint returns partial or differently-shaped data for an entity already in cache (e.g., a metadata endpoint, a stats endpoint, a lazy-load expansion endpoint), use the same Entity as the schema — don't create a wrapper entity.

See partial-entities for patterns and examples.

7. Gotchas

  • An endpoint without schema bypasses normalization: its data is never updated by other endpoints' responses or ctrl.set().
  • Type responses with Denormalize<typeof schema>, not InstanceType<>; it handles every schema (Union, Collection), not just Entity.

References

Vue projects: read <name>.vue.md instead of <name>.md when it exists.

Repository

reactive/data-client

Path

.agents/skills/data-client-schema/SKILL.md

Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.