Storage types
On this page
You never name a column type. Each column is the store engine's own type for the field's type:
| Field type | PostgreSQL |
|---|---|
Uuid and types based on it |
uuid |
Int |
smallint, integer or bigint, from the declared range |
Decimal { scale: 2 } |
numeric(p, 2) |
Bool, Text, Bytes |
boolean, text, bytea |
Instant, CalendarDate |
timestamptz(3), date |
Duration |
bigint, in milliseconds |
Enum without data |
text holding the variant name; a standard enum holds its code, GBP, Europe/London |
Money (Money and payments) |
two columns, always: <field>_amount bigint of minor units and <field>_currency char(3), with a check constraint holding the currency to the one a type fixes |
ZonedTime (Configuration and time) |
two columns: <field>_instant timestamptz(3) and <field>_zone text holding its zone's code |
Enum with data, Object, List, Set, Map |
jsonb |
Structured fields, recursive ones included, are stored whole as jsonb,
in the API's JSON, which caches and Redis entities hold too
(Structured values).
The database enforces types, required fields, keys and references. The application checks each field's own rules. Renaming an enum variant changes stored values, so it needs a migration.
Standard types in columns
A standard type whose data entry lists named columns is stored in those,
as Money is, rather than as jsonb
(Every boundary through the contract).
Every standard object and choice type is stored in named columns laid out
from its parts, <field>_<part>:
- a part that may be
noneis a column that may beNULL; - an amount of money takes two columns, and another standard object its own;
- a choice's variant's name goes in
<field>_kind, then each part its variants hold; - a
ZonedTimegoes in<field>_instantand<field>_zone.
A range is one column of PostgreSQL's range type of its ends, a
LocalTime is time, and a LocalDateTime is timestamp without a zone,
each named for the field. Every standard enum holds its code
(Europe/London, GET), as Currency does. A quantity's column carries
a comment naming its kind and SI unit (Length in metres (m)). A rate
type's fixed from: and to: get check constraints, as a Money type's
currency: does.
Only the standard collections (RichText, RichTextBlock,
LocalisedText) stay structured jsonb, holding their contract's JSON,
which the route codec writes and reads back.
A geometry (Point and the other five, or Geometry) is one PostGIS
geography column. It is untyped, and a check constraint holds it to its
geometry type and to WGS84. The first migration with one creates the
extension (Storage under Geospatial).
Changing what a field allows
Each migration's snapshot records what every field allows beyond its
column type: an enum's variants, and the field's bounds, lengths and
patterns. So changing them is drift that jadpo check reports like any
other.
- A change that allows more, such as a variant added or a longer
max_length, is a migration with nothing to run. - A change that allows less, such as a variant renamed or removed, a
shorter
max_lengthor a smaller range, needs a decision.backfill:gives the records already stored, whose value the field no longer allows, one it does. The migration's SQL replaces exactly those.
Here large leaves BoxSize, and parcels stored as large become medium:
type BoxSize = Enum { small medium}
database/migrations/20261020T1401_drop_large/changes.jadpo:
// changes.jadpo/// Stops sending large boxes.migration { Parcel.size: { backfill: BoxSize.medium // parcels stored as large become medium }}
The migration's SQL is UPDATE "parcel" SET "size" = 'medium' WHERE "size" NOT IN ('medium', 'small').
An older snapshot that doesn't record what fields allow has it recorded by the next migration, with nothing to decide.
A stored value is checked when it is read
Every stored value is checked when it is read, by its field's validator:
the same check a write and a backfill use. That is the field's own rules,
then its type's along its chain, a built-in type's own check (Email,
Url), and a standard object's rules across its parts. Nothing checks a
value twice, and a value a write would keep is never refused on read.
Every read that makes a stored value checks it: a query's records, get,
each kind of include (a parent, a child, children), a nearest query's
records, a map layer's properties and geometry, a geo. condition's
recheck, and the record a create or an update gives back, with every
stored field it holds. So does each field a backfill worked out in Jadpo
reads.
An aggregate makes a stored value only for a group by a field and for
min and max, which are checked. A count, a sum, an average and a time
bucket are worked out, not stored, so a sum past a field's max: is no
defect. A field kept as JSON is read by its codec with its type's and the
field's rules (Structured values), and not checked
again. A field a migration retires has no declaration left, so a backfill
reads it as its built-in type alone.
A row that breaks a rule. A row written around Jadpo that breaks a
rule is a defect naming the field and the rule as written, never the
value: a stored `Tool.stars` that breaks `max: 5`. So the fault
behind a 500 or a stopped backfill names what to fix and leaks nothing.
none breaks no rule. A backfill stops as it does on any defect, naming
the record's key (it read a stored …).
scale: can't break on read. A decimal with scale: is kept in a
numeric(p, s) column, which rounds anything written into it to its
places, so ranges, lengths and patterns are the rules such a row can
break.
Not built yet: a new pattern: on a stored field, since PostgreSQL's
regular expressions aren't Jadpo's (jadpo migrate new says so);
comparing or ordering by a standard type stored in named columns, or by a
standard collection, in a query; and CHECK constraints holding fields'
rules in the database.
Markdown for agents: /docs/storage-types.md.Built from Jadpo main at a707efb.