Downloadable offline regions (experimental)

@honua/sdk-js/offline contains bounded, independently usable slices of issue #396. It defines a versioned manifest, storage-neutral download coordinator, persistent browser store, durable edit queue, and composed local-first status. It does not make the broader local-first feature complete.

The checked-in network-disabled reference workflow shows the public IndexedDB, diagnostic, fetch-handler, edit-queue, replay, and status contracts booting through a host-owned application-shell worker when networking is disabled before reload. It captures a field edit while disconnected, keeps it across a reload taken with networking still disabled, and replays it once on reconnect against a loopback fixture transport — which proves the durable local transitions, not hosted replica synchronization.

import {
  createOfflineRegionDiagnostic,
  createOfflineRegionManifest,
  downloadOfflineRegion,
} from "@honua/sdk-js/offline";

const manifest = await createOfflineRegionManifest({
  name: "Field area",
  sourceId: "incidents",
  endpoint: "https://example.test/FeatureServer/0",
  authorizationScopeFingerprint: currentAclFingerprint,
  bounds: { minX: -158.3, minY: 21.4, maxX: -157.6, maxY: 21.8, crs: "EPSG:4326" },
  minZoom: 8,
  maxZoom: 14,
  sourceVersion: "source-v3",
  schemaVersion: "schema-v7",
  planVersion: "plan-v2",
  observation: { state: "live", observedAt: "2026-07-10T10:00:00Z" },
  resources: plannedResources,
});

const receipt = await downloadOfflineRegion(manifest, {
  store: applicationStore,
  load: applicationResourceLoader,
  logicalQuotaBytes: 512 * 1024 * 1024,
  signal: abortController.signal,
  onProgress: renderProgress,
});

const diagnostic = await createOfflineRegionDiagnostic(
  manifest,
  await applicationStore.inventory(),
  {
    logicalQuotaBytes: 512 * 1024 * 1024,
    now: new Date(),
    staleAfterMs: 15 * 60 * 1000,
  },
);

Planning a snapshot and reading it back (experimental)

planOfflineRegionSnapshot() is the producer between the protocol-neutral contract and the region store: it turns a source identity, a canonical Query, a bounded extent, and the payloads an application already holds into a manifest whose resource identities are deterministic functions of that selection. An identity is derived from contract inputs — source id, normalized credential-free endpoint, authorization-scope digest, source / schema / plan versions, extent, canonical query, resource kind and selector — and never from a signed or token-bearing request URL.

import {
  createMemoryOfflineRegionStore,
  createOfflineRegionFeatureBatch,
  createOfflineRegionSnapshotLoader,
  downloadOfflineRegion,
  encodeOfflineRegionFeatureBatch,
  planOfflineRegionSnapshot,
  readOfflineRegionQuery,
} from "@honua/sdk-js/offline";

const query = { outFields: ["id", "status"], returnGeometry: true };
const batch = createOfflineRegionFeatureBatch(await source.query(query), {
  pagination: { offset: 0 },
});

const snapshot = await planOfflineRegionSnapshot({
  name: "Field area",
  sourceId: source.descriptor.id,
  endpoint: source.descriptor.locator.url,
  authorizationScopeFingerprint: currentAclFingerprint,
  bounds: { minX: -158.3, minY: 21.4, maxX: -157.6, maxY: 21.8, crs: "EPSG:4326" },
  sourceVersion: "source-v3",
  schemaVersion: "schema-v7",
  planVersion: "plan-v2",
  observation: { state: "live", observedAt: new Date().toISOString() },
  attribution: { noaa: "Data: NOAA" },
  query,
  contents: [
    {
      kind: "features",
      bytes: encodeOfflineRegionFeatureBatch(batch),
      contentType: "application/json",
      attributionIds: ["noaa"],
    },
  ],
});

await downloadOfflineRegion(snapshot.manifest, {
  store: applicationStore,
  load: createOfflineRegionSnapshotLoader(snapshot),
  logicalQuotaBytes: budget.logicalBudgetBytes,
});

// Later, with no network at all.
const read = await readOfflineRegionQuery(snapshot.manifest, {
  store: applicationStore,
  authorizationScopeFingerprint: currentAclFingerprint,
  query,
  bounds: snapshot.manifest.bounds,
});
render(read.result.features, read.attribution, read.provenance.observation);

