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
};
PropertyTypeRequiredDescription
type'entity'yesDiscriminator
idstringyesName of the field on your data that holds the id
key(...args) => stringyesGenerates the Redis key. Include the id to avoid collisions
fieldsRecord<string, FieldDefinition>noSelf-documenting field types; advisory only (no coercion)
relationsRecord<string, RelationDefinition>noNames of related entity types and their cardinality
ttlnumber (seconds)noPer-schema TTL. Falls back to cache.defaultTTL
versionstringnoReserved 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.

TypeRound-trip with default serializerWith JSON_SERIALIZER
stringOKOK
numberOKOK (loses NaN, ±Infinity)
booleanOKOK
objectOKOK
arrayOKOK
dateOK (preserved as Date)becomes ISO string
bigintOK (preserved as BigInt)throws (JSON can't serialize)
mapOK (preserved as Map)becomes {}
setOK (preserved as Set)becomes {}
regexpOK (preserved as RegExp)becomes {}
bufferOK (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 },
}
PropertyTypeDescription
typestringSchema key of the related entity
kind'one' | 'many'Cardinality. one → single object; many → array
cascadebooleanReserved for future cascade-delete (currently no-op)
lazybooleanReserved 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 listYou need pagination
Insertion order is enoughYou need sorting by score
You don't need cascade invalidationYou 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,
};
PropertyTypeRequiredDescription
type'indexedList'yesDiscriminator
entityTypestringyesSchema key of the entity stored
key(...args) => stringyesRedis key generator
idFieldstringyesField name that uniquely identifies items
scoreFieldstringnoEntity field used as ZSET score. Default: Date.now() at insert
maxSizenumbernoTrim list to this size on every insert (drops lowest-scored)
trackMembershipbooleannoRecords back-index for cascade invalidation. Default false

Score derivation

How scores are calculated

  1. If scoreField is omitted, the score is Date.now() at insertion time
  2. If scoreField is set, the cache reads entity[scoreField] and converts via Number(value)
  3. If that's non-finite, it tries new Date(value).getTime()
  4. ISO 8601 strings, Date objects, numeric strings, and numbers all work
  5. 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.