Thinking in Schemas
Consider a typical blog post. The API response for a single post might look something like this:
{
"id": "123",
"author": {
"id": "1",
"name": "Paul"
},
"title": "My awesome blog post",
"comments": [
{
"id": "324",
"createdAt": "2013-05-29T00:00:00-04:00",
"commenter": {
"id": "2",
"name": "Nicole"
}
},
{
"id": "544",
"createdAt": "2013-05-30T00:00:00-04:00",
"commenter": {
"id": "1",
"name": "Paul"
}
}
]
}
Declarative definitions
We have two nested entity types within our article: users and comments. Using various schema, we can normalize all three entity types down:
- TypeScript
- JavaScript
import { schema, Entity } from '@data-client/endpoint';
import { Temporal } from 'temporal-polyfill';
class User extends Entity {
id = '';
name = '';
}
class Comment extends Entity {
id = '';
createdAt = Temporal.Instant.fromEpochMilliseconds(0);
commenter = User.fromJS();
static schema = {
commenter: User,
createdAt: Temporal.Instant.from,
};
}
class Article extends Entity {
id = '';
title = '';
author = User.fromJS();
comments: Comment[] = [];
static schema = {
author: User,
comments: [Comment],
};
}
import { schema, Entity } from '@data-client/endpoint';
import { Temporal } from 'temporal-polyfill';
class User extends Entity { }
class Comment extends Entity {
static schema = {
commenter: User,
createdAt: Temporal.Instant.from,
};
}
class Article extends Entity {
static schema = {
author: User,
comments: [Comment],
};
}
Normalize
import { normalize } from '@data-client/normalizr';
const args = [{ id: '123' }];
const normalizedData = normalize(Article, originalData, args);
Now, normalizedData will create a single serializable source of truth for all entities:
{
result: "123",
entities: {
articles: {
"123": {
id: "123",
author: "1",
title: "My awesome blog post",
comments: [ "324", "544" ]
}
},
users: {
"1": { "id": "1", "name": "Paul" },
"2": { "id": "2", "name": "Nicole" }
},
comments: {
"324": {
id: "324",
createdAt: "2013-05-29T00:00:00-04:00",
commenter: "2"
},
"544": {
id: "544",
createdAt: "2013-05-30T00:00:00-04:00",
commenter: "1"
}
}
},
// contents excluded for brevity
indexes,
entitiesMeta,
}
Denormalize
import { denormalize } from '@data-client/normalizr';
const denormalizedData = denormalize(
Article,
normalizedData.result,
normalizedData.entities,
args,
);
Now, denormalizedData will instantiate the classes, ensuring all instances of the same member (like Paul) are referentially equal:
Article {
id: '123',
title: 'My awesome blog post',
author: User { id: '1', name: 'Paul' },
comments: [
Comment {
id: '324',
createdAt: Instant [Temporal.Instant] {},
commenter: [User { id: '2', name: 'Nicole' }]
},
Comment {
id: '544',
createdAt: Instant [Temporal.Instant] {},
commenter: [User { id: '1', name: 'Paul' }]
}
]
}
MemoCache
MemoCache is a singleton that can be used to maintain referential equality between calls as well
as potentially improved performance by 2000%. Its methods are memoized.
memo.denormalize
import { MemoCache } from '@data-client/normalizr';
// you can construct a new memo anytime you want to reset the cache
const memo = new MemoCache();
const { data, paths } = memo.denormalize(
Article,
normalizedData.result,
normalizedData.entities,
args,
);
const { data: data2 } = memo.denormalize(
Article,
normalizedData.result,
normalizedData.entities,
args,
);
// referential equality maintained between calls
assert(data === data2);
memo.denormalize() is just like denormalize() above but includes paths as part of the return value. paths
is an Array of paths of all entities included in the result.
memo.query
memo.query() allows denormalizing Queryable based on args alone, rather than a normalized input.
const data = memo.query(
Article,
args,
normalizedData,
);
Queryable
Queryable Schemas allow store access without an endpoint. They achieve this using the
queryKey method that produces the results normally stored in the endpoint cache.
This enables their use in these additional cases:
- useQuery() - Rendering in React
- schema.Query() - As input to produce a computed memoization.
- ctrl.get/snap.get
- memo.query()
- Improve performance of useSuspense, useDLE by rendering before endpoint resolution
Querables include Entity, All, Collection, Query,
Union, and Scalar. Lazy fields produce a Queryable via their .query accessor.
interface Queryable {
queryKey(
args: readonly any[],
queryKey: (...args: any) => any,
getEntity: GetEntity,
getIndex: GetIndex,
// `{}` means non-void
): {};
}
Custom Schema interface
Custom schema implementations can participate in normalization and denormalization by implementing the same methods as built-in schemas.
normalize(input, parent, key, delegate, parentEntity?)
normalize() receives the value at the current schema node and returns the
normalized representation to store in the surrounding result. Use
delegate.visit() to recursively normalize nested schemas and delegate.args
to read the endpoint args for the current normalize call.
import type { INormalizeDelegate } from '@data-client/endpoint';
class Wrapper {
schema = Article;
normalize(
input: any,
parent: any,
key: string | undefined,
delegate: INormalizeDelegate,
) {
const normalized = delegate.visit(this.schema, input.data, input, 'data');
return {
...input,
data: normalized,
requestId: delegate.args[0]?.requestId,
};
}
}
INormalizeDelegate
interface INormalizeDelegate {
visit(schema: any, input: any, parent: any, key: any): any;
readonly args: readonly any[];
readonly meta: { fetchedAt: number; date: number; expiresAt: number };
getEntities(key: string): EntitiesInterface | undefined;
getEntity(key: string, pk: string): any;
mergeEntity(schema: Mergeable, pk: string, incomingEntity: any): void;
setEntity(schema: { key: string }, pk: string, entity: any): void;
invalidate(schema: { key: string }, pk: string): void;
checkLoop(key: string, pk: string, input: object): boolean;
}
parentEntity is the nearest enclosing entity-like schema, when present. Most
custom schemas can ignore it; schemas that need their containing entity context
can use it to derive related storage keys.
Before v0.18, normalize() received args and visit as separate positional
parameters: (input, parent, key, args, visit, delegate, parentEntity?). Use
delegate.args and delegate.visit() instead.
denormalize(input, delegate)
denormalize() receives the normalized input and returns the denormalized value.
Use delegate.unvisit() for nested schemas and delegate.args for endpoint
args.
import type { IDenormalizeDelegate } from '@data-client/endpoint';
class Wrapper {
schema = Article;
denormalize(input: any, delegate: IDenormalizeDelegate) {
const value = delegate.unvisit(this.schema, input.data);
return {
...input,
data: value,
requestId: delegate.args[0]?.requestId,
};
}
}
If denormalized output changes based on args, register that cache dimension with
delegate.argsKey(fn). See Scalar for an args-dependent schema.
Schema Overview
| Data Type | Mutable | Schema | Description | Queryable |
|---|---|---|---|---|
| Object | ✅ | Entity | single unique object | ✅ |
| ✅ | Union(Entity) | polymorphic objects (A | B) | ✅ | |
| 🛑 | Object | statically known keys | 🛑 | |
| Invalidate(Entity) | delete an entity | 🛑 | ||
| List | ✅ | Collection(Array) | growable lists | ✅ |
| 🛑 | Array | immutable lists | 🛑 | |
| All | list of all entities of a kind | ✅ | ||
| Map | ✅ | Collection(Values) | growable maps | ✅ |
| 🛑 | Values | immutable maps | 🛑 | |
| Scalar | ✅ | Scalar | lens-dependent entity fields | ✅ |
| any | Query(Queryable) | memoized custom transforms | ✅ | |
| Lazy(Schema) | deferred denormalization | ✅ |