Browser-local databases (localdb)

core-ui/localdb declares IndexedDB databases in Go. The data lives in the visitor's browser: no server table, no request per write. The localdb runtime module reads the declarations and gives page scripts a small API over them: read and write records, list them in index order, run several writes as one transaction, and hear about changes from this tab and every other tab of the site.

Use it for state that belongs to one visitor's browser and must outlive the tab: a draft, a saved list, settings, a game save. It survives reloads, closed tabs and restarts. For records you want rendered as forms and lists without writing JavaScript, use local entities, which are built on this package.

Declare a database

Declare databases at package level, before the UI host serves its first page. Each store keys its records by id unless you say otherwise.

26 lines
package main

import "github.com/DonaldMurillo/gofastr/core-ui/localdb"

var (
	Pokedex = localdb.New("pokedex")

	// Records keyed by "id"; AutoKey mints a UUIDv7 when a record has none.
	Members = Pokedex.Store("members",
		localdb.AutoKey(),
		localdb.Index("by_level", "level"),
		localdb.Index("by_team_slot", "team", "slot"), // compound
		localdb.MultiEntryIndex("by_type", "types"),   // one entry per array element
	)

	// Records keyed by their own "slug"; a duplicate label is refused.
	Tags = Pokedex.Store("tags",
		localdb.KeyPath("slug"),
		localdb.UniqueIndex("by_label", "label"),
	)
)

func main() {
	_ = Members
	_ = Tags
}

Names (database, store, index) are 1-64 lowercase letters, digits, - or _. Key paths are dotted ASCII identifiers (meta.slug); __proto__, constructor and prototype are refused. A bad declaration panics at startup. The browser stores the database as gofastr.<name>, so it never collides with one your own scripts open.

framework/uihost puts the declarations in every page head as an inert <script type="application/json" id="gofastr-localdb"> block, on live pages and in static export. There is nothing to mount.

The browser module ships with this package, not with the core runtime. An app that never imports core-ui/localdb contains none of it, and a page downloads it only when something calls __gofastr.loadModule('localdb').

Use it from a page script

Load the module, open the database, call it. Every call returns a promise.

13 lines
await __gofastr.loadModule('localdb');
const db = await __gofastr.localdb.open('pokedex');

const id = await db.put('members', { name: 'Pikachu', level: 30, types: ['electric'] });
const pika = await db.get('members', id);

// Highest level first, six at most: IndexedDB does the ordering.
const top = await db.list('members', { index: 'by_level', direction: 'prev', limit: 6 });

// Levels 20 to 40, inclusive.
const mid = await db.list('members', { index: 'by_level', lower: 20, upper: 40 });

await db.delete('members', id);
MethodDoes
get(store, key)The record, or undefined.
put(store, record)Insert or replace. Resolves the key.
add(store, record)Insert only; an existing key rejects constraint.
delete(store, key) / clear(store)Remove one record / all of them.
count(store, query?)How many records match.
list(store, query?)Records in key order, or in query.index order.
tx(stores, mode, fn)Several operations as one transaction.
watch(store, fn)Change notifications. Returns the unsubscribe function.

A query takes index, then either only or lower/upper (with lowerOpen/upperOpen for exclusive bounds), plus direction (next, prev, nextunique, prevunique), offset, limit, and keys: true to get primary keys instead of records.

Each single call is its own transaction and resolves once the write is committed to disk, not before.

Transactions

tx runs a function against several stores at once. If the function throws, nothing it wrote is kept.

4 lines
await db.tx(['members', 'tags'], 'readwrite', async (t) => {
  await t.put('members', { name: 'Eevee', level: 5 });
  await t.put('tags', { slug: 'normal', label: 'Normal' });
});

Only await t's own methods inside the function. IndexedDB commits a transaction as soon as it has no request pending, so awaiting a fetch or a timer in the middle ends it early: whatever was written before lands, the next t.put cannot, and tx rejects with aborted instead of resolving.

Watching for changes

5 lines
const stop = db.watch('members', (e) => {
  // e.origin is "local" (this tab) or "remote" (another tab).
  // e.changes is [{op: "put" | "add" | "delete" | "clear", key}].
  refreshTeam();
});

The watcher runs after the transaction commits. Other tabs hear about the change through a BroadcastChannel that carries store names, ops and keys only, never record values; the other tab reads the record from the database the tabs share. A tab announces its writes whether or not it watches anything itself, and a large transaction is sent in slices of 1000 changes. A message with an unknown op or the wrong shape is ignored; a change to a store this tab does not declare (one a newer deploy added) is skipped while the rest of the message is delivered.

Schema changes

There is no version number to keep. When a page opens a database whose stored schema is missing a declared store or index, the module raises the IndexedDB version and creates it.

Nothing is ever deleted. A tab still open on the previous deploy may need the store or index the new deploy removed, and a page from an older deploy opening a newer database finds everything it declares. Unused stores and indexes stay until the visitor clears site data.

Changing a store's key path, or an existing index's key path or options, is not done in place. Opening such a store rejects with code schema. A key path change would need every record re-keyed; an index rebuilt in place would be rebuilt back by any tab still on the previous deploy, each tab's upgrade closing the other's connection. Declare the new shape under a new name (a new store, copying records over in a page script, or a new index name, which needs no copying).

When another tab upgrades the database, this tab's open connection closes and the next call reopens it. document gets a gofastr:localdb event (detail: {type, db}, where type is versionchange, close or blocked) if a page wants to show it.

Keeping the data

Browsers can delete site data when the disk is short. Ask them not to:

2 lines
const kept = await __gofastr.localdb.persist();     // true if granted
const usage = await __gofastr.localdb.estimate();  // {usage, quota} in bytes, or null

Each browser decides differently. Chromium grants it to sites the visitor uses often, Firefox may ask the visitor, and Safari grants it to sites added to the home screen. false is an answer, not an error. Data the visitor cannot afford to lose belongs on a server too.

Errors

Every rejection is an Error with name: "LocalDBError" and a code:

CodeMeaning
unsupportedThe browser has no IndexedDB.
unknown-db, unknown-store, unknown-indexNot declared in Go.
invalidA record that is not a plain object, a bad key, or a bad query.
constraintA unique index or add refused a duplicate.
quotaThe browser is out of storage for this site.
schemaA store's key path changed, or the schema could not be applied.
version, closed, aborted, failedIndexedDB refused for another reason.

The browser's own error message is dropped, so a record's contents never end up in an error string. The module never writes to the console.

What it is not

  • Not shared between visitors or devices. Each browser has its own copy.
  • Not private from the visitor. Anyone with devtools can read and edit it, so treat every record you read back as untrusted input.
  • Not a cache of server data. Server rows stay on the server and reach the page through RPC; this is for data that has no server copy.

Common mistakes

  • Awaiting other work inside tx. IndexedDB commits a transaction once no request is pending. A fetch or a timer awaited inside the function ends it: the next t.put fails and tx rejects with aborted, though what was written before the await has landed. Do the other work before or after.
  • Treating a read-back record as trusted. The visitor (or any script on your origin) can change it. Put values into the page with textContent, never innerHTML, and check types before using them.
  • Changing a store's key path or an index's definition in place. It rejects schema. Declare a new store (and copy the records across in a page script) or a new index name.
  • Storing the only copy of something important. Browsers may delete site data, visitors clear it, and it never leaves the device. Call persist(), and keep a server copy of anything that matters.
  • Declaring a database inside a function. localdb.New belongs at package level; a second call with the same name panics, and a declaration made after the first page render is missing from that page's manifest.