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
The management API: negotiate the version, diff and push definitions, load import records, and mirror the host's data files.

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.

EndpointPurpose
GET /api/versionReport the server's API version, so the local client can detect skew and build its path prefix.
GET /api/v3/manifestList every definition the server holds with a hash of each, so the local client can diff and push only what changed.
GET /api/v3/schemaReturn 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/defReturn 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/defUpload 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/defDelete 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/checkGiven 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/importUpsert one imported record, keyed by id, appending a new version only when its data changed.
GET /api/v3/dataList every file in the host's columnar data tree with its size, so the local client can mirror it.
GET /api/v3/data/fileStream 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

MethodGET
Path/api/version
AuthAuthorization: Bearer <token>
ParametersNone.

Response

A 200 JSON object:

{ "api": 2 }
The server's protocol version.
FieldTypeMeaning
apiintegerThe 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

MethodGET
Path/api/v3/manifest
AuthAuthorization: Bearer <token>
ParametersNone.

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…"
}
Every definition the server holds, each hashed, so the local client can diff.
FieldTypeMeaning
(each key)stringA 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>
Read one host's full definition set.
ParameterInMeaning
hostqueryThe 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" }
}
Every definition the host holds, so the local client can check for drift and print what to restore.

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>
Read one definition file by its path.
ParameterInMeaning
pathqueryThe 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" }
Upload one definition file by its path.
ParameterInMeaning
pathqueryThe definition's path, relative to the content root and inside a domain folder. An unsafe path, or one outside a domain, is rejected.
(body)bodyThe definition's JSON bytes.

Response

A 200 JSON object:

{ "stored": true }
A successful write.
FieldTypeMeaning
storedbooleantrue 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>
Remove a definition that is gone locally.
ParameterInMeaning
pathqueryThe definition path to remove. An unsafe path is rejected.

Response

A 200 JSON object:

{ "deleted": true }
Whether a definition was removed.
FieldTypeMeaning
deletedbooleantrue 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>
The ids and file times of every record in a collection's import files.
ParameterInMeaning
hostqueryThe data host. A bad host is 400.
collectionqueryThe collection the records import into. An unknown collection is 400.
(body)bodyConcatenated 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 }
Upsert one record under its derived id, with its source file's time.
ParameterInMeaning
hostqueryThe data host. A bad host is 400.
collectionqueryThe collection to write into. An unknown collection is 400.
idqueryThe record's 32-hex id, derived by the local client from a local id or the file name and position.
mtimequeryThe source file's modification time, stored as this record's change token.
(body)bodyThe 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 }
Whether a new version was appended, or the record already matched.
FieldTypeMeaning
storedbooleanAlways true on success; the file time was recorded.
changedbooleantrue 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>
List the host's data files with their sizes.
ParameterInMeaning
hostqueryThe 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 }
  ]
}
Every file in the host's data tree, each with its byte size.
FieldTypeMeaning
filesarrayOne entry per file under the host's datahoster-data/ tree, sorted by path.
files[].pathstringThe file's path within the data tree — how the local client names its local copy under _data.
files[].sizeintegerIts 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-
Fetch only the tail of a data file grown since last time.
ParameterInMeaning
hostqueryThe data host the file belongs to. A bad or malformed host is 400.
pathqueryThe file's path within the data tree, from /data. An unsafe path is 400.
RangeheaderOptional. 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 Content with a Content-Range header when a Range was sent; 200 otherwise.
  • An absent file, or a Range at or past the end, returns an empty 200 body — 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.

1

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.

2

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.

3

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.

4

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.

5

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.

6

Remove what's gone

DELETE /api/v3/def — a user or endpoint absent locally is deleted on the server, after the uploads.

7

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.

8

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.

Users & endpoints — any time

A user or an endpoint is access, not data, so edit it as often as you like: rotate a token, change a permission, add or remove one. The next sync pushes the change or deletes what is gone.

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.

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.

StatusWhen it appears
400A bad, malformed or unsafe path on a def or data request, or a bad host on a data list.
409An 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.
410A GET /api/v3/def for a definition the server does not hold. The local client reads it as "new, not a change".
413A PUT body over the server's size limit.
426An 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.
404The 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.

MessageWhen 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, int128A definition file fails its schema — an unknown type, a bad name, or malformed structure.
…/notes: not a valid definition pathA 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 char8char1024 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 clientThe 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 drifted domain, named with the definition to restore; other domains synced above it.

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"
A local validation error naming the definition at fault.