Skip to content

Server contract — what GeoLeaf requires from a data server ​

Applies to: @geoleaf/core v3.x · @geoleaf-plugins/connector

INFO

This page is for whoever runs the server. It says what GeoLeaf sends and how it reads each answer, for the three exchanges it has with a data server: pulling a layer for offline use, writing the edits made offline, and renewing a session. It names no product: any server that honours these rules works, and each section says how a server that does not will fail. What the application declares and watches — the layer's blocks, the queue, the entries set aside — is the offline write cycle; every client-side key is in the configuration guide and the connector guide; the contract of the offline capability itself is its specification.


1. Pull — reading a layer for offline use ​

A layer declaring offline.source is pulled into the device's local store: by the download window of @geoleaf-plugins/offline-ui, or by GeoLeaf.Storage.pullLayer(layerId, { bbox }).

1.1 The imposed base — OGC API Features, Part 1 ​

WhatGeoLeaf sends or reads
RequestGET {url}/collections/{collectionId}/items?f=json&limit=1000 — collectionId defaults to the layer id; a url already ending in /items is used as it is
Extentbbox=west,south,east,north (WGS 84) when the pull has one: the zone chosen in the download window, or the caller's bbox. The server returns the features whose geometry intersects it
Answera GeoJSON FeatureCollection — type: "FeatureCollection" and a features array; anything else fails the pull
Paginationthe links entry with rel: "next", an absolute http(s) URL, followed until it is absent. It must carry the query of the first request (bbox, datetime, limit): GeoLeaf does not rebuild it
TotalnumberMatched, when served — for the progress bar and the "N out of M" notice; never required
Identityid on the feature, or properties.id. A feature with neither is not stored, and is counted in the report's skipped
Client identityproperties.local_id, when the server stores the one GeoLeaf sent with a write (§2). It lets a later pull find the record it already holds instead of creating a second one
Freshness markerthe property named by offline.source.versionProperty (default updated_at), kept per entity. It is what the write filter compares (§2.1) and what a second pull reads (§1.2)
GeometryGeoJSON; a WKT string is converted

GeoLeaf stops at offline.maxFeatures entities. The answer is then a subset, and the pull says so (capped: true; a complete pull also shows a notice naming the layer) — and removes nothing (§1.2).

A pull that is interrupted does not resume from a cursor: the next one starts again from the first page. An offset-based next link does not survive rows inserted between two runs, and writing by identity is what makes starting again cheap.

1.2 What a complete pull does ​

A pull is complete when it was neither cut by maxFeatures, nor aborted, nor failed. Every pull, complete or not, rewrites only what changed: an entity served under the freshness marker the device already holds is left as it is (unchanged). An entity served without its marker is always rewritten — nothing says it did not change.

A complete pull then removes from the device the synchronised entities it did not return (removed). Its answer is the whole of what the source holds for the layer and the extent, so an entity it did not name was deleted on the server — or moved out of the extent. With a bbox, the last complete pull therefore defines the layer's content on the device: pulling another zone replaces the previous one.

Two things are never removed: an entity carrying an edit not yet sent — the write queue meets the deletion and sets the edit aside, see §2.4 — and a record without a server identity.

What the server must guarantee for this to hold: every modification of an entity, geometry included, advances its freshness marker. A change that does not is not seen by the next pull.

1.3 Declared freshness and deletions — the delta ​

A layer whose server can serve what changed since an instant, deletions included, declares it:

json
{
    "offline": {
        "enabled": true,
        "maxFeatures": 5000,
        "source": {
            "url": "https://example.tld/ogc",
            "versionProperty": "updated_at",
            "delta": { "freshness": "datetime", "deletedProperty": "deleted_at" }
        }
    }
}

Both keys are required together: a freshness filter alone never sees a deletion.

The first pull of such a layer is complete (§1.2). It records a mark: the greatest freshness marker it was served, kept exactly as the server wrote it. The following pulls, for the same source and the same extent, only send:

GET {url}/collections/{collectionId}/items?f=json&limit=1000&bbox=…&datetime=<mark>/..

— the datetime parameter of OGC API Features Part 1, as an interval open at its end and closed at its start. A served entity whose deletedProperty is set (anything but absent, null or false) is a tombstone: GeoLeaf never stores it, and removes its copy from the device — unless that copy carries an edit not yet sent (§2.4). A delta removes nothing else: what it did not return simply did not change.

