Client-Server Synchronisation
Datahoster keeps your local host in step with the server over a private, token-protected
management API on the server's IP. Caddy terminates TLS and
reverse-proxies to a localhost-only Node API at 127.0.0.1:8788, routed by
Host header. Each run the local client
pushes only the definitions that changed, then mirrors the server's
append-only columnar data files down. The read-only endpoints behind the log browsers share this same
shape and live on the Tools page.
GET /api/version
GET /api/v3/manifest
GET /api/v3/schema
GET /api/v3/def
PUT /api/v3/def
DELETE /api/v3/def
POST /api/v3/import/check
PUT /api/v3/import
GET /api/v3/data
GET /api/v3/data/file
Private API
Every call is authenticated with a bearer token — the
datahoster-config-apiToken from your local client
config, sent as Authorization: Bearer <token>. There is
nothing else to the surface: no dashboard, login, session or cookie. Without a valid
token — a missing or wrong token, or an unknown route — every reply is an identical plain
404, so a probe cannot tell the API is there at all (see
Server). With a valid token the HTTP status
line carries the outcome: 200 ok, 400 a bad path or bad host, 409
an append-only violation, 413 a too-large body, and 426 a request on
a different API version's path. The routes are versioned under /api/v3/; the local client pins the prefix to
its own shared/version.js
version, so a server on a different version answers 426 (or the wall) rather than the
local client silently following it onto a contract it does not speak.
| Endpoint | Purpose |
|---|---|
GET /api/version | Report the server's API version, so the local client can detect skew and build its path prefix. |
GET /api/v3/manifest | List every definition the server holds with a hash of each, so the local client can diff and push only what changed. |
GET /api/v3/schema | Return one host's whole definition set, so the local client can check its schema still matches the server before syncing and, on drift, print the exact definition to restore. |
GET /api/v3/def | Return one definition's current bytes, so the local client can show the online structure beside the local one when a change is refused. |
PUT /api/v3/def | Upload one definition file — a collection, column, user or endpoint. The schema is append-only, so an existing collection or column may not be changed; users and endpoints may. |
DELETE /api/v3/def | Delete a user or endpoint that is gone locally. A collection or column can never be deleted (the schema is append-only). |
POST /api/v3/import/check | Given each import record's id and its file's modification time, return the ids whose file time the server has not seen (the records to send), and delete any import-owned record no longer in the set. |
PUT /api/v3/import | Upsert one imported record, keyed by id, appending a new version only when its data changed. |
GET /api/v3/data | List every file in the host's columnar data tree with its size, so the local client can mirror it. |
GET /api/v3/data/file | Stream one data file, Range-supported, so the local client mirrors each file whole once and appends only the growing tail. |
GET /api/version
Reports the server's wire-contract version. It is deliberately unversioned (no /api/vN
prefix), so a local client whose version has drifted can still ask what the server speaks. When a versioned
request comes back 426 (or the wall), the local client reads this endpoint to say which side to
upgrade — turning an otherwise opaque failure into a clear message. The local client itself pins its path
prefix to its own shared/version.js
version — see common/net.js.
Request
| Method | GET |
|---|---|
| Path | /api/version |
| Auth | Authorization: Bearer <token> |
| Parameters | None. |
Response
A 200 JSON object:
{ "api": 2 }
| Field | Type | Meaning |
|---|---|---|
api | integer | The server's API version. The local client compares it to its own; on a mismatch it reports which side to upgrade. |
GET /api/v3/manifest
Returns every definition file the server currently holds, keyed by its path, each with a
sha256 of its bytes. The local client scans your content root the same way and compares hashes,
so it uploads only the definitions that differ and deletes the ones that are gone. Definitions are the
small JSON files a host is authored from: a collection's datahoster-collection.json, each
column file, each user file, and each endpoint file.
Request
| Method | GET |
|---|---|
| Path | /api/v3/manifest |
| Auth | Authorization: Bearer <token> |
| Parameters | None. |
Response
A 200 JSON object:
{
"api.example.com/datahoster-collections/messages/datahoster-collection.json": "b1946ac9…",
"api.example.com/datahoster-collections/messages/datahoster-collection-columns/email.json": "9f86d081…",
"api.example.com/datahoster-users/you.json": "3a7bd3e2…",
"api.example.com/datahoster-endpoints/submit/datahoster-endpoint-insert.json": "2c624232…"
}
| Field | Type | Meaning |
|---|---|---|
| (each key) | string | A map of definition path (rooted at the domain folder) to the sha256 of its bytes. A path present locally but absent here is new; one present here but gone locally is deleted; a differing hash is a change. |
GET /api/v3/schema
Returns one host's whole definition set — every collection, column, user and endpoint file — keyed by path, with each file's parsed JSON. The local client fetches this for each host before it pushes, so it can confirm its local copy still matches the server. Because the schema is append-only, a collection or column the server holds that is missing locally (deleted, or its folder renamed) or whose definition differs is drift: that host is left untouched and the drift is reported with the exact definition to restore, while every other host still syncs. Users and endpoints are access definitions, not data, and stay freely mutable.
Request
GET /api/v3/schema?host=api.example.com
Authorization: Bearer <token>
| Parameter | In | Meaning |
|---|---|---|
host | query | The data host (domain folder) to read. A missing or malformed host is 400. |
Response
A 200 JSON object mapping each definition's path to its parsed JSON:
{
"api.example.com/datahoster-collections/messages/datahoster-collection.json": { "datahoster-collection-name": "messages" },
"api.example.com/datahoster-collections/messages/datahoster-collection-columns/email.json": { "datahoster-column-name": "email", "datahoster-column-type": "char64" }
}
GET /api/v3/def
Returns one definition file's current bytes — the online structure. The local client reads it when a
push is refused, so it can print the server's definition beside your local one and show exactly what
drifted. A file the server does not hold answers 410, which the local client reads as "new, not a
change".
Request
GET /api/v3/def?path=api.example.com/datahoster-collections/messages/datahoster-collection-columns/email.json
Authorization: Bearer <token>
| Parameter | In | Meaning |
|---|---|---|
path | query | The definition's path, relative to the content root and inside a domain folder. An unsafe path, or one outside a domain, is 400. |
Response
A 200 with the file's raw JSON bytes — the same content a
PUT stored. A definition the server does
not hold is 410 with an { "error": … } body; a bad or unsafe path
is 400.
PUT /api/v3/def
Uploads one definition file, creating or replacing it. The server validates it before writing — its
schema — and enforces the same
append-only rule the local client pre-checks: a new
collection is created empty; a new column
added to an existing collection is backfilled; but an existing
collection or column may never change — an identical re-push is a no-op, and any change to one is refused
409 with the online and local structures in the body (add a new, differently-named collection
or column instead). Users and endpoints carry no data and may be replaced freely.
Request
PUT /api/v3/def?path=api.example.com/datahoster-users/you.json
Authorization: Bearer <token>
Content-Type: application/json
{ "datahoster-user-token": "k7Qw2f9xJ4mB6pR8tZ1nD3sV5hL0aYc" }
| Parameter | In | Meaning |
|---|---|---|
path | query | The definition's path, relative to the content root and inside a domain folder. An unsafe path, or one outside a domain, is rejected. |
| (body) | body | The definition's JSON bytes. |
Response
A 200 JSON object:
{ "stored": true }
| Field | Type | Meaning |
|---|---|---|
stored | boolean | true when the definition was written. |
A change to an existing collection or column is refused 409 (append-only; its
body carries the error and the online and local structures); a bad,
malformed or unsafe path is 400; a body over the size limit 413.
Each has an { "error": … } body — see Errors.
DELETE /api/v3/def
Removes a definition that exists on the server but is gone locally — a user
or endpoint you deleted. Deleting a definition that is already absent is
still a success. A collection or column
can never be deleted — the schema is append-only, so its delete
is refused 409. This is also why a local deletion or a renamed folder can never drop online
data: the client sees the collection or column missing, reports the drift, and skips that host rather than
removing anything. Dropping stored data is out of scope of the sync.
Request
DELETE /api/v3/def?path=api.example.com/datahoster-endpoints/old-form/datahoster-endpoint-insert.json
Authorization: Bearer <token>
| Parameter | In | Meaning |
|---|---|---|
path | query | The definition path to remove. An unsafe path is rejected. |
Response
A 200 JSON object:
{ "deleted": true }
| Field | Type | Meaning |
|---|---|---|
deleted | boolean | true if something was removed; false if the path was already absent (still a 200 — delete is idempotent). |
Removing a data-bearing column is 409; a bad or unsafe path is
400. Each has an { "error": … } body — see
Errors.
POST /api/v3/import/check
The change pre-check for imported records. The local client sends a
compact binary blob, one 24-byte row per record: the record's 16-byte id followed by the
float64 modification time of the file it came from. The server replies with the 16-byte ids
whose stored file time it has not seen, so the local client sends the full record only for those. It saves
bandwidth without giving up correctness: the server still compares data on the
PUT, so a file whose time changed but whose
rows did not writes nothing.
The blob is also the collection's whole desired import state, so the server reconciles here:
any record it holds a token for (one the import brought in)
whose id is absent from the blob is deleted the usual way and its token dropped. Records with no token (a
public insert) are never touched. The local client runs this step even under
--full, so a removed record is always reconciled away; an X-Import-Pruned response
header reports how many were deleted.
Request
POST /api/v3/import/check?host=api.example.com&collection=frameworks
Authorization: Bearer <token>
Content-Type: application/octet-stream
<id(16) + mtime(float64) rows>
| Parameter | In | Meaning |
|---|---|---|
host | query | The data host. A bad host is 400. |
collection | query | The collection the records import into. An unknown collection is 400. |
| (body) | body | Concatenated 24-byte rows: id (16 bytes) then the file's mtime (little-endian float64). |
Response
A 200 whose body is the 16-byte ids to send, concatenated —
Content-Type: application/octet-stream. An id is included when the server holds no file
time for it (a new record) or a different one (its file changed). An empty body means nothing changed.
PUT /api/v3/import
Upserts one imported record. The body is the record's JSON in its
import forms; the server coerces it against the
collection's columns (the same shared code the
local client validated with), writes it under the id in the query, and attributes it to the
built-in datahoster-import user. It appends a new version only when the record differs from
the current one, and always records the file's mtime so the next
pre-check can skip it. This is an admin route, so
it bypasses the endpoint permissions that guard the public insert.
Request
PUT /api/v3/import?host=api.example.com&collection=frameworks&id=9a3c…&mtime=1782864000123
Authorization: Bearer <token>
Content-Type: application/json
{ "name": "React", "language": "JavaScript", "released": 2013, "stars": 229000 }
| Parameter | In | Meaning |
|---|---|---|
host | query | The data host. A bad host is 400. |
collection | query | The collection to write into. An unknown collection is 400. |
id | query | The record's 32-hex id, derived by the local client from a local id or the file name and position. |
mtime | query | The source file's modification time, stored as this record's change token. |
| (body) | body | The record's fields, in their import value forms. A field that fails the schema is 400. |
Response
A 200 JSON object:
{ "stored": true, "changed": true }
| Field | Type | Meaning |
|---|---|---|
stored | boolean | Always true on success; the file time was recorded. |
changed | boolean | true if a new version was appended, false if the record already matched and nothing was written. |
A field that fails its type, or an unknown/bad host, collection or id, is 400;
a body over the size limit is 413. Each has an { "error": … } body.
GET /api/v3/data
Lists every file in a host's columnar data tree
(datahoster-data/) with its size, so the local client can mirror it. The store is
append-only: an immutable full chunk is never rewritten, and only the current tail
chunk grows. Comparing each file's size to its local copy is all the local client needs to know what to fetch.
The local client mirrors these files verbatim into
<contentRoot>/_data/<host>/datahoster-data/…, byte-identical to the server, and
the Data Logs tool reads them back through the
storage engine.
The mirror copies bytes, so it cannot itself judge whether they read back cleanly. After each mirror the local client loads every collection through the storage engine and reads it: a value that fails to decode is reported by host and collection, and the Data Logs tool shows the same error in place of that collection's records. A decode failure is surfaced, never rendered as an empty collection.
Request
GET /api/v3/data?host=api.example.com
Authorization: Bearer <token>
| Parameter | In | Meaning |
|---|---|---|
host | query | The data host to list. A bad or malformed host is 400. |
Response
A 200 JSON object:
{
"files": [
{ "path": "messages/datahoster-data-records/datahoster-data-record-00000000.dat", "size": 56000 },
{ "path": "messages/datahoster-data-columns/email/datahoster-data-column-text-00000000.dat", "size": 12000 },
{ "path": "messages/datahoster-data-columns/email/datahoster-data-column-text-00000000-00000000.jsonl", "size": 4211 }
]
}
| Field | Type | Meaning |
|---|---|---|
files | array | One entry per file under the host's datahoster-data/ tree, sorted by path. |
files[].path | string | The file's path within the data tree — how the local client names its local copy under _data. |
files[].size | integer | Its size in bytes. A size larger than the local copy means the local client fetches the growing tail; a missing local copy means it fetches the whole file. |
GET /api/v3/data/file
Streams the bytes of one data file. A Range header is honoured so the local client appends only
the tail grown since last time; an immutable full chunk is fetched once, whole. Because the store is
append-only, the local client always knows what it already has: it fetches a file whole when it is missing
locally, or from its local size when the server's copy is larger.
Request
GET /api/v3/data/file?host=api.example.com&path=messages/datahoster-data-columns/email/datahoster-data-column-text-00000000-00000000.jsonl
Authorization: Bearer <token>
Range: bytes=4096-
| Parameter | In | Meaning |
|---|---|---|
host | query | The data host the file belongs to. A bad or malformed host is 400. |
path | query | The file's path within the data tree, from /data. An unsafe path is 400. |
Range | header | Optional. bytes=N- fetches only from byte N — the local client passes its local size to append just the new tail. |
Plus Authorization: Bearer <token>.
Response
The raw file bytes — Content-Type: application/octet-stream with
Accept-Ranges: bytes.
206 Partial Contentwith aContent-Rangeheader when aRangewas sent;200otherwise.- An absent file, or a
Rangeat or past the end, returns an empty200body — never an error.
Synchronisation Process
A run is stateless: the local client recomputes everything from your content root and the server's manifest each time, and only ever sends the differences. It validates the whole host locally first, then validates the whole push plan against the online data before touching anything, so a broken or blocked set never reaches the server. It then pushes, deletes, imports and mirrors.
Pin the version
Every call uses the /api/v<N> prefix from the local client's own
shared/version.js. A server on
a different version answers 426; the local client then reads
GET /api/version to say which side to
upgrade, not a silent misparse.
Scan and validate locally
scan.js reads the content root
into definitions and runs the checks below. Any
failure stops the run before anything is sent.
Diff the manifest
GET /api/v3/manifest — compare
each definition's sha256 against the local one to build the upload and delete sets.
Unchanged definitions are skipped entirely. If both sets are empty the run mirrors and stops.
Validate the whole plan
Using GET /api/v3/data (which
collections hold data online) and GET
/api/v3/def (the online structure), the local client refuses any
blocked change and prints the online and local
structures. Nothing is pushed on a refusal — the plan is atomic.
Push new & changed
PUT /api/v3/def — upload each
changed definition. A new collection is created empty; a new column is
backfilled to match the others.
Remove what's gone
DELETE /api/v3/def — a user
or endpoint absent locally is deleted on the server, after the uploads.
Import records
With the schema confirmed online, read every collection's
import files (validation errors stop here, before any send),
pre-check by file time (which also deletes any
import-owned record no longer in the files), then
PUT /api/v3/import only the records that
changed.
Mirror the data
GET /api/v3/data then
GET /api/v3/data/file — copy the
server's columnar files into _data, whole once and then by
Range tail. The Data Logs tool
reads them.
What you can change
The schema is append-only: it only ever grows. This one rule is enforced on both sides —
the local client checks each host against the server's online
schema before it pushes, and the server answers 409 — so the two can never disagree.
Add — any time
Add a collection or a column
whenever you need it. A new collection is created empty; a new column on an existing collection is
backfilled — given the same number of rows as the collection's other columns, each
set to the column's default. A draft whose name starts with an underscore
(_price) is skipped entirely, so you can get a definition right, then drop the underscore
to adopt it.
Existing collections & columns — append-only
Once a collection or column is
defined it may never change or be removed — its type, default, max
and chunk config are fixed for good, with or without data. To change one, add a new, differently-named
column (price2). This also means a local deletion or a renamed folder can never drop online
data.
Drift is reported and enforced per domain. If one host's local copy diverges from its online schema — a collection or column missing or changed — that host is left exactly as it is online and the drift is printed with the definition to restore, while every other host still syncs normally.
Validation
Before it sends anything, the local client parses every local definition, so a malformed set never reaches the server. Because a host is only its small definition files, the checks are cheap and run entirely on your machine; the server parses each one again on write, so the two can never disagree. A run that fails validation pushes nothing.
- Only dotted host folders are scanned; loose files, dotfiles and the
_dataand_toolsfolders are ignored and never pushed. - Every definition sits at a legal path inside a host's collection, user or endpoint folder.
- Every definition file parses against its schema: a valid type, a valid name, and a well-formed structure.
- Every collection and column name inside a file matches the folder or file it sits in.
The append-only rule is checked separately, against each host's online schema, in the plan step: a host whose local copy has dropped or changed a collection or column is skipped and its drift reported, while the other hosts push normally.
Errors
On the wire, a failure carries its outcome in the HTTP status line with a short
{ "error": … } body; without a valid token every reply is instead the same 404
wall, so a probe learns nothing.
| Status | When it appears |
|---|---|
400 | A bad, malformed or unsafe path on a def or data request, or a bad host on a data list. |
409 | An append-only violation: a change to an existing collection or column, or a delete of either. The body carries the error and, for a change, the online and local structures. |
410 | A GET /api/v3/def for a definition the server does not hold. The local client reads it as "new, not a change". |
413 | A PUT body over the server's size limit. |
426 | An authenticated request on a different API version's path (/api/v<other>/…). The local client reads GET /api/version and reports which side to upgrade. |
404 | The wall: a missing or wrong token, or an unknown route. Identical to a real not-found, so a probe learns nothing. |
Before it sends anything, the local client validates the whole host locally and pre-checks the push plan; a run that cannot proceed stops with one of the errors below and pushes nothing. Each names the definition at fault so you can fix it and run again. Names in the messages are illustrative.
| Message | When it appears |
|---|---|
1 domain(s) not synced — schema drift (see above) | One or more hosts diverge from the append-only schema — a collection or column that is missing locally (deleted or its folder renamed) or was changed. Each drifted host is named with the definition to restore; the run leaves those hosts untouched, syncs the rest, and exits non-zero. |
…/datahoster-collection.json: name "message" does not match its folder "messages" | A datahoster-collection-name (or a column's name) does not match the folder or file it sits in. |
…/age.json: datahoster-column-type "int" is not one of text, char8, char16, char32, char64, char128, char256, char512, char1024, integer, number, boolean, int128 | A definition file fails its schema — an unknown type, a bad name, or malformed structure. |
…/notes: not a valid definition path | A JSON file sits somewhere that is not a legal collection, user or endpoint definition path. |
…/datahoster-endpoint-select.json: filter uses column "email", a text column… | An endpoint filters or sorts on a text column. Text lives on disc, not in memory, so it can be returned but not queried — use a char8…char1024 column for a value you filter or sort on. |
…/datahoster-endpoint-select.json: filter uses column "nope", which does not exist… | An endpoint references a column (in its returned columns, filter, sort, fields or forced values) that its collection does not have, or references a collection that is not defined. Every reference is checked against the collection's schema before the sync runs. |
import …/javascript.json[2]: unknown field "bogus" | An import record is invalid: an unknown field, a missing required field, a value that fails its type, or a set at/by. The message names the file and the record's index. Import runs as its own phase after the push, and a failure there stops before any record is sent. |
server speaks API v3, this local client speaks v2 — upgrade the local client | The local client's API version differs from the server's; the version probe reports which side to upgrade. |
Drift names the domain and the definition to restore, and leaves that host untouched while the others sync:
$ node datahoster-local.js --sync --config config.json
[datahoster] syncing /srv/data -> https://198.51.100.10
[datahoster] push: 2 uploaded, 0 deleted
[datahoster] api.example.com: NOT SYNCED — the schema is append-only and the local copy diverges from the server. Restore the definitions below (or, to change one, add a new differently-named collection/column instead):
column "email" was changed locally (append-only — add a new one instead). api.example.com/datahoster-collections/messages/datahoster-collection-columns/email.json:
server: {"datahoster-column-name":"email","datahoster-column-type":"char64"}
local: {"datahoster-column-name":"email","datahoster-column-type":"char32"}
[datahoster-local] fatal: 1 domain(s) not synced — schema drift (see above)
A local validation failure stops on the first bad definition, before any request:
$ node datahoster-local.js --sync --config config.json
[datahoster] sync failed: api.example.com/datahoster-collections/messages/datahoster-collection.json: name "message" does not match its folder "messages"