The read path answers only what the region actually holds:

read.cache reports the persistent-cache decision — region identity, query fingerprint, authorization-scope digest, freshness, completeness, and exactly which stored resources answered — and read.planCache feeds explainQuery() so the query plan reports it too. A fresh region carries its manifest identity as the plan's fingerprint validator, which binds the plan's own fingerprint to the region that answered it; a stale region deliberately carries no validator.

Tiles, assets, and metadata (experimental)

A region has always stored tile, asset, and metadata resources; those kinds read back under exactly the same gate a feature batch does. Address them with the typed selector builders so a snapshot and a later read agree without persisting any URL:

import {
  offlineRegionAssetSelector,
  offlineRegionMetadataSelector,
  offlineRegionTileSelector,
  planOfflineRegionSnapshot,
  readOfflineRegionAsset,
  readOfflineRegionMetadata,
  readOfflineRegionTile,
} from "@honua/sdk-js/offline";

const snapshot = await planOfflineRegionSnapshot({
  ...selection,
  contents: [
    {
      kind: "tile",
      bytes: tileBytes,
      contentType: "application/vnd.mapbox-vector-tile",
      selector: offlineRegionTileSelector({ z: 12, x: 671, y: 1042 }),
    },
    { kind: "asset", bytes: spriteBytes, contentType: "image/png", selector: offlineRegionAssetSelector("sprite-2x") },
    {
      kind: "metadata",
      bytes: landingPageBytes,
      contentType: "application/json",
      selector: offlineRegionMetadataSelector("landing-page"),
    },
  ],
});

const tile = await readOfflineRegionTile(snapshot.manifest, {
  store: applicationStore,
  authorizationScopeFingerprint: currentAclFingerprint,
  tile: { z: 12, x: 671, y: 1042 },
});
render(tile.bytes, tile.contentType, tile.attribution);

const landing = await readOfflineRegionMetadata(snapshot.manifest, {
  store: applicationStore,
  authorizationScopeFingerprint: currentAclFingerprint,
  document: "landing-page",
});
console.log(landing.document); // parsed, because the stored media type is JSON

What these reads add to the shared discipline:

Binding tiles to the map runtime

Offline tiles reach MapLibre through the protocol seam the runtime already uses for other custom schemes, rather than a second tile pipeline. A style names offline-region:// tiles and the handler answers each one from the region.

The simplest binding is to hand loadMapPackage a handler and the style sources that should come from storage. It rewrites those sources' tile templates and registers the protocol before map.setStyle:

import { createOfflineRegionTileProtocol } from "@honua/sdk-js/offline";
import { loadMapPackage } from "@honua/sdk-js/runtime";

const runtime = await loadMapPackage(pkg, map, {
  client,
  offlineRegion: {
    tileHandler: createOfflineRegionTileProtocol({
      manifest,
      store: applicationStore,
      authorizationScopeFingerprint: currentAclFingerprint,
    }),
    sourceIds: ["incidents"],
  },
});

Registration is driven by evidence, never by import. @honua/sdk-js/runtime never imports @honua/sdk-js/offline: a handler is bound to one manifest, one store, and one authorization scope, none of which the runtime can invent, so the caller supplies it and the runtime decides only when it must be registered. ensureOfflineRegionProtocol() is idempotent per scheme, exactly like the PMTiles registration it shares a registry with, and a style that addresses the scheme with no handler supplied fails the load — a missing handler would render blank tiles rather than an error. The same check guards maplibreRenderer for an owned map.

rewriteStyleTilesForOfflineRegion() is available on its own, and is deliberately narrow:

A tile the region does not hold rejects with its typed reason instead of falling back to the network or rendering nothing, and the handler declares no-store so freshness, eviction, and provenance stay answerable from the region alone.

The same identities also plug into the existing service-worker seam. resolveOfflineRegionResourceId() turns a selection into the resource id an OfflineRegionResourceMatcher hands to createOfflineRegionFetchHandler(), so a host that already matches its own tile URLs keeps doing exactly that.

createMemoryOfflineRegionStore() is a non-durable twin of the IndexedDB store for Node, workers, and tests. Both pass one shared suite:

