Durable Streams supports per‑stream JSON Schemas and schema evolution via lenses. Schemas and lenses are stored in SQLite as a per‑stream registry.
Profiles and schemas are separate concerns:
- a profile defines stream semantics
- a schema defines payload shape
See stream-profiles.md.
Each stream has a schema registry stored in SQLite (schemas table). The registry
format is:
{
"apiVersion": "durable.streams/schema-registry/v1",
"schema": "my-stream-name",
"currentVersion": 2,
"routingKey": {"jsonPointer": "/user/id", "required": true},
"search": {
"primaryTimestampField": "eventTime",
"fields": {
"eventTime": {
"kind": "date",
"bindings": [{"version": 1, "jsonPointer": "/eventTime"}],
"column": true,
"exists": true,
"sortable": true
},
"service": {
"kind": "keyword",
"bindings": [{"version": 1, "jsonPointer": "/service"}],
"normalizer": "lowercase_v1",
"exact": true,
"prefix": true,
"exists": true
}
}
},
"boundaries": [
{"offset": 0, "version": 1},
{"offset": 150, "version": 2}
],
"schemas": {
"1": {"...": "json schema v1"},
"2": {"...": "json schema v2"}
},
"lenses": {
"1": {"...": "lens v1->v2"}
}
}Notes:
boundariesmap stream offsets to schema versions; they are stored as numbers and must fit inNumber.MAX_SAFE_INTEGER.routingKeyis optional. When configured, the server derives routing keys from JSON appends using the JSON Pointer.searchis optional. When configured, the server builds schema-owned search structures fromsearch.fields.search.fieldssupports stable logical field IDs, per-version bindings, aliases, and capability bits such asexact,prefix,column,exists, andsortable.search.rollupsis optional. When configured, the server builds schema-owned.aggrollup companions and enablesPOST /v1/stream/{name}/_aggregate.
GET /v1/stream/<name>/_schemareturns the registry.POST /v1/stream/<name>/_schemaupdates it.POST /v1/stream/<name>/_schemais strict: it accepts only the supported fields for schema updates, routing-key updates, and search updates.- Profile-owned live/touch configuration belongs in
/_profile, not/_schema.
One profile-owned exception exists in the current shipped system:
- installing the
evlogprofile auto-installs its canonical schema version1and defaultsearchregistry, so the default evlog path does not require a separate manual/_schemacall
Accepted POST shapes:
- Schema install or schema evolution:
{"schema": {"type": "object", "additionalProperties": true}, "lens": { ... }, "routingKey": {"jsonPointer": "/id", "required": true}, "search": {"primaryTimestampField": "eventTime", "fields": {"service": {"kind": "keyword", "bindings": [{"version": 1, "jsonPointer": "/service"}], "exact": true, "prefix": true, "exists": true}, "eventTime": {"kind": "date", "bindings": [{"version": 1, "jsonPointer": "/eventTime"}], "column": true, "exists": true, "sortable": true}}}}- Routing-key only update:
{"routingKey": {"jsonPointer": "/subject/uri", "required": true}}- Search-only update:
{"search": {"primaryTimestampField": "eventTime", "fields": {"status": {"kind": "integer", "bindings": [{"version": 1, "jsonPointer": "/status"}], "exact": true, "column": true, "exists": true}}}}- Search update with rollups:
{"search": {"primaryTimestampField": "eventTime", "fields": {"eventTime": {"kind": "date", "bindings": [{"version": 1, "jsonPointer": "/eventTime"}], "exact": true, "column": true, "exists": true, "sortable": true}, "service": {"kind": "keyword", "bindings": [{"version": 1, "jsonPointer": "/service"}], "exact": true, "prefix": true, "exists": true}, "duration": {"kind": "float", "bindings": [{"version": 1, "jsonPointer": "/duration"}], "exact": true, "column": true, "exists": true, "sortable": true, "aggregatable": true}}, "rollups": {"requests": {"dimensions": ["service"], "intervals": ["1m"], "measures": {"requests": {"kind": "count"}, "latency": {"kind": "summary", "field": "duration", "histogram": "log2_v1"}}}}}}Important rule:
- a search-only update requires an already-installed schema version
- if you are installing the first schema for a stream, install
schemaandsearchtogether in the same_schemarequest - first-schema installation is not idempotent after data exists; stateful
clients that reopen an existing stream must
GET /_schemafirst and skip the install when the current registry already matches the desired schema/search configuration
Not supported:
- registry-shaped writes like
{ "schemas": ..., "lenses": ... } - routing-key aliases such as
routing_key,routingKeyPointer, orjson_pointer - legacy
indexes[] - profile fields under
_schema
- When
currentVersion > 0, JSON appends are validated against the current schema. - External
$refis not supported. - Standard JSON Schema
format: "date-time"is supported and enforced. - If validation fails, the append returns 400.
- Reads always return events matching the current schema version.
- Older events are promoted by applying the lens chain
v -> v+1 -> ... -> currentVersion. - Reads do not re‑validate JSON against the schema; correctness is enforced at update time and write time.
GET /v1/stream/<name>?filter=...may reference only fields named insearch.fields.- Exact-equality filter clauses can use the internal exact family to prune sealed segments.
- Typed equality/range clauses can use
.colcompanions to prune segment-local docs. _searchuses the samesearch.fieldsregistry to drive exact, prefix, typed, and text queries._aggregateusessearch.rollupsto drive object-store-native precomputed rollups with raw-scan fallback for correctness.- Unsealed tail reads still verify from the promoted JSON records.
- The first schema (
currentVersion: 0 -> 1) requires an empty stream. - Subsequent updates require a valid lens (
from=N,to=N+1). - Lens safety is validated with a proof check against the old/new schemas.
If routingKey is configured:
- The server derives routing keys per JSON entry using the JSON Pointer.
- JSON appends must not include
Stream-Key(otherwise 400).
Schemas do not define:
- whether a stream is
generic,evlog,metrics, orstate-protocol - profile-owned endpoints or runtime hooks
Schemas do define payload-owned field extraction, including routing keys and schema-owned search field declarations and rollups.
Those responsibilities belong to the stream profile layer.