sembast_web: sembast on IndexedDB
package:sembast_web provides databaseFactoryWeb, a DatabaseFactory
storing each sembast database in one IndexedDB database. It works with
dart:js_interop (JavaScript and WASM builds). The data API is the regular
package:sembast API; only the factory changes.
import 'package:sembast_web/sembast_web.dart'; // re-exports sembast.dart
final store = intMapStoreFactory.store('product');
Future<void> demo() async {
// The path is an IndexedDB database name, not a file path.
var db = await databaseFactoryWeb.openDatabase('my_app.db');
var key = await store.add(db, {'name': 'Table', 'price': 15});
print(await store.record(key).get(db));
await db.close();
}
Guidelines
Dependencies and imports
- Add
sembast_webnext tosembastinpubspec.yaml. Importpackage:sembast_web/sembast_web.dart; it re-exportspackage:sembast/sembast.dartsoStoreRef,Finder, etc. are available. - Exported factories:
databaseFactoryWeb(page/main thread) anddatabaseFactoryWebWorker(inside a web worker).sembast_web_html.dartonly holds a deprecateddatabaseFactoryWebalias; do not use it. - The import compiles on the VM and in Flutter mobile/desktop builds, but
the getters throw
UnimplementedErrorthere. Select the factory with a conditional import when the app also targets io (example below), or usetekartik_app_flutter_sembast'sgetDatabaseFactory()which does it.
Database name and location
openDatabase(name)takes an IndexedDB name scoped to the origin (scheme + host + port).localhost:8080andlocalhost:8081are different origins: use a fixed--web-portwhile debugging or the data "disappears".- Directory-like names (
'data/my.db') are allowed; they are just names.factory.sandbox(path: 'prefix')from sembast prefixes them. There is no file, nopath_provider, no directory creation. deleteDatabase(name)anddatabaseExists(name)work. Close the database before deleting it.- Storage is per browser profile and subject to the browser's IndexedDB quota and eviction; treat it as a cache unless persistence is granted. Keep it small (guideline: below ~30K records).
Cross-tab behavior
- Every tab or worker that opens the same database keeps its own in-memory
copy. Writes go to IndexedDB in a journal with a revision number; other
tabs are notified through a
BroadcastChanneland load only the new records, soonSnapshot/onSnapshotslisteners fire in every tab. - Transactions are cross-tab safe: if another tab committed since the transaction started, the local data is refreshed and the transaction callback runs again. Transaction bodies must therefore be idempotent and free of side effects (no UI updates, no network calls, no incrementing outside variables inside the callback). Compute results from the data read inside the callback.
await db.checkForChanges()forces an immediate refresh from IndexedDB (normally not needed; notifications handle it).db.compact()purges the journal history.
Data and API limitations
- Keys:
intorStringonly, as in sembast. Auto-increment int keys and generated string keys work. - Values: the sembast types (
String,num,bool,null,Map<String, Object?>,List<Object?>,Timestamp,Blob).DateTimeandUint8Listare still rejected: useTimestampandBlob. - When compiled to JavaScript (not WASM) an
intis a JS number: values and keys above 2^53 lose precision. Store 64-bit ids asString. Timestampround-trips at microsecond precision on the VM but the platformDateTimeonly has millisecond precision on JavaScript; do not rely on sub-millisecond values in web code.codec:is accepted (sync codecs andAsyncContentCodecBase) and the signature is checked, but the README states codecs are not supported on the web and browser-side encryption gives no real protection: keep secrets off the client or encrypt individual fields yourself.- Everything else (queries, listeners, triggers, import/export,
onVersionChangedmigrations) behaves as in sembast.
Testing
- Unit tests that must run on the VM: use
newDatabaseFactoryMemory()frompackage:sembast/sembast_memory.dart, ordatabaseFactoryMemoryJdbfrompackage:sembast/utils/jdb.dartto reproduce the journal semantics (re-run transactions) without a browser. - Browser tests:
dart test -p chromewithdatabaseFactoryWeb; delete the database at the start of each test.
Examples
Factory selection with a conditional import
lib/db/db_factory.dart:
import 'package:sembast/sembast.dart';
import 'db_factory_stub.dart'
if (dart.library.js_interop) 'db_factory_web.dart'
if (dart.library.io) 'db_factory_io.dart' as impl;
/// The database factory for the current platform.
DatabaseFactory get appDatabaseFactory => impl.appDatabaseFactory;
/// Resolves the database path or name for the current platform.
Future<String> appDatabasePath(String name) => impl.appDatabasePath(name);
lib/db/db_factory_web.dart:
import 'package:sembast_web/sembast_web.dart';
DatabaseFactory get appDatabaseFactory => databaseFactoryWeb;
// IndexedDB name: nothing to resolve.
Future<String> appDatabasePath(String name) async => name;
lib/db/db_factory_io.dart (Flutter, with path_provider):
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sembast/sembast_io.dart';
DatabaseFactory get appDatabaseFactory => databaseFactoryIo;
Future<String> appDatabasePath(String name) async {
var dir = await getApplicationDocumentsDirectory();
await dir.create(recursive: true);
return join(dir.path, name);
}
lib/db/db_factory_stub.dart:
import 'package:sembast/sembast.dart';
DatabaseFactory get appDatabaseFactory =>
throw UnsupportedError('No sembast factory for this platform');
Future<String> appDatabasePath(String name) =>
throw UnsupportedError('No sembast factory for this platform');
Usage, identical on every platform:
import 'package:sembast/sembast.dart';
import 'db/db_factory.dart';
Future<Database> openAppDatabase() async {
var path = await appDatabasePath('my_app.db');
return appDatabaseFactory.openDatabase(path, version: 1);
}
Idempotent transaction (safe when re-run after a concurrent write)
import 'package:sembast_web/sembast_web.dart';
final counters = StoreRef<String, int>('counters');
/// Increments atomically even with several tabs; the body may run twice.
Future<int> nextSequence(Database db, String name) {
return db.transaction((txn) async {
var record = counters.record(name);
var value = (await record.get(txn) ?? 0) + 1;
await record.put(txn, value);
return value; // Derived from data read inside the transaction only.
});
}
Listening across tabs
import 'dart:async';
import 'package:sembast_web/sembast_web.dart';
final notes = stringMapStoreFactory.store('note');
StreamSubscription<List<RecordSnapshot<String, Map<String, Object?>>>>
watchNotes(Database db) {
// Also fires when another tab adds, updates or deletes a note.
return notes
.query(finder: Finder(sortOrders: [SortOrder('updatedAt', false)]))
.onSnapshots(db)
.listen((snapshots) => print('${snapshots.length} notes'));
}
VM test reproducing journal (re-run) semantics
import 'package:sembast/sembast.dart';
import 'package:sembast/utils/jdb.dart';
import 'package:test/test.dart';
void main() {
test('idempotent transaction', () async {
var factory = databaseFactoryMemoryJdb;
await factory.deleteDatabase('test');
var db = await factory.openDatabase('test');
var record = StoreRef<String, int>.main().record('count');
await db.transaction((txn) async {
await record.put(txn, (await record.get(txn) ?? 0) + 1);
});
expect(await record.get(db), 1);
await db.close();
});
}
Common mistakes
- Using
databaseFactoryIoin a web build (UnimplementedError) ordatabaseFactoryWebin a mobile build; use the conditional import. - Treating the database name as a file path (
getApplicationDocumentsDirectorydoes not exist on the web). - Running non-idempotent code inside
db.transaction(network calls, counters in closures): it can execute twice on the web. - Debugging Flutter web on a random port and losing the IndexedDB data.
- Storing 64-bit integers or
DateTimevalues; useStringids andTimestamp. - Relying on browser storage as the only copy of important data.