import { runOfflineRegionStoreConformance } from "@honua/sdk-js/offline";

const report = await runOfflineRegionStoreConformance({
  createStore: () => createMemoryOfflineRegionStore(),
  label: "memory",
});
console.log(report.failed === 0);

Storage budget and quota admission

logicalQuotaBytes is an honest accounting of declared payload lengths, but on its own it has no relationship to the space the browser will grant. Ask the platform instead of inventing a constant: probeOfflineStorageBudget() reads navigator.storage.estimate() through an injectable interface and derives a conservative logical budget.

import {
  downloadOfflineRegion,
  probeOfflineStorageBudget,
  requestOfflinePersistentStorage,
} from "@honua/sdk-js/offline";

const budget = await probeOfflineStorageBudget();
if (budget.status === "unavailable") {
  // No StorageManager, or no estimate. The SDK reports that rather than
  // fabricating a number; the application decides what to offer.
  showUnknownCapacity(budget.reason, budget.persistence);
} else if (budget.logicalBudgetBytes < manifest.totalLogicalBytes) {
  showTooLarge(budget.remainingBytes, budget.reserveBytes);
} else {
  await downloadOfflineRegion(manifest, {
    store: applicationStore,
    load: applicationResourceLoader,
    logicalQuotaBytes: budget.logicalBudgetBytes,
  });
}

// Only ever on an explicit user action: this can prompt.
if (budget.persistence === "best-effort" && userAskedToKeepData) {
  const persistence = await requestOfflinePersistentStorage();
  console.log(persistence.status); // "granted" | "denied" | "unavailable"
}

Derivation is deterministic integer arithmetic over the reported values:

remaining = max(0, quota - usage)
reserve   = min(remaining, max(minimumReserveBytes, floor(remaining * headroomRatio)))
budget    = remaining - reserve

so the derived budget can never exceed the platform-reported remaining quota, and an origin with less free space than the reserve floor (16 MiB by default) is offered 0 rather than a number that cannot be honoured. The estimate is deliberately imprecise and origin-scoped, so the result is advisory, never a guarantee — which is exactly why a reserve exists and why physical occupancy, deduplication, and index overhead stay outside the contract.

A device can still refuse a write the budget admitted. When it does, the platform QuotaExceededError raised while staging, writing, or committing is classified as quota-exceeded / offline.region.quota rather than the internal store-failed / offline.storage.failure class, and the error carries the admission plan that was attempted:

try {
  await downloadOfflineRegion(manifest, downloadOptions);
} catch (error) {
  if (isHonuaError(error, "offline.region.quota")) {
    // Required, evicted, and projected logical bytes, without recomputing them.
    showEvictionPrompt(error.admission);
  }
}

A refused plan is a proposal, not a record of what happened: a region refused before the download starts evicts nothing, and no region outside admission.evictRegionIds is ever removed. Pinned regions are never proposed. isStorageQuotaPressureError() is exported so a host-supplied OfflineRegionStore can classify the same condition the same way instead of hiding a full device inside its own wrapper.

Persisted state changes the eviction risk for every cached region, so it is observed rather than inferred: the probe reports persistence: "persisted" | "best-effort" | "unknown", and createOfflineRegionDiagnostic() accepts the probe result and republishes it as diagnostic.storage. The diagnostic never probes on its own, and navigator.storage.persist() is reached only through requestOfflinePersistentStorage() — a download is not consent to prompt.

Durable edit queue

The same subpath exposes a storage-neutral queue contract, a deterministic in-memory implementation, and a persistent IndexedDB implementation. Queue identity is partitioned by the already-digested authorization scope, source, and an opaque application idempotency key.

import { createIndexedDbOfflineEditQueue, replayOfflineEditPass } from "@honua/sdk-js/offline";

const queue = createIndexedDbOfflineEditQueue();
const enqueued = await queue.enqueue({
  authorizationScopeDigest: manifest.source.authorizationScopeDigest,
  sourceId: manifest.source.id,
  idempotencyKey: localMutationId,
  edit: {
    operation: "update",
    featureId: incidentId,
    attributes: { status: "contained" },
  },
});

