Database/Property lifecycle

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"},
       "xFormat": {"type": "number", "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 — unique within the type by a read-then-create preflight (409 property.xkey_conflict), never seen by storage, mutable — 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
items, properties (nested shapes) pinned narrowing would invalidate stored values
name, description, xKey freely mutable labels and the handle
meta.index freely mutable the search flag
xFormat and every path under it — type, icon, pos, options.<key>.{name,color,pos,meta.<k>}, relation.{targetTypes,filter}, config.<k>, vendor keys freely mutable per leaf; an option key itself immutable the descriptor is a hint, not a guarantee; the option key IS the stored value

Pins are enforced at apply time on every peer, convergently: an op that tries to change kind is dropped, not merged. Changing a pinned fact means defining a new property with a new id — the old one keeps its values. The slug (xFormat.type) is mutable but the server only lets it move within the pinned kind — text to email, never text to number.

kind is always explicit; nothing is defaulted from the descriptor. Value conventions per slug: 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 number kind, a well-formed {"$date": …} for an instant, a plain any://<objectId> for a relation, a period / money / geo compound in its exact shape — against the property's current slug (400 property.format_violation otherwise). A choice value is not checked against xFormat.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 valid flag 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, the handle, reorder, icon, the index flag and the whole choice-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",
                 "xFormat.options.noir.name": "Noir",
                 "xFormat.options.noir.color": "gray",
                 "xFormat.options.noir.pos": "a2"},
       "unset": ["xFormat.options.western"]}'
# → 204
any type property option set $SPACE $MOVIE $GENRE noir --name Noir --color gray
  • set targets a leaf and never carries an object; a container (meta, xFormat, xFormat.options, xFormat.options.<key>, xFormat.relation, xFormat.config) is rejected on set but allowed on unset — unsetting an option key deletes the option. That is what keeps one client from replacing the bag and dropping keys another client added.
  • Pinned paths answer 400 property.immutable; unknown paths or wrong leaf types 400 request.invalid_field; a slug that does not fit the kind, a reserved key or an unparseable xFormat.relation.filter 400 property.format_invalid; a taken xKey 409 property.xkey_conflict.
  • 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.