What the server must guarantee:

  1. datetime filters on the freshness marker. OGC API Features lets the server choose which temporal property datetime applies to: for this collection, it must be versionProperty.
  2. The marker is an RFC 3339 instant, advanced by every modification — geometry included — and by every deletion: a tombstone carries the instant of the deletion.
  3. A row never becomes visible with a marker lower than one already served. A server that stamps a row when its statement starts and commits it later can serve a newer row first: the mark then passes the older one, and that change is missed until the next complete pull.
  4. Tombstones are kept at least as long as a device may go without pulling. A deletion whose tombstone was purged before a device pulled again is never learned by that device, until its next complete pull.

Two limits follow from the protocol, not from a server: an entity moved out of the extent is not in a delta's answer — the bbox filters its new geometry — and stays on the device at its old position until the next complete pull; and a delta that brings more changes than maxFeatures is a cut one.

The mark is dropped, and the next pull is complete again, when:

  • the device's local cache is purged;
  • the zone or the source changes (url, collectionId, versionProperty, bbox);
  • a delta is cut by maxFeatures;
  • a served marker is not a readable instant.

INFO

Measured against a real server on 20/09/2026 — an OGC API Features server for the reads, a REST layer over the same database for the writes, on the repository's proof bench. What that run established, and what it did not:

  • datetime does filter on the freshness marker, once the collection declares — in the server's own configuration — which temporal property it applies to. A collection that declares none answers datetime with a 500 — loudly, which is the safe failure: a server that answered by ignoring the filter would serve its whole collection to every delta, and the pull would report success.
  • The next link does carry datetime through, URL-encoded. GeoLeaf follows that link verbatim (§1.1), so a server dropping the parameter would answer page two with the entire collection.
  • The freshness marker the read side serves does select the row on the write side — without the two sides agreeing byte for byte. That was the run's one surprise, and it took a second pair of servers to surface it: see §2.3.

It remains proven on one pair of servers, and a business backend is not one of them: what a given backend exposes as its synchronisation timestamp, and how it splits collections, is not exercised here.

1.4 How a pull is authenticated ​

The pull uses the page's fetch, and so does the rest of the offline preparation — tiles, styles, glyphs, profile files. The preparation presents what the display presents, no more and no less:

  • Behind @geoleaf-plugins/connector, a request whose URL lies under the connector's baseUrl carries Authorization: Bearer <token> (§3) — vector tiles and glyphs included, as the map itself requests them. Outside baseUrl, GeoLeaf adds no header.
  • GeoLeaf sets no credentials mode, so the browser's default applies: a request to the page's own origin carries that origin's cookies, a cross-origin request carries none. A server that authenticates by a same-origin session cookie is therefore reached by the preparation as it is by the page.

In a profile that declares modules.offline.dataOrigins, a layer is pulled only from a declared origin or from the page's own; any other source is refused, and the pull reports refused: "originUndeclared". Neither cacheable nor prefetch is read there — the layer naming its source is the request — and an authenticated origin is pulled like any other.

The rule is applied again to every page after the first. Those are requested at the URL your server renders — the next link, or the cursor cursorPath names — so render them on the origin that served the first page, or on another declared one. A cursor naming any other origin is not followed: the pull stops there, reports cursorRefused: true, and is partial — nothing is removed from the device on its account.

GeoLeaf sets no timeout on a pull page; a caller of pullLayer can stop it with an AbortSignal.


2. Write — the offline queue ​

Every edit is written to the device first, then queued; the queue is sent when the network allows, one entry at a time, in capture order. A layer's target is its write block: { "enabled": true, "endpoint": "…", "dialect": "collection", "geometryProperty": "geom", "properties": [ … ] }.

The collection dialect is the only one the queue sends. rest is spoken by the editor plugin's online adapter only; a queued edit on a layer declaring it is set aside as dialectNotSupported.

2.1 Requests ​