const receipt = await replayOfflineEditPass(queue, async (request, { signal }) => {
  const serverAcknowledgement = await sendThroughHostedMutationTransport(request, { signal });
  return {
    kind: "applied",
    editId: request.editId,
    requestFingerprint: request.requestFingerprint,
    idempotencyKey: request.idempotencyKey,
    serverOperationId: serverAcknowledgement.operationId,
    serverGeneration: serverAcknowledgement.generation,
  };
}, {
  authorizationScopeDigest: manifest.source.authorizationScopeDigest,
  sourceId: manifest.source.id,
  workerId: replayWorkerId,
  limit: 10,
  leaseDurationMs: 30_000,
});

console.log(enqueued.status); // "enqueued" or "duplicate"
console.log(receipt.appliedCount);

Replay conflicts in the sync-conflict vocabulary

A conflicted acknowledgement records an opaque conflictId on the queued edit. The SDK separately ships a conflict-review vocabulary — SyncConflictDetail, SyncConflictId, SyncConflictKind, ServerGenerationCursor. Without a projection an application holds two disjoint vocabularies for the same event.

projectOfflineReplaySyncConflict() is that projection, and only that: a pure function with no I/O, no clock, and no runtime dependency on the replica-sync module. Pass a replica binding to replayOfflineEditPass() or createLocalFirstStatus() and the projection is applied to the conflicts those surfaces already report — it is not a second channel.

import { projectOfflineReplaySyncConflict, replayOfflineEditPass } from "@honua/sdk-js/offline";

// The SDK cannot derive a replica from a queue partition, so the binding is an
// explicit application input. Omit it and no projection is produced; nothing is
// guessed.
const replica = { replicaId: registeredReplicaId, datasetId: registeredDatasetId };

const receipt = await replayOfflineEditPass(queue, transport, {
  authorizationScopeDigest: manifest.source.authorizationScopeDigest,
  sourceId: manifest.source.id,
  workerId: replayWorkerId,
  limit: 10,
  leaseDurationMs: 30_000,
  replica,
});

for (const outcome of receipt.outcomes) {
  if (outcome.syncConflict?.outcome !== "projected") continue;
  console.log(outcome.syncConflict.conflict.id); // a SyncConflictId
  console.log(outcome.syncConflict.conflict.kind); // "replica-sync"
  console.log(outcome.syncConflict.conflict.serverGen); // a ServerGenerationCursor
}

// The same projection is available directly for a record already in the queue.
const projection = projectOfflineReplaySyncConflict({ edit: conflictedQueuedEdit, replica });

Three rules make the projection safe to consume:

This makes the two vocabularies agree; it does not make the SDK a replica-sync client. The replay transport is still application-owned, end-to-end exactly-once delivery and hosted replica synchronization remain server properties, and validating a projected conflict against live server conflict semantics stays gated on that server work.

Replica conflict policy on a replay pass

A replica is registered with a ReplicaConflictPolicy. A replay pass accepts that policy explicitly, and either carries it out or refuses it by name — it is never quietly downgraded to "park the conflict and hope someone looks".

The test that decides the two arms is one question: can the complete effect on the durable edit queue be computed from queue-side state alone — the queued edit, its conflict record, and the local clock — with no remote content and no server decision?

Policy SDK disposition Replay action Reason
manual locally-honoured retain-for-review queue-side-outcome
server-wins locally-honoured discard-local-edit queue-side-outcome
client-wins server-adjudicated refuse needs-server-override
last-writer-wins server-adjudicated refuse needs-remote-edit-time

OFFLINE_REPLAY_CONFLICT_POLICIES is the machine-readable form of this table, and it is keyed by policy, so a new member of the shipped union fails the build rather than falling through to a default. The same table appears in the ReplicaConflictPolicy contract's own documentation, and a test fails if the two surfaces disagree.

import { isHonuaOfflineConflictAdjudicationError, replayOfflineEditPass } from "@honua/sdk-js/offline";

