Skip to content

feat(sqlite): ask the browser not to evict a browser database - #658

Merged
pal-tamas merged 2 commits into
mainfrom
worktree-browser-sqlite-persist
Aug 10, 2026
Merged

feat(sqlite): ask the browser not to evict a browser database#658
pal-tamas merged 2 commits into
mainfrom
worktree-browser-sqlite-persist

Conversation

@pal-tamas

Copy link
Copy Markdown
Owner

Summary

Rask.SQLite.Browser keeps its snapshots in IndexedDB, and IndexedDB is evictable. Under storage
pressure a browser may discard them, and the database comes back empty on the next load with nothing to
indicate why — the app looks like it lost the user's data, and nothing in it can say otherwise.

#645 landed IStorageEstimator.RequestPersistAsync(), so the
owning tab can now ask for the origin to be exempted from eviction.

builder.Services.AddRaskBrowserSqlite("app");                        // asks by default
builder.Services.AddRaskBrowserSqlite("app", o => o.RequestPersistentStorage = false);  // or don't

Decisions worth reviewing

  • IsPersistedAsync() is checked before asking, so an origin that is already exempt is never asked a
    second time — which matters on the browsers that prompt.
  • A refusal never fails the boot. It is logged and changes nothing else; the app runs exactly as it did
    before, the risk simply stops being silent. One log branch, not two: RequestPersistAsync resolves
    false both when the browser declines and when it has no such API, and from the caller's side those have
    the same consequence.
  • Only the owning tab asks. The others persist nothing, so a prompt there would buy the user nothing.
  • RequestPersistentStorage defaults to true, because a silently evictable database is a worse
    default than a request. It is an option for a real reason though: Chromium decides from engagement
    heuristics without prompting, whereas Firefox shows a permission prompt — and this is asked during
    boot rather than from a click. An app that would rather choose its moment sets it to false and calls
    IStorageEstimator.RequestPersistAsync() from a user-gesture handler.

Testing

  • 48/48 in Rask.SQLite.Browser.Tests (6 new): asks once; never asks when already persisted; never asks
    in a non-owner tab; never asks when disabled; still boots and restores when declined; still boots when
    the interop throws.
  • dotnet format clean, dotnet build Rask.slnx -warnaserror clean.
  • Full local browser E2E gate and CLI build gate passed on push.
  • No benchmarks: nothing here touches the render hot path.

Follow-ups (not in this PR)

  • The ranged-flush seam — IOriginPrivateFileSystem.WriteAsync now exists, so an offset+length interface
    can be designed against something real, with per-page hashes as the diff baseline rather than a shadow
    copy.
  • Multi-tab promotion, still documented as unimplemented.

Rask.SQLite.Browser keeps its snapshots in IndexedDB, and IndexedDB is evictable. Under storage
pressure a browser may discard them, and the database comes back empty on the next load with nothing
to indicate why — the app looks like it lost the user's data, and nothing in it can tell them
otherwise. #645 landed IStorageEstimator.RequestPersistAsync, so the owning tab can now ask.

IsPersistedAsync is checked first, so an origin that is already exempt is never asked a second time —
which matters on the browsers that prompt.

A refusal is logged and changes nothing else. The app runs exactly as it did before; the risk simply
stops being silent. One log branch, not two: RequestPersistAsync resolves false both when the browser
declines and when it has no such API, and from the caller's side those have the same consequence.

Only the owning tab asks. The others persist nothing, so a prompt there would buy the user nothing.

RequestPersistentStorage defaults to true because a silently evictable database is a worse default
than a request, but it is an option for a real reason: Chromium decides from engagement heuristics
without prompting, whereas Firefox shows a permission prompt — and this is asked during boot rather
than from a click. An app that would rather choose its moment sets it to false and calls
IStorageEstimator.RequestPersistAsync() from a user-gesture handler instead.
A second tab was told to close the other one and then left to guess when that had happened. Now
BrowserSqliteOwnership.Available completes once the owner goes away, so an app can offer a reload.

Reloading is what takes ownership, and that is a constraint rather than a shortcut. A waiting tab
already opened its own empty database at boot and the app holds live connections to it: the file
cannot be swapped underneath them, and a tab that started persisting its empty database would
overwrite the previous owner's good snapshot with nothing. So this reports availability; it does not
transfer ownership.

The watcher polls with TryRequestAsync, which acquires and releases inside the call, rather than
waiting on RequestAsync. Waiting would mean HOLDING the lock the instant it frees — making this tab
an owner it must never be, and blocking a tab that could actually use it. Which is also why the
signal is advisory: another tab may win between the poll and the reload, and the reloaded page runs
the normal election and finds out.

The watch is started unawaited so it cannot delay the boot, and StopAsync cancels and awaits it so a
poll cannot outlive the page.

Covered by an E2E that opens two real pages in one browser context, closes the owner, and asserts the
second flips from "another tab has this" to "your data is ready".
@pal-tamas
pal-tamas force-pushed the worktree-browser-sqlite-persist branch from e37685c to ff8ee98 Compare August 10, 2026 04:44
@pal-tamas
pal-tamas merged commit 863d654 into main Aug 10, 2026
10 checks passed
@pal-tamas
pal-tamas deleted the worktree-browser-sqlite-persist branch August 10, 2026 05:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant