# Offline and storage

> Which storage to use for what, how origin partitioning isolates you, and how to build a mini app that survives a lift shaft.

Source: https://miniapp.sollar.com/guides/offline-and-storage/

---

A mini app runs at its own synthetic origin — `https://<appid>.miniapp.localhost/` on Android,
served through a custom scheme handler on iOS. Every web storage mechanism is therefore partitioned
to that origin **by the browser engine**, not by a convention the runtime remembers to apply.

That distinction is the point. Cache reuse between mini apps is the first of six vulnerability
categories documented across nine mini-program ecosystems, and it exists in those platforms because
they store everything in a shared directory and rely on the runtime to keep the paths apart. Origin
isolation is a rule the engine cannot violate.

## Choosing

| Need | Use | Quota | Syncs across devices |
|---|---|---|---|
| Small preference, should follow the user | `sollar.storage` | 256 KB | **yes** |
| Structured local data, offline records | IndexedDB | ~50 MB | no |
| Trivial local flag | `localStorage` | ~5 MB | no |
| Static assets for offline launch | Cache API + service worker | shared with IndexedDB | no |
| Anything secret | **nowhere on the client** | — | — |

> **DANGER**
No token, no key, no credential, no long-lived secret goes into any of these. All of them are
readable by the page, and the page runs on a device you do not control. Call
`sollar.auth.getToken()` before each use and let the host manage renewal.

## `sollar.storage`

```js
await sollar.storage.set('preferred_view', 'compact')
const view = await sollar.storage.get('preferred_view')   // undefined if unset
await sollar.storage.remove('preferred_view')
```

Keyed to (user, mini app). Survives reinstall and follows the user to a new device — which
`localStorage` does not. Use it for the handful of preferences that should follow a person, not for
data.

Exceeding 256 KB throws `ERR_STORAGE_FULL`. It is a preference store; if you are near the limit you
want IndexedDB.

## IndexedDB

Local, per-origin, and the right place for an offline record set.

```js
const db = await new Promise((ok, err) => {
  const req = indexedDB.open('acme-erp', 1)
  req.onupgradeneeded = () => {
    req.result.createObjectStore('orders', { keyPath: 'purchase_order_id' })
  }
  req.onsuccess = () => ok(req.result)
  req.onerror = () => err(req.error)
})
```

It is not synced and not backed up. Treat it as a cache that can vanish: a user clearing storage, an
OS reclaiming space under pressure, or a reinstall all take it. Anything that must survive belongs on
your server.

## Offline

```json title="manifest.json"
"capabilities": { "offline": true }
```

Declaring `offline` lets the mini app launch with no network. The package is already local — it was
installed, not fetched — so the shell always loads. What you have to decide is what happens when
your API is unreachable.

### A service worker for the shell

```js title="sw.js"
const SHELL = 'shell-v3'

self.addEventListener('install', (e) => {
  e.waitUntil(caches.open(SHELL).then((c) =>
    c.addAll(['/', '/index.html', '/app.css', '/app.js'])))
})

self.addEventListener('fetch', (e) => {
  const url = new URL(e.request.url)

  // Same-origin: cache first. The package is local; it cannot be stale.
  if (url.origin === location.origin) {
    e.respondWith(caches.match(e.request).then((r) => r ?? fetch(e.request)))
    return
  }

  // Your API: network first, fall back to the last good response.
  e.respondWith(
    fetch(e.request)
      .then((res) => {
        const copy = res.clone()
        caches.open('api-v1').then((c) => c.put(e.request, copy))
        return res
      })
      .catch(() => caches.match(e.request)))
})
```

> **CAUTION**
Service workers require a secure context. Mini apps are served over `https` at a synthetic origin, so
they qualify — but on iOS this depends on a custom scheme served through `WKURLSchemeHandler` being
treated as a secure context, which is verified per release and tracked as an open risk. Feature-detect
rather than assume:

```js
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
}
```

### A write queue

The harder half of offline is not reading; it is what happens to a write made on a train.

```js
async function queueApproval(orderId, note) {
  const tx = (await openDb()).transaction('outbox', 'readwrite')
  await tx.objectStore('outbox').add({
    id: crypto.randomUUID(),          // the idempotency key
    kind: 'approve',
    order_id: orderId,
    note,
    queued_at: new Date().toISOString(),
  })
}

async function flushOutbox() {
  if (!navigator.onLine) return
  const { access_token } = await sollar.auth.getToken()

  for (const item of await readOutbox()) {
    const res = await fetch(`https://erp.acme.example/api/orders/${item.order_id}/approve`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${access_token}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': item.id,       // the same key on every retry
      },
      body: JSON.stringify({ note: item.note }),
    })
    if (res.ok || res.status === 409) await removeFromOutbox(item.id)
    else break                            // stop; try the whole queue again later
  }
}

window.addEventListener('online', flushOutbox)
```

Three things that are not optional:

**The idempotency key is generated when the item is queued, not when it is sent.** A retry after a
timeout on a request the server *did* process is the normal case, and only a stable key makes the
second attempt harmless.

**`409` counts as done.** "Already approved" means the queue succeeded on an earlier attempt whose
response was lost.

**Stop at the first hard failure.** Draining the rest of a queue against a server that is refusing
requests turns one problem into many.

### Be honest in the UI

An action that is queued is not an action that happened. Show it as pending. A mini app that renders
an approval as complete when it is sitting in an outbox has told the user something untrue, and they
will act on it.

## What you never cache

- Tokens. Call `getToken()` each time.
- Anything you would not want read off a stolen, unlocked device.
- Personal data beyond what the current screen needs.

When a user is deprovisioned, your backend receives `user.deprovisioned` and must delete their data
— but you cannot reach the copy on their device. Cache the minimum, and clear it on sign-out:

```js
await sollar.storage.clear()
for (const key of await caches.keys()) await caches.delete(key)
indexedDB.deleteDatabase('acme-erp')
```

## Quotas and eviction

Storage is subject to eviction under pressure, and mini apps are evicted before the host. Do not
build anything whose correctness depends on the cache still being there — including a
"we already synced this" flag whose loss would silently skip a sync.

## Uninstall

Uninstalling removes every trace on the device: package, origin storage, caches, and the
`sollar.storage` entries for that user. Nothing survives except what your backend holds — which is
why `user.deprovisioned` matters, and why the deletion obligation is yours.