try {
  const receipt = await replayOfflineEditPass(queue, transport, {
    authorizationScopeDigest: manifest.source.authorizationScopeDigest,
    sourceId: manifest.source.id,
    workerId: replayWorkerId,
    limit: 10,
    leaseDurationMs: 30_000,
    replica,
    conflictPolicy: registeredReplica.conflictPolicy,
  });
  for (const outcome of receipt.outcomes) {
    // "retained-for-review", "discarded-local-edit", or "not-applied".
    if (outcome.conflictPolicy) console.log(outcome.conflictPolicy.outcome);
  }
} catch (error) {
  if (!isHonuaOfflineConflictAdjudicationError(error)) throw error;
  // Names the policy it refused; nothing was claimed and nothing was replayed.
  console.log(error.code, error.policy); // "server-adjudicated-policy" "client-wins"
}

The refusal happens before the first edit is claimed, so a pass either honours the policy it was given for every conflict it meets or does nothing at all.

Recording a reviewed resolution

recordOfflineConflictResolution() takes the reviewer's decision in the shipped contract's own shape — a SyncConflictResolution, the value a conflict-review surface already produces — and records it against the durable queued edit that raised the conflict.

import { recordOfflineConflictResolution } from "@honua/sdk-js/offline";

const receipt = await recordOfflineConflictResolution(queue, {
  authorizationScopeDigest: manifest.source.authorizationScopeDigest,
  sourceId: manifest.source.id,
  editId: conflictedEditId,
  // Straight from the review surface; no mapping is invented in between.
  resolution: { conflictId, choice: "accept-server", resolvedBy: { id: reviewerId } },
});

console.log(receipt.disposition); // "discarded"
console.log(receipt.state); // "cancelled"
console.log(receipt.acknowledgement); // "unacknowledged-by-server"

What the record then says:

Choice Disposition Queue state Meaning
accept-client requeued pending The local edit stands and is delivered again.
accept-server discarded cancelled The local edit is abandoned; it was never delivered.
discard discarded cancelled The local edit is abandoned; it was never delivered.
merge Refused.

Composed local-first status

Cache diagnostics answer "what is in the store", and the queue answers "what have I not delivered". createLocalFirstStatus composes both, plus a host-supplied connectivity signal, into one versioned, payload-free snapshot that names a single state.

import { createLocalFirstStatus } from "@honua/sdk-js/offline";

const partition = {
  authorizationScopeDigest: manifest.source.authorizationScopeDigest,
  sourceId: manifest.source.id,
};

const status = createLocalFirstStatus({
  // Reachability is host policy. The SDK never reads navigator.onLine, because
  // link state is not endpoint reachability.
  connectivity: endpointReachable ? "online" : "offline",
  now: new Date(),
  regions: [diagnostic],
  // A bounded detail sample for conflicted identities and timing...
  edits: await queue.list({ ...partition, limit: 100 }),
  // ...and the authoritative totals, because list() is capped at 100 records
  // and has no cursor.
  editCounts: await queue.countByState(partition),
});

console.log(status.state); // "pending"
console.log(status.reason); // "undelivered-edits"
console.log(status.reads.availability, status.reads.freshness);
console.log(status.writes.coverage); // "complete"
console.log(status.writes.undeliveredCount, status.writes.conflictedCount);
// Empty unless `replica` was supplied; see "Replay conflicts in the
// sync-conflict vocabulary" above.
console.log(status.writes.syncConflicts.length);

OfflineEditQueue.list() returns at most 100 records in created order and has no cursor, so it can never be counted. Pass editCounts from countByState(), which reads the partition/state index without materializing any edit payload; the returned totals are authoritative and drive both writes.counts and the headline state. Without it, writes.coverage is sampled and the counts are only a lower bound, so a partition whose first 100 records are terminal would read as idle while later work is still undelivered. A sample that disagrees with its own totals is rejected rather than published.

The headline state is resolved by a total, deterministic precedence. Data problems outrank undelivered work, which outranks mere staleness, which outranks being disconnected:

Precedence state reason Condition
1 conflicted conflicted-edits Any queued edit is in the conflicted state.
2 expired expired-regions Any stored region is expired.
3 partial partial-regions / missing-regions Regions were supplied and worst-case completeness is not complete.
4 pending undelivered-edits Any edit is pending, leased, or retryable.
5 stale stale-regions Any stored region is stale.
6 offline disconnected Connectivity is offline and nothing above applies.
7 online connected Connectivity is online and nothing above applies.

