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"
}| Field | Present | Meaning |
|---|---|---|
| $id | always | The record’s unique identifier. |
| $createdAt | always | When the record was created (UTC). |
| $updatedAt | always | When the record was last updated (UTC). |
| $collection | on reads | The name of the collection the record belongs to. |
| $createdBy | when set | The 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
$ — 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,tagsabove). - Send a value and it overwrites (
status). - Send
nulland 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
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.