@braidlabs/data
Framework-neutral, persistence-first client data layer with version-skew tolerance, partition isolation, and cross-realm distributed locks.
IndexedDB is the primary source of truth, and in-memory state is treated as a derived view. Every stored record is wrapped in a @braidlabs/skew envelope and projected to the current reader's version on read.
Installation
npm install @braidlabs/data @braidlabs/skewKey Architecture Concepts
- Addressable Record Store: Avoids read-modify-write races by storing individual addressable records per collection, rather than large monolithic blobs per key.
- Tenant & Partition Scoping: Records are partitioned (e.g.
guest,tenant_123,user_456), preventing data leaks across tenant or login boundaries. - Projection on Read: A record outlives the build that authored it. When read, the record is automatically migrated forward to the reader's declared schema version.
- Cross-Context Distributed Locks: Implements
navigator.locksacross multiple iframe realms and browser tabs with reliable fallback.
Usage
Creating a Record Store
import { createRecordStore, indexedDbRecordDriver } from '@braidlabs/data';
import { UserSchema } from './user.schema';
const users = createRecordStore({
driver: indexedDbRecordDriver({ database: 'my-app-db', collections: ['users'] }),
collection: 'users',
schema: UserSchema,
});
// Writing a versioned record into a partition
await users.put({
id: 'user_1',
partition: 'tenant_acme',
value: {
id: 'user_1',
firstName: 'Grace',
lastName: 'Hopper',
email: 'grace@navy.mil',
verified: true,
},
});
// Reading with automatic schema projection
const user = await users.get('user_1', 'tenant_acme');
if (user) {
console.log('Projected user:', user.value.firstName);
}Channels: state, events, and late joiners
createEventBus gives each channel two halves, because state is not an event:
const bus = createEventBus({ consumer: 'blotter' });
const selection = bus.channel<{ id: string }>('selection'); // scope: 'page' by default
selection.broadcast({ id: 'row-1' }); // state: last value wins
selection.subscribe((row) => highlight(row)); // called at once with the current value
await selection.emit('row.opened', { id: 'row-1' }); // an event: not replayed- State replays to late subscribers.
subscribeis called at once with the current value, as core's context bus does. - A context that joins late is caught up. A fragment mounted on demand, or a new tab on an
'origin'channel, asks the others for the current state the first time it reads the channel. The context that published it answers. If that context is gone, whichever other holder answers first does. The answer arrives asynchronously:subscribefires when it lands, andstate()returnsundefineduntil then. A channel used only for events never asks. - Events do not replay. A late listener does not see an
emitthat happened before it subscribed, which is the safe default for anything with a verb. For an event a consumer must not miss, useemit(type, payload, { delivery: 'at-least-once' })with adriverand theconsumerslist: each consumer gets its own durable queue, delivered when it next mounts. - Scope is how far it reaches.
'page'reaches every same-origin realm on this page, and no other tab.'origin'reaches every tab too. Each fragment runs in its own realm, a separate JavaScript context, so both travel over aBroadcastChannel.- The page is the highest same-origin document above the bus: the composed page, even when the app is embedded in another site.
- It is named by when that document started. If browsers coarsen that time (Firefox's fingerprinting resistance rounds it to 100 ms), two tabs opened in the same instant could share page scope. A host that must rule this out passes its own
createEventBus({ page }). - Cross-origin
trust="untrusted"fragments are not reached.
- Values are cloned. Each subscriber, handler and other context gets its own structured clone, so nobody can change what another sees. A value that cannot be cloned (one holding a function, say) is not delivered, and the failure is reported to
onEventErrorrather than thrown frombroadcastoremit.
State that survives a reload
persist: true keeps a channel's state in the bus's driver, for example IndexedDB:
const driver = indexedDbRecordDriver({ database: 'app', collections: [...EVENT_BUS_COLLECTIONS] });
const bus = createEventBus({ consumer: 'blotter', driver });
const selection = bus.channel('selection', { persist: true });
selection.subscribe((row) => highlight(row)); // after a reload: the row selected before it- What's stored: the last value this context broadcast, per partition. When a contract is registered for the channel (
bus.register), it's stored as a versioned envelope, so a newer or older build reads it at its own version.broadcast(undefined)clears it. - Freshness: a stored value is a fallback. A value broadcast since the reload wins over it, and so does a fresher value another context holds.
- Timing: restoring is asynchronous.
subscribefires when the value is read, andstate()isundefineduntil then. - Without a
driver:persistthrows rather than quietly forgetting. An IndexedDB driver must listEVENT_BUS_COLLECTIONSamong its collections, as durable events need too. - Sign-out purges it only if told to. Stored state can identify what someone was looking at. List the same collections in
createTenancy({ collections: [...yours, ...EVENT_BUS_COLLECTIONS] })sosignOut()removes them with everything else. - Register contracts first. Call
bus.register(name, contract)before opening a persisted channel. A value stored under a contract that this build hasn't registered is reported and not handed over, because its shape could belong to another version. - Open it the same way everywhere. A channel's options are fixed by its first
channel()call in a context. Opening it again with a differentpersistorscopethrows. - Failures are reported, not thrown. A value that can't be stored (not cloneable, no partition active after sign-out, a failing driver) still goes out as a broadcast, and the failure goes to
onEventError.
Intents across fragments
addIntentListener and raiseIntent mean "somebody on this page, handle this". With a page (see scope above), a handler registered in one fragment's realm is a candidate for a raise in another's:
// in the billing fragment
bus.addIntentListener('ViewInvoice', { handler: async ({ id }) => openInvoice(id), priority: 1 });
// in the blotter fragment, a separate realm
const { handled } = await bus.raiseIntent('ViewInvoice', { id: 'o1' });- One ranking. Handlers here and in other realms are ranked together. The
'first','all','ask'and custom policies, andcandidates(), see one list. Each candidate from another realm carries asource. - Versions are the raiser's call. Whether a handler can read the payload, and the projection to the version it declared with
as, are decided where the intent is raised, so register the intent's contract (bus.register) in every fragment that raises it. A handler in another realm that declaresasisn't offered to a raiser with no contract to project with. An older fragment never receives a shape it can't read, and never runs migration code. - Results and errors come back. A handler's return value resolves the raise, and a thrown error rejects it. Both cross as structured clones: a result can't hold functions, and an error arrives as its message, rebuilt as
NoIntentHandlerErrorwhen it was one. A local handler's result is returned as is. - A realm that goes away stops being offered. A bus that closes, or whose page is hidden or unloaded (including into the back-forward cache, which says hello again on return), leaves the directory, and raises waiting on it reject immediately. A realm that dies without a word (a crashed or frozen frame) is dropped the first time a call to it times out, after
intentTimeoutMs, which defaults to 30 seconds. - Discovery is asynchronous. Each bus learns about the others' handlers by message. A raise that finds nobody in a bus's first 100 ms waits for the directory once before giving up, so a fragment can raise as it mounts.
- Page scope only. Another tab's handlers are not candidates, subject to the page-identity caveat above; pass
pageto rule it out. Without a page (outside a browser, unlesspageis passed), intents stay within the bus. - The page's same-origin realms trust each other. Any same-origin script can write to the page's channels, so a call to a handler in another realm is code running on that realm's behalf. Malformed messages are dropped and an answer is only taken from the realm the call went to, but this is not a security boundary: put untrusted code in a
trust="untrusted"fragment, which can't reach these channels. - One partition per bus. A bus joins its partition's channels when it is created. After a tenant switch, create a new bus for the new partition rather than reusing the old one.
Cross-Context Distributed Locks
Coordinate actions (such as draining the mutation outbox or flushing cache) across multiple tabs and iframe realms without race conditions:
import { withLock } from '@braidlabs/data';
await withLock('flush-outbox:tenant_acme', async () => {
// Only one tab or realm executes this block at a time
await flushPendingMutations();
});API Reference
createRecordStore(options: RecordStoreOptions): RecordStore
| Method | Signature | Description |
|---|---|---|
put | (collection, key, schema, value, owner?) => Promise<void> | Envelopes and stores a versioned record. |
get | (collection, key, schema) => Promise<T | null> | Reads and projects record to target schema version. |
delete | (collection, key) => Promise<void> | Removes a record by key. |
list | (collection, schema) => Promise<T[]> | Lists all records in the partition, ordered by sequence. |
clearPartition | (collection) => Promise<void> | Clears all records for current partition (e.g. on logout). |
Storage Drivers
indexedDbRecordDriver(options: IndexedDbDriverOptions): Production persistent IndexedDB driver.memoryRecordDriver(): In-memory driver for unit testing and ephemeral sessions.