OperationRequest
createPOST {endpoint}
updatePATCH {endpoint}?id=eq.<serverId>&<versionProperty>=eq.<marker>
deleteDELETE {endpoint}?id=eq.<serverId>&<versionProperty>=eq.<marker> — no body
  • The marker filter is added when the edit carries the marker it was based on; values are URL-encoded (the + of a timezone offset included). An update or a delete of an entity that has no server identity yet sends nothing.
  • Headers: Content-Type: application/json and Prefer: return=representation. GeoLeaf adds no authentication header of its own: behind the connector, Authorization is added to the requests under its baseUrl.
  • Body of a create or an update — a flat object: the properties listed in write.properties (every property when the list is absent), the GeoJSON geometry object under geometryProperty, and local_id, the client identity.
  • Each request is abandoned after 15 seconds.

2.2 How each answer is read ​

AnswerReading
2xx with the row — [row] or rowsuccess. row.id becomes the server identity, and row[<versionProperty>] the new marker
2xx with [], filtered update or deleteconflict: the row changed since the capture. GeoLeaf reads it (GET {endpoint}?id=eq.<serverId>), keeps that version locally, and sends the write again without the marker filter — the device wins
2xx with [], unfiltered updatethe row no longer exists: set aside as deletedOnServer
2xx with [], unfiltered deletesuccess — the row is already gone
2xx with [] on a createthe row was not returned, and whether it was written is unknown: GeoLeaf reads GET {endpoint}?local_id=eq.<localId>&select=id, as after a 409. A row: success, identity recorded. []: the server wrote nothing — a refusal, retried like a 403 (below). Unreadable: retried
2xx without a readable array — 204, an empty bodysuccess — but the entity's stored marker is cleared, so its next edit goes out unfiltered, and a create answered so gets no server identity
409 on a create"already have it": GeoLeaf reads GET {endpoint}?local_id=eq.<localId>&select=id. A row: success, identity recorded. []: a refusal, retried like a 403 (below). Unreadable: retried
409 on an update or a deletesuccess
404 on an update or a deleteset aside as deletedOnServer
401set aside as authRequired, and the queue stops until the session comes back (§3)
501set aside as notImplementedByServer
408, 429, 500, 502, 503, 504, a network error, a timeoutretried — four attempts in all: 30 s after the first failure, 2 minutes after the second, 8 minutes after the third — then set aside as retryBudgetExhausted
403, and any other 4xxretried the same way, then set aside as rejectedByServer

An edit set aside is kept, never dropped: the device shows it, and an operator re-queues it — for a motive whose cause can be lifted — or discards it after seeing it.

2.3 What the server must guarantee ​

  • local_id is unique. A create replayed after a lost answer must get a 409, not a second row.
  • Prefer: return=representation is honoured, the row carrying id and the marker. A create answered [] costs a second request — GeoLeaf looks the row up by local_id (§2.2). A create answered with no readable body at all — a 204 — leaves the entity without a server identity: its later edits cannot be sent. An update answered without its row leaves no marker, and the next edit goes out unfiltered.
  • A filtered update or delete that matches nothing answers 2xx with [] — not 412, not 404. A 404 sets the edit aside as deleted, and a 409 on an update counts as a success.
  • The marker served by the read side selects the row on the write side. GeoLeaf sends back the exact string it was served, so the write endpoint must parse it rather than compare it as text. The two ends may serialise the same instant differently and still be correct — measured on 20/09/2026, the read side padded microseconds to six digits (…711140+00:00) where the write side served the database's canonical form (…71114+00:00), for roughly one row in ten. A write endpoint that string-compared would lose the filter silently: §2.2 reads the empty answer as a conflict, and the device wins every time.
  • A delete answers with the row it deleted, even when the deletion is a soft one. A server that marks the row instead of removing it, and answers the delete with an empty body, is read by §2.2 as a conflict rather than a success: the queue re-reads the row, keeps that copy on the device as a live entity, and sends the write again unfiltered. The deletion does land, after a detour indistinguishable from a data race. Measured on 20/09/2026 — a BEFORE DELETE trigger that suppresses the delete empties the RETURNING clause the write endpoint answers from; an INSTEAD OF DELETE on a view that returns the old row does not.
  • The primary key is id, and endpoint carries no query string — GeoLeaf appends its own.

2.4 An edit on an entity deleted on the server ​

The pull does not decide for a pending edit (§1.2). The queue meets the deletion — a 404, or [] to the unfiltered resend — and sets the edit aside as deletedOnServer: it is not replayed, since replaying would recreate what the server deleted, and it stays visible until an operator discards it.

2.5 Photos ​

