Schema Design
Learn how to design effective schemas for your data model.
A schema is a flat record
Every key is a registered name and every value is one of the three schema kinds: entity, list, or indexedList
Entity Schema
An entity represents a single addressable object with optional fields and relations to other entities.
const post = {
type: 'entity' as const,
id: 'id', // name of the field that holds the entity's primary id
key: (id: string | number) => `post:${id}`, // Redis key generator
fields: {
title: { type: 'string' as const },
content: { type: 'string' as const },
views: { type: 'number' as const },
published: { type: 'boolean' as const },
metadata: { type: 'object' as const },
tags: { type: 'array' as const },
createdAt: { type: 'string' as const },
},
relations: {
author: { type: 'user', kind: 'one' as const }, // 1:1
comments: { type: 'comment', kind: 'many' as const }, // 1:N
category: { type: 'category', kind: 'one' as const },
},
ttl: 3600, // optional; seconds. Falls back to cache.defaultTTL.
version: '1.0.0', // optional metadata
};| Property | Type | Required | Description |
|---|---|---|---|
type | 'entity' | yes | Discriminator |
id | string | yes | Name of the field on your data that holds the id |
key | (...args) => string | yes | Generates the Redis key. Include the id to avoid collisions |
fields | Record<string, FieldDefinition> | no | Self-documenting field types; advisory only (no coercion) |
relations | Record<string, RelationDefinition> | no | Names of related entity types and their cardinality |
ttl | number (seconds) | no | Per-schema TTL. Falls back to cache.defaultTTL |
version | string | no | Reserved for migration tooling |
Field Types
Field types are advisory only. The cache does NOT coerce values. They exist so schemas are self-documenting and obvious typos are caught early.
| Type | Round-trip with default serializer | With JSON_SERIALIZER |
|---|---|---|
string | OK | OK |
number | OK | OK (loses NaN, ±Infinity) |
boolean | OK | OK |
object | OK | OK |
array | OK | OK |
date | OK (preserved as Date) | becomes ISO string |
bigint | OK (preserved as BigInt) | throws (JSON can't serialize) |
map | OK (preserved as Map) | becomes {} |
set | OK (preserved as Set) | becomes {} |
regexp | OK (preserved as RegExp) | becomes {} |
buffer | OK (preserved as Buffer) | becomes { type: 'Buffer', data: [...] } |
Relations
relations: {
author: { type: 'user', kind: 'one' as const },
comments: { type: 'comment', kind: 'many' as const, cascade: true, lazy: false },
}| Property | Type | Description |
|---|---|---|
type | string | Schema key of the related entity |
kind | 'one' | 'many' | Cardinality. one → single object; many → array |
cascade | boolean | Reserved for future cascade-delete (currently no-op) |
lazy | boolean | Reserved for future lazy-loading (currently no-op) |
Reserved field names
Any key starting with __rse_ is rejected at schema registration time to prevent collisions with internal metadata.
Plain List Schema
A list is a JSON array of IDs stored under a single Redis key. Best for short, flat collections.
const recentPosts = {
type: 'list' as const,
entityType: 'post', // must match a registered entity schema
key: (...params: any[]) => `posts:recent`,
idField: 'id', // which field on entities provides the id
ttl: 600,
};Trade-offs
Pros
- Minimal storage overhead
- Simple Lua scripts
- Atomic add/remove
Cons
- Every read parses the whole array
- Not suitable for large lists
- No built-in pagination or sorting
Choosing list flavour
Use list when… | Use indexedList when… |
|---|---|
| The list is short (≤ a few hundred ids) | The list may grow large |
| You always read the whole list | You need pagination |
| Insertion order is enough | You need sorting by score |
| You don't need cascade invalidation | You want trackMembership |
If in doubt, prefer indexedList. The overhead is modest and it scales without surprise.
Indexed List Schema
An indexedList stores IDs in a Redis ZSET, scored by any field on the entity. Supports pagination, sorting, size capping, and cascade invalidation.
const globalFeed = {
type: 'indexedList' as const,
entityType: 'post',
key: (...params: any[]) => `feed:global`,
idField: 'id',
scoreField: 'createdAt', // optional; defaults to insertion time
maxSize: 10_000, // optional; trims oldest beyond this
trackMembership: true, // optional; enables cascade invalidation
ttl: 86400,
};| Property | Type | Required | Description |
|---|---|---|---|
type | 'indexedList' | yes | Discriminator |
entityType | string | yes | Schema key of the entity stored |
key | (...args) => string | yes | Redis key generator |
idField | string | yes | Field name that uniquely identifies items |
scoreField | string | no | Entity field used as ZSET score. Default: Date.now() at insert |
maxSize | number | no | Trim list to this size on every insert (drops lowest-scored) |
trackMembership | boolean | no | Records back-index for cascade invalidation. Default false |
Score derivation
How scores are calculated
- If
scoreFieldis omitted, the score isDate.now()at insertion time - If
scoreFieldis set, the cache readsentity[scoreField]and converts viaNumber(value) - If that's non-finite, it tries
new Date(value).getTime() - ISO 8601 strings, Date objects, numeric strings, and numbers all work
- If neither conversion yields a finite number, throws
InvalidOperationError
Membership tracking
When trackMembership: true, every insert also adds the list key to a per-entity back-index set (__rse_membership:<entityType>:<id>). On invalidateEntity, the cache atomically reads that back-index and ZREMs the entity from every tracked list.
Performance consideration
Do not enable trackMembership unless you actually need cascade invalidation — it doubles the write traffic per insert.
Schema Design Rules of Thumb
- One key per entity, always. Don't define two entity schemas pointing to the same Redis key — it breaks normalization.
- Use indexedList over list whenever a collection might exceed ~200 items. ZSET ops are O(log N); JSON-array ops rewrite the entire blob.
- Set trackMembership: true only when you actually need cascade invalidation. Each insert costs one extra SADD.
- scoreField should be monotonic (timestamps, sequence ids). Non-monotonic scores make pagination unstable.
- maxSize is your friend for unbounded feeds — caps memory at a known ceiling.
- TTL hierarchy: parent ≥ children when using cascadeTTL. Otherwise children may expire before the parent expects them.