Data Model

How Koolbase shapes the records you read and merges the ones you write — the reserved $ metadata namespace, the absent-vs-null rule on updates, and how soft-delete interacts with uniqueness.

Record shape

Every record reads back as a flat object: your fields sit at the top level, and Koolbase's system metadata lives under reserved $-prefixed keys. Internal identifiers (project_id, collection_id) are never exposed.

{
  "title": "Q3 Report",
  "status": "draft",

  "$id": "rec_3kf9q2",
  "$collection": "documents",
  "$createdBy": "usr_8a21cc",
  "$createdAt": "2026-05-25T10:00:00Z",
  "$updatedAt": "2026-05-25T10:30:00Z"
}
FieldPresentMeaning
$idalwaysThe record’s unique identifier.
$createdAtalwaysWhen the record was created (UTC).
$updatedAtalwaysWhen the record was last updated (UTC).
$collectionon readsThe name of the collection the record belongs to.
$createdBywhen setThe id of the user who created it. Absent when there was no user — e.g. created by a Function.

The $ namespace is reserved both ways

You can't write a field whose name starts with $ — the write is rejected — and any $-prefixed key in your data is dropped on read. That's the guarantee that your own fields can never collide with system metadata. Treat $id, $createdAt, and $updatedAt as always present; check for $createdBy before using it.

Updates merge — absent vs null

An update is a shallow merge, not a replace. Only the fields you send are touched:

// stored
{ "title": "Q3 Report", "status": "draft", "tags": ["finance"] }

// update patch
{ "status": "published", "reviewer": null }

// result
{ "title": "Q3 Report", "status": "published", "tags": ["finance"], "reviewer": null }
  • Omit a field and it's left unchanged (title, tags above).
  • Send a value and it overwrites (status).
  • Send null and the field is set to null — present, with a null value (reviewer).

That absent-vs-null distinction is the whole point: omitting is how you leave a field alone, and an explicit null is how you clear it. The merge is also shallow — a nested object you send replaces the entire object at that key, it isn't deep-merged:

// stored
{ "settings": { "theme": "dark", "lang": "en" } }

// patch
{ "settings": { "theme": "light" } }

// result  —  the whole settings object is replaced; "lang" is gone
{ "settings": { "theme": "light" } }

There is no remove-a-field operation

Setting a field to null stores null — it does not delete the key. A merge only ever adds or overwrites keys; it never removes them. To change one field inside a nested object, send the whole object with that field updated.

Soft delete

Deleting a record is a soft delete: it's marked deleted and immediately disappears from every read — get, query, counts — but isn't physically purged right away. Two consequences worth knowing:

  • A soft-deleted record can't be fetched or updated — it's gone from the caller's perspective.
  • Its unique values are freed. A unique constraint only covers live records, so you can immediately create a new record reusing a deleted one's unique value — re-registering a removed user's email, for instance.

Uniqueness, null & absent

A unique constraint only collides live, non-null values. A field that's absent or explicitly null is exempt — null values are treated as distinct, so any number of records can have no value for a unique field without conflicting.

Same for uniqueness, different for writes

For a unique constraint, absent and null behave the same — both slip past it. On a write they don't: absent leaves the field alone, null clears it. Keep the two ideas separate.