Aggregation is worst-case, so one expired or partial region cannot be hidden by fresher siblings. Freshness aggregates over stored regions only: a cache miss has no stored observation to age. reads.availability is live when the host reports connectivity, cached when it does not but a region is readable, and unavailable when it is neither. The status is deeply frozen, JSON serializable, deterministic for identical inputs regardless of supplied order, and never reads edit.attributes or edit.geometry.

Cache facets are validated together, not independently. A diagnostic is derived from one stored entry, so state, freshness, completeness, reason, and readable cannot vary freely; a tampered or hand-built combination that the diagnostic could never have produced is rejected instead of resolving to a confidently wrong headline.

Schema versions, migration, and an unreadable store

Two versions govern the persistent stores, and they are deliberately separate.

The region store's forward ladder

OFFLINE_REGION_SCHEMA_MIGRATIONS is an ordered registry of fromVersiontoVersion steps, each a pure structural rewrite of one stored record. On open the store reads the persisted version and resolves a plan before touching anything:

planOfflineRegionSchemaMigration(version) answers "can this store still be read?" without opening it, and readableOfflineRegionSchemaVersions() lists the versions this build accepts. The walk is bounded and cycle-safe, so a malformed registry refuses rather than spinning.

A migrated record keeps exactly the identity it was stored under. Every step's output is checked before the next one runs: a step that rewrote a region id, a resource id, or the authorization-scope digest fails the record and aborts the whole transaction, leaving the prior database version readable. Re-deriving the scope digest would silently repartition another principal's cached bytes, so it is treated as a defect rather than a migration.

The shipped 2 → 3 step is deliberately the identity on region records: that database-version change altered only staging rows. It exists so the ladder, its bounds, its identity invariants, and its refusal are exercised before the first real layout change, rather than being written under the pressure of one.

What an application should do when its store is unreadable

store-unreadable means the cached bytes are intact but this build declines to interpret their layout — most often a version rollback, occasionally a hand-modified database. It is not retryable and it is not corruption.

  1. Report it. The regions are still on disk and still belong to the user.
  2. Offer an explicit re-download, or a newer build that can read the layout.
  3. Only then reset the store deliberately, with indexedDB.deleteDatabase(name). The SDK will not do that for you, because cached region bytes are expensive to re-acquire — often over a metered or absent network — and a silent bulk delete is the worst available outcome.

Pass onRecovery to createIndexedDbOfflineRegionStore to observe every open. The report separates the two failure modes on purpose: discardedCorruptRecords counts records this build proved unusable and removed, while unsupportedRegions counts regions left untouched because their schema version has no path forward.

The edit queue's recovery posture

The queue is the durable source of retry, lease, dependency, conflict, and audit state, so a silently accepted malformed record there becomes a wrong write rather than a lost read. Nothing persisted is trusted on the way back in.

onRecovery reports counts and stable reason codes (foreign-version, corrupt-record, credential-screened, orphaned-metadata, and the restored-metadata repair) through the same offline error envelope every other queue failure uses. Validation reads identity, state, and timing fields only; it never reads an edit.attributes or edit.geometry value, and no report echoes one. A discarded record is a lost local write, so recovery discards only what it can prove is unusable, and it always says how many.

Contract guarantees

The manifest contains logical resource ids, not request URLs. The injected loader may resolve short-lived signed URLs or authorization at download time; those values never cross the persistent-store boundary.

Non-goals and remaining work

The storage-backed fetch handler can be installed in a service worker or other fetch integration, but the host still owns request matching and network reachability policy. This slice does not provide encryption policy, a complete application-level query/read cache, a server transport adapter, or an automatic connectivity loop. createLocalFirstStatus composes state that already exists; it does not probe reachability, trigger reconnect, or revalidate a stale region. The queue and one-pass coordinator are local durability primitives; they do not claim end-to-end exactly-once synchronization. Applications must bind the injected transport to established Honua Server replica-sync, upload-cursor, and conflict-review contracts exposed through @honua/app-platform; this offline storage subpath does not duplicate that client or manufacture server acknowledgement. End-to-end integration evidence remains required before issue #396 can satisfy its Beta acceptance criteria. This entrypoint is @experimental and subpath-only so the root and browser bundles do not absorb it.