A photo field declaring uploadEndpoint sends the file on its own:

  • POST {uploadEndpoint}, a multipart body with one part named file;
  • the answer is a 2xx JSON object whose url — or path — is the address the file landed at;
  • the budget is 30 seconds plus one second per 32 kB of file.

A photo taken off-network waits on the device and is sent before the queue is drained; its URL is then written onto its entity as an edit. A failed upload keeps the file and tries again.


3. Session — authentication and renewal ​

Authentication is @geoleaf-plugins/connector's: it adds Authorization: Bearer <token> to the requests under its baseUrl — the page's fetch, and the requests MapLibre makes (tiles, glyphs, sprites). It has two modes, and exactly one is configured.

3.1 getToken — the host provides the token ​

The page owns the session and hands the token over at each request. The server needs only to accept Authorization: Bearer <token> and to answer 401 to a token it refuses. On a 401, the connector asks getToken once more and replays the request once; a null then emits geoleaf:connector:auth-error.

3.2 auth.endpoint — a login route on the server ​

Sign-in, from the connector's login window:

POST {auth.endpoint}
Content-Type: application/json

{ "login": "…", "password": "…" }

A 2xx JSON answer carrying a non-empty string token and expiresIn — the lifetime in seconds, a finite number greater than 0 — signs in. The token is opaque to GeoLeaf: it is never decoded. 401, 404, a 5xx and any other non-2xx each fail the sign-in with their own message; so does a body that is not that JSON. The exchange is given 15 seconds, body included: past them it fails as an unreachable server does.

A refusal may say why, and it is optional. A non-2xx answer declared Content-Type: application/problem+json (RFC 9457) and whose body is a JSON object is read as problem details:

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{ "type": "https://example.com/problems/second-factor-required",
  "title": "Second factor required",
  "detail": "A second factor is required for this account." }

The login window shows detail — title when there is none — under its own message, as text, cut at 300 characters, in whatever language the server wrote it. type, title, detail and the whole body reach the host in geoleaf:connector:login-failed, with the status. The status still decides the window's own message, and a refusal without such a body fails exactly as described above. The body is read within the same 15 seconds, and a body over 16,384 characters is not read as a problem.

Renewal, derived from the same endpoint — /refresh is appended to it as it is written, so …/auth/login becomes …/auth/login/refresh:

POST {auth.endpoint}/refresh
Content-Type: application/json
Authorization: Bearer <current token>

No body. The token presented may already have expired: the server decides. The renewal is read as one of three verdicts, and only a refusal ends a session:

VerdictWhenEffect
reneweda 2xx whose JSON carries a non-empty token and a finite expiresIn greater than 0the new token is stored and used
unavailableno network; the 15-second budget spent, body included; a body cut in transit; 408, 429, 500, 502, 503, 504the session is kept, and the renewal is tried again later
refusedany other non-2xx — 401, 403, 404, 501… — or a 2xx whose body cannot renewthe session is erased, geoleaf:connector:auth-error is emitted

There is no timer. When a request needs the token, the connector renews first if less than 30 seconds are left, and in the background if less than 5 minutes are. A 401 on a data request triggers one renewal — shared with any already in flight — and one replay.

A server without /refresh answers it 404: a refusal. Its sessions end 30 seconds before expiresIn — the token is erased and geoleaf:connector:auth-error emitted, and with auth.ui the login window opens again.

Signing out is local: the connector erases the token and emits geoleaf:connector:signed-out. It calls no route, so a token that must stop working before its expiry has to be revoked by the server itself.

The security posture of this exchange — HTTPS, where the token is kept, the three traps of common identity providers — is in §7 of the security guide.


4. Transport ​

  • HTTPS. The connector refuses a baseUrl or an auth.endpoint that is not https:, unless the URL itself targets a development host — localhost, *.localhost, the loopback addresses, or a name under .test, reserved for testing (RFC 6761). The destination is judged, not the page: a page served on localhost may not send a credential over http:// to a remote host. auth.signupUrl and auth.forgotPasswordUrl follow the same rule. The data endpoints of the layers are not checked by the connector, and must be served over HTTPS in production.
  • CORS. A server on another origin than the application must allow it, and — for the writes and the authenticated requests — the Authorization, Content-Type and Prefer headers and the PATCH and DELETE methods, in its answer to the browser's preflight.

Released under the MIT License.