Database
Property lifecycle
A property definition is a small synced record inside its type. Its id is content-addressed, its structural facts are pinned by the first write, its display facts merge per path, and its removal is a tombstone that leaves values untouched. Knowing which is which is what keeps concurrent schema edits from fighting.
1. Define
curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/types/$MOVIE/properties \
-H 'Content-Type: application/json' \
-d '{"name": "Year", "xKey": "year", "kind": "number", "scope": "synced",
"meta": {"index": "props", "pos": "a1", "icon": "calendar"}}'
# → 201 {"propId": "EwyHGrtTdxB"}
any type property add $SPACE $MOVIE --name Year --xkey year --kind number
The propId is derived from the change that created the record (base58(xxh3-64(changeId)), up to 11 chars) — the same id on every peer, and the field key under which values are stored. Built-in properties on any use readable ids (name, description, icon, tags) that an 11-char base58 string can never collide with.
xKey is your stable code-side handle: clients resolve xKey → propId from GET …/properties and write by propId. It is metadata — not unique, not enforced, never seen by storage — and GET …/types requires a type-level xKey for the same reason (types resolve by handle, not by display name).
2. What the first write pins
| Field | Mutability | Why |
|---|---|---|
id |
immutable | it is the record id and the storage key |
kind (string / number / boolean / array / object / datetime) |
first-write-wins | values are validated against it on every peer |
scope (synced / account / local) |
first-write-wins | it selects the write route and version domain; a route change would strand values |
format.type (links / date / datetime / select / multiselect) |
first-write-wins, sub-path granular | it constrains kind; a broad replace of format is dropped at apply so a type change can't be smuggled in |
items, properties (nested shapes) |
client-soft: additions only | narrowing would invalidate stored values |
name, description, xKey, xKind |
freely mutable | labels |
meta.<k>, format.ui, format.filter, format.meta.<k> |
freely mutable | consumer conventions |
format.options.<key>.{name,color,pos,meta.<k>} |
freely mutable per leaf; the key itself immutable | the key IS the stored value |
Pins are enforced at apply time on every peer, convergently: an op that tries to change kind or format.type is dropped, not merged. Changing a pinned fact means defining a new property with a new id — the old one keeps its values.
kind may be omitted when format is given; it defaults from the format (links/multiselect ⇒ array, select ⇒ string, date/datetime ⇒ datetime). Passing "kind": "string" with a date format opts into the ISO-string convention instead of instants — and that choice is pinned too. Value conventions per kind and format: Data types.
3. Values
Values live on objects at {typeId}.{propId}, written through the typed set route, which auto-routes by the declared scope:
curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/properties/$OBJ/set/$MOVIE \
-H 'Content-Type: application/json' \
-d '{"EwyHGrtTdxB": 1995}'
Three rules connect values to definitions:
- The server validates shape, not membership. A number for a
numberkind, a well-formed{"$date": …}for an instant, a plainany://<objectId>for a link (400 property.format_violationotherwise). Aselectvalue is not checked againstformat.options— options are dangling-tolerant by design. - Read tolerance. A value that violates the current definition, or sits under an unknown propId, is returned as-is. There is no
validflag and no re-validation cascade; clients decide how to render out-of-spec data. - Same-name properties are not a conflict. Two peers concurrently adding "Rating" produce two ids, both fully real. Consolidating is a user or agent decision, never a merge rule.
Why it matters. Hosted schemas migrate tables in one transaction the server controls. Here two devices can edit a schema offline and both edits must converge without a coordinator — so the design moves every "breaking" fact into an immutable pin and everything else into per-path LWW. Nothing ever needs a migration, and nothing that merged is ever rolled back.
4. Patch
PATCH …/properties/:propId is one CRDT change with per-path set / unset. It covers rename, reorder, icon, index hints and the whole select-option lifecycle:
curl -X PATCH http://127.0.0.1:7001/v1/spaces/$SPACE/types/$MOVIE/properties/$GENRE \
-H 'Content-Type: application/json' \
-d '{"set": {"name": "Genre",
"format.options.noir.name": "Noir",
"format.options.noir.color": "gray",
"format.options.noir.pos": "a2"},
"unset": ["format.options.western"]}'
# → 204
any type property option set $SPACE $MOVIE $GENRE noir --name Noir --color gray
setmust target a scalar leaf; a bare container (meta,format.options,format.options.<key>) is rejected onsetbut allowed onunset— unsetting an option key deletes the option.- Pinned paths answer
400 property.immutable; unknown paths400 request.invalid_field; a badformat.uivocabulary or an unparseableformat.filter400 property.format_invalid. - Deleting then re-adding the same option key works: it is a field unset, not a tombstone.
- Definitions on registered built-in types are frozen:
400 type.registered.
Because each leaf merges independently, two members renaming different options at once both win; two renaming the same option converge on the later write.
5. Remove
curl -X DELETE http://127.0.0.1:7001/v1/spaces/$SPACE/types/$MOVIE/properties/$PROP # → 204
any type property remove $SPACE $MOVIE $PROP
Removal tombstones the definition record. Stored values are not cleaned up — they stay in the object rows as orphan data, read-tolerant — and later writes to the removed id are dropped op by op. The same holds one level up: dropping a type from an object's any.types orphans that namespace's values rather than deleting them.
There is deliberately no "rename or delete a value across N objects" operation: values store option keys, not labels, so a rename is one definition write and zero object writes, and a delete leaves keys dangling by design.
6. Observe
Definitions are synced records, so a schema change is visible live to every member the same way data is — subscribe to the type object's properties dataset, or re-read GET …/types/:typeId/properties. Search indexing of values follows meta.index (props by default, a named scope, or none) — Indexing.
Related
- Types and properties — the endpoint walkthrough.
- Data model — where definitions and values sit.
- Runtime datasets — the same pin/patch discipline applied to dataset fields.