ConstWire the assembled implementation modules into the facade. Called once by
entry.ts after every sub-module has been imported and assembled.
IndexedDB layer accessor — read by StorageContract.DB.
CacheAPI cache manager accessor — read by StorageContract.CacheManager.
Network state detector resolved lazily from globalThis.GeoLeaf.
Cache namespace ({ Storage, LayerSelector }) — read by StorageContract.Cache
(download-handler, selection-cache, cache-control-zone).
Plugin Contract v1 accessor for the IndexedDB layer.
Plugin Contract v1 accessor for the CacheAPI cache manager.
Plugin Contract v1 accessor for the cache namespace ({ Storage, LayerSelector }).
Initialise every available storage sub-module (IndexedDB, CacheManager, and optionally the offline detector).
per-module options; enableOfflineDetector is opt-in (default
false). The Service Worker is not started from here: it is the PWA capability's.
true once all available modules are initialised.
true when the IndexedDB connection is open (plugin ready).
true as soon as the offline engine registered — independently of IndexedDB
opening, which isAvailable tests.
⚠️ Delegation added so @geoleaf-plugins/offline-ui stops importing
StorageContract. The contract is a SINGLETON whose state is a module-scoped
let: a plugin loaded as <script type="module"> has its own graph and
cannot share it. The copy it embedded was never initialised — this member
gives it the real one, through the namespace.
Resolves when the offline engine is ready to drive.
⚠️ NEVER resolves while modules.offline is disabled — the engine does not
load, and UI actions awaiting it defer indefinitely. That is the contract's
behaviour, taken as-is. Same delegation motive as isPluginLoaded.
Pulls a declared layer's entities into the local features store.
Bounded by the layer's offline.maxFeatures and, optionally, by a bounding box.
Downloading NEVER grants write access: the records land as synced and no queue entry
is created (invariant S6 of the sync contract).
A layer that is on the map and declares offline.enabled is given what the store
holds as the pull ends: the map follows without a reload, and geoleaf:layer:updated
announces it. Since 3.15.0 — the layer kept its previous drawing until the next load.
⏱ Waits for the engine, but not indefinitely. The implementation lives in
the offline chunk, loaded via import() after boot — calling it at the
first frame would find it absent. The race is bounded by
PULL_ENGINE_WAIT_MS because StorageContract.whenReady() never
resolves when modules.offline is disabled: unbounded, the call would hang
forever on an engine-less variant.
⚠️ Unlike the layer read, there is no network fallback here. A pull
without an engine must SAY SO — refused: "engineUnavailable" — and not
return a zero nothing distinguishes from an empty layer.
Identifier of the layer to pull.
Optionaloptions: { bbox?: [number, number, number, number]; signal?: AbortSignal }
bbox extent and abort signal.
The pull report; refused is non-null when nothing was written.
Applies an edit locally AND queues it for the server — the optimistic write.
The entity goes into the features store and the operation into the
outbox, in a single transaction: a field capture has no other copy, and
a half-done write would be undetectable after the fact.
⚠️ Nothing written here confers editability — on the contrary, the call is REFUSED, with its motive, when the layer is not modifiable online. Downloading a layer never made a layer modifiable.
Bounded engine wait, same motive as Storage.pullLayer: the offline
chunk is deferred, and whenReady() never resolves without
modules.offline.
The layer, operation kind, identity and entity. For an existing entity the
identity may be the server one the map shows: the store resolves it to its own key. The
entity may be partial: its properties are merged over the stored ones, and the stored
geometry is kept when it carries none.
The report: entry created, merged, or cancelled; localId is the key the edit
landed on; refused carries the motive.
Does the layer grant this operation? — to consult BEFORE writing.
🛑 It is SYNCHRONOUS AND ENGINE-LESS, deliberately.
The permission is read from the active profile, not IndexedDB: routing it
through the edit bag (like Storage.applyEdit) would have made it
unavailable when the offline chunk is not loaded — i.e. exactly the case
that carried the hole, @geoleaf-plugins/editor declaring requires: [] and
running in persistence.mode: "online" without this engine.
⚠️ Does not replace applyEdit's guard, it precedes it. applyEdit
keeps refusing on its own account: a caller not consulting this predicate
bypasses nothing. Both apply grantsEdition, the same function.
⚠️ An unknown layer yields false — refuse the unknown, otherwise a typo in
an identifier would amount to authorisation.
The layer identifier from the active profile.
The submitted operation.
true only when the layer literally grants this operation.
Drains the outbox: pushes every queued edit and reconciles server identities (4.5).
⚠️ Runs in the PAGE, never in the Service Worker — contract point 5. The
connector's fetch patch lives in the page; a replay from the worker does not
see it and would leave without a token. That is the motive that removed the
Background Sync path.
Does not throw: a failing entry stays queued, failed not being terminal.
The drain tally; refused is non-null when the engine is not wired.
Arms the drain triggers — lifecycle only, hence the underscore.
🛑 UNDERSCORED ON PURPOSE, AND IT IS NOT SHYNESS. namespace-surface.mjs drops
_-prefixed members from the measured surface, so the golden master and
EXPECTED_FACADE_MEMBERS do not move for a lifecycle concern. A public
armOutboxDrain() would be a promise to integrators that arming is theirs to make —
it is not: the capability arms once its storage is ready, and calling it from
outside could only duplicate that.
Optionaloptions: { pollIntervalMs?: number }
{ pollIntervalMs }, from modules.offline.drain.
Can a write to layerId be HELD here, to be sent later?
🛑 THE PREDICATE THAT MAKES ONE WRITE PATH SAFE — and its first draft asked the
wrong question. It asked whether the DRAIN could push the layer, and answered
false when the layer declared no write target, so a caller sent those writes down
its own online path. The E2E suite refuted it on the shipped bundle: deliverables have
their write endpoints stripped, so nothing was holdable there and an offline save
stopped reaching the outbox. Holding beats losing — a held capture is visible,
counted, and eventually set aside with a named motive; one sent to a dead endpoint is
gone.
false when the engine is not wired at all — the capability is opt-in, so a caller
must be able to learn that the queue simply does not exist here.
The layer about to be written to.
true when a local write will be accepted and kept.
Releases the drain triggers — the capability's teardown owes this.
Mounts the permanent sync strip — lifecycle only, hence the underscore.
Optionaloptions: { enabled?: boolean }
{ enabled }, from modules.offline.banner.
Removes the sync strip and its listeners.
Asks for a drain now — used by a write that has just landed in the queue.
⚠️ Fire-and-forget by design: a caller that awaited it would tie its own latency to the network, which is the very coupling the outbox exists to break.
What asked, for the log.
Requeues a quarantined entry, when its cause is LIFTED.
Reserved for motives whose cause can be observed as lifted —
retryBudgetExhausted, layerNoLongerWritable, notImplementedByServer,
authRequired and dialectNotSupported. The other two, deletedOnServer and
rejectedByServer, name a server fact no local gesture undoes: their exit is
Storage.discardQuarantined. A requeued entry starts a fresh replay budget.
An entry that goes back is announced — geoleaf:offline:quarantine-exited, with
exit: "requeued" — and a pass is asked for: no pushOutbox() has to follow.
The entry's contract identifier.
{ok} and, on refusal, its motive.
Requeues EVERY requeueable quarantined entry, in one gesture.
🛑 The per-entry exit takes a contract id, which nothing displays — so the only way to use it was to list the queue in a console. Yet what produces quarantines is never one entry: an expired token or a maintenance window sets aside a whole tour's captures at once.
The rule that decides stays Storage.requeueQuarantined's — motive
requeueable, cause observed as lifted — so what it refuses is COUNTED in
skipped, never silently taken.
The gesture is announced ONCE, whatever the number of entries it brings back
(geoleaf:offline:quarantine-exited), and asks for one pass. Bringing nothing back
announces nothing.
Optionalreason: string
Restrict to this motive; omitted, every requeueable entry.
How many came back, how many the rule left, or the refusal.
Destroys a quarantined entry, on EXPLICIT confirmation.
🛑 confirmedLocalId must be the entry's localId — a value the caller only
knows by having LISTED it. That is what makes it structurally true that the
capture was enumerated before being discarded, as ServerDeletionPolicy
requires since its amendment: what the contract forbids is the loss the
operator did not SEE.
The entity's local record then returns to the server's truth, unless another queue
entry still names it: removed when the server has nothing to give back (a creation that
never landed, deletedOnServer), marked synced otherwise, so the next pull replaces it.
A destroyed entry is announced — geoleaf:offline:quarantine-exited, with
exit: "discarded" — once the local record has settled. No pass follows: nothing is left
to send.
The entry's contract identifier.
This entry's localId, as the caller read it.
{ok} and, on refusal, its motive.
Which quarantine motives an operator's gesture can lift.
🛑 ONE AUTHOR FOR THE RULE. A window offering "retry all (motive X)" must know
which motives that sentence is true for BEFORE the click — a button that does nothing
when pressed teaches the user to stop pressing it. Without this read every surface
would hard-code the list, and a plugin cannot import the core (INV-NS), so its copy
would diverge unfalsifiably.
⚠️ It does NOT promise an entry will come back: Storage.requeueQuarantined still verifies that the cause is observed as lifted where that is checkable. It says which motives have an exit at all — the others exit through Storage.discardQuarantined.
The requeueable motives — empty when the offline engine is not wired.
The versions a settled conflict CRUSHED, and that the device kept (v6 store).
🛑 lastWriteWins overwrites, and until 17/09/2026 it overwrote BLIND. The drain
detected the conflict and logged it, then re-sent the write unfiltered without ever
reading the row it was about to destroy. After a settled update the server still holds
a row; after a settled DELETE it holds nothing, and neither did anything else.
⚠️ One record per entity: a second conflict on the same entity replaces the first. That is the store's bound — nothing evicts it, so the key is what keeps it finite.
⚠️ readOutcome says how much of the crushed row could be established. "unreadable"
means the overwrite happened anyway and the version is lost — recorded rather than
invented, because making the write depend on a read the server may not grant would
repeal the policy in silence.
⚠️ Only the active profile's conflicts, unless asked otherwise. The archive is shared
by every profile of the origin; a conflict belongs to the profile its write was queued
under (profileId). One archived before 3.18.0 has none, and is listed under every
profile.
OptionallayerId: string
Restrict to this layer; omitted, every layer.
Optionalscope: { allProfiles?: boolean }
allProfiles: true lists every profile's conflicts, each with its
profileId.
The archived conflicts — empty when the offline engine is not wired.
Drops the archived conflicts of one layer, or all of them.
⚠️ The only purge there is, and its being manual is deliberate. db/eviction.ts
names one store (layers), so nothing reclaims this one on its own — the same
property that keeps unsynchronised work out of eviction's reach.
⚠️ Only the active profile's conflicts, unless asked otherwise — the scope Storage.listConflicts reads: a purge under one profile must not erase what another profile's operator has not looked at yet.
OptionallayerId: string
Restrict to this layer; omitted, every layer.
Optionalscope: { allProfiles?: boolean }
allProfiles: true purges every profile's conflicts.
true when the purge ran, false when the engine is not wired.
Reports, layer by layer, what offline really has at hand.
🛑 The case it exists to make visible: a layer that declares a pull source
(offline.source) but was never pulled. The contract describes it as "the case with
no observable until the outage" — everything works, until the field. It shows up here
as declaredNeverPulled. A layer read from the local store with no source has nothing
to pull, and is notDeclared.
Never throws. With no engine wired, returns an empty array rather than a reassuring report: a report saying "all is well" having read nothing would be worse than no report, since it would believe itself complete.
One report per active-profile layer; empty when the engine is not wired.
"Can I leave?" — what a device must be read for before going off-network, in one read.
Assembles facts that each had their own door — the per-layer report, the queue, the
quota, the last preparation (when it ran, what failed, which zooms of a basemap were
left out) — and one that had none: whether the browser keeps this origin's data
(navigator.storage.persisted(), which the PWA requested at boot and only logged).
verdict says what they mean together: notReady when a layer declaring something to
pull is not on the device, degraded when the device can leave with something missing
or evictable, ready otherwise. The write session is not in it: its validity is the
connector's knowledge.
Never throws. With no engine wired, it answers null rather than a report that would
reassure without having read anything. Since 3.10.0.
The check, or null when the engine is not wired.
Will the offline preparation download this URL — and if not, why?
The preparation only downloads a basemap or a tiled layer from the application's own
origin while it is undeclared, or from an origin declared cacheable: true and
prefetch: true in modules.offline.dataOrigins. The refusal used to be a console
warning during the download: an interface offering the basemap could neither know it
beforehand nor say it afterwards. This is the same rule, asked before.
url may be a tile URL TEMPLATE: it is judged on the concrete tile the preparation
would request ({s} read as a).
⚠️ One URL, one verdict. A vector basemap is judged twice by the preparation — its
style, then the tiles, glyphs and sprite the style names, which may live on other
origins: a favourable verdict on the style URL does not promise everything behind it.
What a download actually left out is in its result, preparation.refusedOrigins.
Never throws. With no engine wired it answers a refusal — nothing is prepared at all. Since 3.15.0.
A resource URL, a tile URL template, or a style URL.
allowed, the origin judged and, on a refusal, its reason.
What the write queue still owes, as a whole.
🛑 THE SINGLE READ, AND IT IS PUBLIC BECAUSE A PLUGIN NEEDED IT. The sync strip had
its own private version; offline-ui's modal has to show the same four numbers, and it
cannot reach that one — deep imports of @geoleaf/core are bundled as a COPY, whose
StorageContract singleton nothing in the plugin's graph initialises, so the copy would
read an outbox that stays empty forever. Publishing the read is what stops the two from drifting.
⚠️ owed and quarantined are disjoint. An entry set aside is blocked, not owed;
adding them would report work the drain will never do.
Never throws. With no engine wired it answers zeros and null — which is what "there is
no queue on this device" actually looks like, the capability being opt-in.
Network state, entries owed, entries set aside, and the last accepted push.
The choices of a dropdown declared by URL (fetchOptions) — held on the device first,
fetched and kept otherwise when the network is there.
A form that works off-network needs its lists: they are kept where the cache budget
evicts nothing (the preferences store), filled by the offline preparation
(CacheManager.cacheProfile) and by any form opened online. A held list is answered
at once, and refreshed behind the answer while online. The request is the page's own
fetch, so a connector that authenticates its origin authenticates it too.
Never throws. It answers null — with no engine wired, when nothing is held off-network,
or when the list cannot be fetched — and the caller then fetches the URL itself, which is
what a dropdown with no resolver does. Since 3.10.0.
The list's URL, as the field declares it.
The choices, or null.
true when offline — uses the OfflineDetector if present,
otherwise falls back to navigator.onLine.
Aggregate storage usage across IndexedDB and the cache manager.
⚠️ features and outbox are counted since 03/08/2026. This method read
only layersCount and syncQueueCount — the two v3 stores — so after a pull
of 27 entities it still reported 0, and offline-ui displayed that zero. Not
observable while features had no writer: the zero was TRUE. It became false
the day the pull landed.
🛑 The sync: { pending, failed } block is REMOVED. Its only source was
syncQueueCount, i.e. the v3 store nobody wrote any more: it reported 0 in
all circumstances, and failed was never assigned at all. The real tally of
owed writes is outbox.count above, and the per-state breakdown is
StorageDB.getSyncCounts, which yields pendingCount and
quarantinedCount per layer.
storage quota/usage, layer counts (total + per profile), entity, outbox and conflict counts, cached profile ids and the online flag.
Close the IndexedDB connection and tear down the offline detector.
the profile to check.
true if the profile is fully cached for offline use.
the ids of every profile currently available offline.
Implementation modules injected by
entry.ts(composition root) at boot.