IndexedDB Persistence
IndexedDbContext is Oidis' frontend database driver — the only one. Models declared with @Entity whose context resolves to IndexedDbContext are persisted into the browser's IndexedDB. The backend uses MongoDB through a different BaseDbContext implementation; this page is strictly about the frontend path.
Source: io-oidis-commons/source/typescript/Io/Oidis/Commons/DAO/IndexedDbContext.ts.
Architecture Position
| Class | Role | Location |
|---|---|---|
BaseDbDriver (abstract) | Base contract for DB drivers | io-oidis-commons/.../DAO/BaseDbContext.ts |
BaseDbContext (abstract) | Base contract for per-model DB contexts | io-oidis-commons/.../DAO/BaseDbContext.ts |
IndexedDbDriver | Concrete frontend driver | io-oidis-commons/.../DAO/IndexedDbContext.ts |
IndexedDbContext | Concrete frontend context | io-oidis-commons/.../DAO/IndexedDbContext.ts |
| Mongo driver / context | Concrete backend implementation | io-oidis-hub |
Loader instantiates IndexedDbDriver at startup when running in the browser and calls Open({dbName, timestamp}). dbName defaults to the project name; the version is the build timestamp from getProjectConfig().build.timestamp. A failed open is delegated to the protected Loader.onDbOpenFailed($error?) hook (see Handling Open Failures).
How Models Become Object Stores
When the IndexedDB version is greater than what the browser holds, the driver runs an onupgradeneeded upgrade transaction that registers all eligible models:
- Reflection enumerates every class implementing
IModel, skipping theBaseModelbase classes from commons, hub-gui, and hub. - A model is included only if
instance.getContext()resolves toIndexedDbContext— i.e., the model has explicitly opted into local persistence. - For each included model, the driver creates an object store named after
instance.getSymbolicName(), withkeyPath: "_id"andautoIncrement: true. - For each
@Property, a non-unique index is created on thesymbolfield. - Existing stores and indexes are preserved — the upgrade only adds what is missing. There is no rename, no migration of data shape, no drop.
This means a model with a renamed or removed @Property will leave the old index in place across upgrades. To clear stale schema, the IndexedDB database needs to be deleted manually (or the version bumped beyond the current state via developer tools).
Open Lifecycle and Versioning
The version passed to indexedDB.open() is the project's build timestamp. Every release with a fresh build produces a higher version, so every deploy is implicitly an upgrade event for any user with a previous DB.
The full set of behaviours Open() can produce:
| Situation | What happens |
|---|---|
| Open at a lower version than the existing DB | IDBOpenDBRequest.onerror fires with VersionError. The driver logs the error at Debug level only, leaves isOpen = false, and resolves the Promise to false — no exception is thrown. |
| Open at the same version | onsuccess fires immediately. No upgrade transaction. |
| Open at a higher version | onupgradeneeded fires first (object stores and indexes are reconciled by reflection), then onsuccess. |
| Another tab still holds an older version | onblocked fires. The driver logs LogIt.Warning("DB transaction is blocked - waiting for end of unfinished job, maybe in another tab") and waits indefinitely — there is no timeout. The Open() Promise does not settle until the other tab closes the DB or the user reloads. |
| Version changes after a successful open (another tab triggered an upgrade) | The driver registers onversionchange on the connection. When fired, it calls Close(), logs LogIt.Debug("Database is outdated please reload context"), and reconnects only if $autoReconnect = true was passed to the original Open(). |
Synchronous throw during setup (e.g., indexedDB unavailable) | The try/catch logs the error, sets isOpen = false, and resolves the Promise to false. |
On any error path, Open() resolves to Promise<false> instead of rejecting. Callers that await without checking the boolean will continue past the failure and hit cryptic errors when subsequent getContextFor / Find / Save calls run against a closed driver. Always branch on the return value.
onblocked has no timeoutIf a stale tab keeps the previous version of the DB open, Open() never settles. Surface this state in the UI (a "another tab is using an older version, please close it" message) instead of waiting forever.
Handling Open Failures
Loader.onEnvironmentLoad() opens the database at startup and routes any failure - Open() resolving to false or throwing - into the protected onDbOpenFailed($error?) hook. The default implementation logs at Debug level only, keeping the failure out of Sentry (SentryAdapter forwards only WARNING and ERROR traces). This is intentional: a deploy-induced VersionError resolves itself once the server-side update detection reloads the page.
Applications where local persistence is critical infrastructure should override the hook in their Loader subclass:
protected onDbOpenFailed($error? : Error) : void {
// WARNING/ERROR levels are forwarded to Sentry; alternatively force a reload here
LogIt.Error("Can not connect to FE database: " +
(ObjectValidator.IsEmptyOrNull($error) ? "open failed" : $error.message));
}
Cross-Tab Effects After a Deploy
Because the version is the build timestamp, a typical post-deploy sequence looks like this:
- User has tab A open from build
T1. IndexedDB is at versionT1. - User opens tab B after a deploy. Tab B calls
Open({dbName, T2})whereT2 > T1. - Tab B's
Open()triggersonversionchangein tab A. - In tab A, the handler closes the connection and logs the "Database is outdated please reload context" debug message. Without
$autoReconnect = true, the application in tab A now has a closed driver and any further DAO call will fail. - Tab B finishes its upgrade transaction and proceeds normally.
Pass $autoReconnect = true to Open() if a transparent reconnect is acceptable, or otherwise plan the UX around an explicit reload prompt.
Operations Summary
IndexedDbContext covers the same surface as BaseDbContext, but with browser-specific semantics. Each method is asserted as frontend-only via frontendOnlyAssert().
| Method | Behaviour |
|---|---|
FindById($id) | Wraps Find($id) and returns the first hit. Logs a warning if more than one document matches. |
Find($query, $options?) | Cursor-based scan. $query may be omitted/empty (full scan), a string id, or an object with at most one key (uses the matching index). Multi-key queries log a warning and only the first key is honoured — for compound filtering, fetch a wider set and use native JS .filter(). Supports limit, offset, and sort ("next", "prev", "nextunique", "prevunique"). |
Save() | Updates lastModified then calls objectStore.put() with the model's document. |
Update($property) | Runs the property's converter (if reference / IModel) into the document, then Save(). |
Refresh($options?) | Re-reads the current model's record by Id and replays fromModel to repopulate the instance. |
Remove($property?, $child?) | Without arguments: hard delete by _id. With $property: removes that field from the document and persists. With $property and $child: removes a nested key under that property. |
Aggregate($chain, $options?) | Sequentially runs Find for each query in $chain and concatenates results. Not a real aggregation pipeline. |
Remove() is a hard delete hereUnlike the MongoDB-backed BaseModel semantics described in Models Overview (where Delete() is a soft delete and Remove() is the hard counterpart), IndexedDbContext exposes only the hard-delete path via Remove(). There is no soft-delete equivalent on this driver.
Gotchas
- Silent failures.
Open()resolves tofalseon every error. Always check the result. Default logging is Debug-level only - overrideLoader.onDbOpenFailedto escalate. onblockednever times out. Detect blocked state explicitly and prompt the user to close other tabs.$autoReconnectisfalseby default. Without it, an upgrade in another tab leaves this tab with a closed driver.- Build-driven versioning. Every deploy is an upgrade event for active users. Plan reload UX accordingly.
Findaccepts at most one query key. Compound queries needFind({})plus a JS.filter()post-step.- No data migration on upgrade. Renamed or removed
@Propertyfields leave their index entries and document keys untouched. If a schema rename matters, plan a one-time client-side migration step. - Hard delete only.
Remove()permanently deletes; there is no soft-delete flag on the IndexedDB path. - Stores exist only for models loaded at open time. The upgrade transaction registers the model classes already evaluated when
Loaderopens the database. Import frontend models statically from the GUI code; a lazily imported model gets no object store until the next build bumps the version. - Frontend models are registered on the backend too (Hub solutions). The backend bundle evaluates the GUI code, so every
@Entitymodel ends up inGraphQL.RegisterModels(). A@Propertytype other thanString,Number,Boolean,Date,Bufferor a model (e.g.type: Object) throwsNot supported type by GQL schema type resolverand the Hub does not start. Keep browser-only values such as aCryptoKeyon the raw document instead —model.getDocument().keyPair = pairis stored bySave()as-is (structured clone) and restored byFind().
See also: Models Overview | Backend vs Frontend | Converters | PersistenceHandler