Overview & Philosophy
ln-data-store is applied to a hidden element via data-ln-data-store id="name" to provide client-side IndexedDB persistence with automatic schema indexing and optional AES-GCM encryption. It is completely decoupled from the network: responding to request events (ln-data-store:request-query, request-save, request-delete) against local records and broadcasting ln-data-store:stale when cached data expires, allowing applications to function offline and render cached data instantly.
In ln-ashlar, this local-first cache layer decouples UI responsiveness from network latency, treating IndexedDB as the immediate source of truth for presentation components.
The Local-First Doctrine
A local-first store never forces the UI to await network requests. Write actions are committed locally to IndexedDB instantly and dispatched to the interface, while background queues (with exponential backoff and version conflict resolution) sync mutations with the server. The local IndexedDB cache is the single source of truth for the UI.
Three Orthogonal Layers of the Data Pipeline
- The Cache Storage (IndexedDB): A native, highly-performant browser database capable of storing megabytes of structured JSON data. It supports indexing, fast text-based search queries, and stays persisted fully across app restarts and offline sessions.
- The Sync Engine (Delta Protocol): A background service that communicates with server API endpoints. It tracks timestamps, fetches only records changed since the last sync (delta arrays), processes conflict resolutions, and maintains outbox write queues.
-
The Presentation Layer (UI Elements): Data tables (
ln-table) and select controls render data directly from local IndexedDB queries. They listen to bubbled store events (likeln-data-store:created,ln-data-store:updated) to update lists in real-time.
How the Data Flow Coherence Works
When a record is created or mutated by the user:
- The coordinator claims the form submit and dispatches a local write event (e.g.
ln-data-store:request-create) with the form payload and a temporary ID. - The store saves the record locally and instantly dispatches
ln-data-store:created. Sibling views receive the event and display the new record immediately. - In the background, the coordinator pushes the network request via the connector or outbox queue. On server confirmation, the coordinator dispatches an ordinary
ln-data-store:request-updateto rekey the temporary ID to the server ID (emittingln-data-store:updated). On a 4xx create rejection, it dispatchesln-data-store:request-delete(emittingln-data-store:deleted).
For a detailed breakdown of local-first patterns, see the Data Flow Architecture, the ln-data-store README, and the architecture reference.
Attributes
| Attribute | Required | Description |
|---|---|---|
data-ln-data-store | yes | Store name (IndexedDB object store name, event namespace) |
data-ln-data-store-stale | no | Staleness threshold in seconds (default: 300). "0" = always stale (syncs on every mount). "-1" or "never" = never auto-sync (only forceSync()) |
data-ln-data-store-indexes | no | Comma-separated fields to index |
data-ln-data-store-search-fields | no | Comma-separated fields for text search |
Live Demo — Mock Store
This demo simulates ln-store behavior using an in-memory mock (no real IndexedDB or server). The event log shows the lifecycle.
Store State
| Property | Value |
|---|---|
isLoaded | false |
totalCount | 0 |
isSyncing | false |
Records
Event Log
For a live CRUD wiring example with table + create/edit modal + 409-conflict handling, see Store — Usecase.
Events — Commands (dispatched TO the store)
| Event | detail | Description |
|---|---|---|
ln-data-store:request-create | { data } | Create record (optimistic with temp ID) |
ln-data-store:request-update | { id, data, expected_version } | Update record (optimistic, conflict detection) |
ln-data-store:request-delete | { id } | Delete record (optimistic) |
ln-data-store:request-bulk-delete | { ids } | Delete multiple records (optimistic) |
Events — Notifications (emitted BY the store)
Data Events
| Event | When | detail |
|---|---|---|
ln-data-store:ready | Data available | { store, count, source } |
ln-data-store:loaded | Initial full load | { store, count } |
ln-data-store:synced | Delta sync complete | { store, added, deleted, changed: bool } |
Mutation Events
| Event | When | detail |
|---|---|---|
ln-data-store:created | Optimistic create | { store, record, tempId } |
ln-data-store:updated | Optimistic update | { store, record, previous } |
ln-data-store:deleted | Optimistic delete | { store, id } or { store, ids } |
ln-data-store:confirmed | Server confirmed | { store, record, action } |
ln-data-store:reverted | Mutation failed | { store, record, action, error } |
Error Events
| Event | When | detail |
|---|---|---|
ln-data-store:error | Initial load failed | { store, action, error, status } |
ln-data-store:offline | Server unreachable | { store } |
Sync Lifecycle
Init:
1. Open IndexedDB → read _meta
2. Schema mismatch? → clear, full load
3. Has cache? → emit ready (source: 'cache'), delta sync if stale
4. No cache? → full load → emit loaded, ready (source: 'server')
Visibility change (tab returns):
→ delta sync all stores if stale
Delta sync:
1. GET {endpoint}?since={last_synced_at}
2. Upsert data[], delete deleted[]
3. Emit synced
Optimistic Mutation Flow
Create: insert temp → POST → confirmed | reverted
Update: save prev, update → PUT → confirmed | conflict | reverted
Delete: save, remove → DELETE → confirmed | reverted
Bulk: save all, remove → DELETE /bulk-delete → confirmed | reverted
API
const storeEl = document.getElementById('documents');
// Queries (all return Promise)
storeEl.lnDataStore.getAll(options) // → { data, total, filtered }
storeEl.lnDataStore.getById(id) // → Object|null
storeEl.lnDataStore.count(filters) // → Number
storeEl.lnDataStore.aggregate(field, fn) // → Number (sum, avg, count)
// State
storeEl.lnDataStore.isLoaded // Boolean
storeEl.lnDataStore.isSyncing // Boolean
storeEl.lnDataStore.lastSyncedAt // Number|null
storeEl.lnDataStore.totalCount // Number
// Manual triggers
storeEl.lnDataStore.forceSync() // delta sync
storeEl.lnDataStore.fullReload() // clear + full reload
storeEl.lnDataStore.destroy() // cleanup
// Global
window.lnDataStore.clearAll() // wipe all stores (logout)
window.lnDataStore.init(el) // manual init
Query Options
storeEl.lnDataStore.getAll({
sort: { field: 'title', direction: 'asc' },
filters: { status: ['approved', 'draft'] },
search: 'ISO 27001',
offset: 0,
limit: 100
})
// → { data: [...], total: 1247, filtered: 45 }
Server Response Format
// Full load / Delta sync
{
"data": [{ "id": 42, "title": "...", "updated_at": 1736952600 }],
"deleted": [17, 23], // delta only
"synced_at": 1736953500 // next ?since= value
}
Reconnect auto-resync
When the browser goes back online, ln-data-store automatically triggers a remote sync for all loaded stores — default on, no wiring required. The isSyncing guard makes it idempotent alongside manual forceSync() calls.
Opt out per store: <div data-ln-data-store id="chat" data-ln-data-store-no-autosync></div>
Connectivity indicator (listens to ln-data-store:online / ln-data-store:offline on document):
Online — resync triggered.
Offline — cached data still available.
Toggle your network connection to see the indicator update.