@loro-dev/roost
v0.3.0
Published
Browser conversation history and sync for Loro Roost
Readme
Loro Roost for TypeScript
Experimental TypeScript package with IndexedDB storage, a chunked Turn state machine
and experimental v1 codec/cryptography. It supports local writes, remote import,
seal verification, pending dependencies, outbox and stream-scoped change hints.
The sync entry provides explicit Streams authentication and host payload crypto.
Lody runtime integration, device support and stable APIs remain separate
acceptance work. Wire v1 and storage layout can change. Unsealed updates are
provisional and unsigned; production E2EE is not implemented. Author
equivocation has no convergence guarantee.
The Inbox module adds atomic raw receipt/cursor storage, without authenticating
or applying those bytes.
npm install @loro-dev/[email protected]This source targets the coordinated Rust/TypeScript/Native 0.3.0 release; the registry determines npm availability. Native SQLite storage in this release accepts fresh/v10 containers only. Preserve older databases with their compatible runtime; there is no automatic migration or deletion.
For source development and browser tests:
pnpm install --frozen-lockfile
pnpm check
pnpm test
# Only one engine, when installed:
BROWSERS=chromium pnpm test
# Install test browsers if this machine does not already have them:
pnpm exec playwright install chromium webkitTests launch genuine Chromium and WebKit against a loopback-only fixture server and fresh browser contexts. There is no fake-indexeddb, external service, sleep or timing race. A test deadline only terminates failures; synchronization depends on actual request/transaction events. WebKit desktop tests are not iOS device tests.
Transaction API
import { IndexedDbStorage } from '@loro-dev/roost';
const storage = await IndexedDbStorage.open('roost-example');
const key = new Uint8Array([1]);
await storage.transact(true, function* (tx) {
const previous = yield* tx.get('projection', key);
yield* tx.put('projection', key, new Uint8Array([previous?.[0] ?? 0, 42]));
yield* tx.put('outbox', key, new Uint8Array([42]));
}); // Resolves only after IndexedDB's complete event.
await storage.close();transact(write, program) invokes the synchronous generator once, with no retry.
yield* tx.get/put/delete/scan resumes in a request event while IndexedDB permits
new requests. This low-level generator contract is deliberately different from
Rust's synchronous callback. The Store message API exposes ordinary
Promises above it. Do not await network, worker computation, timers or unrelated
Promises in a transaction. Programs must do finite synchronous computation; no execution-time or memory budget is imposed here. Empty-scan chains use an iterative driver, not recursive callbacks. Compute outside and recheck any dependent versions in
the transaction. No keepalive requests are issued.
The transaction methods use arbitrary byte keys and byte values with a string
namespace. get returns undefined for absence; put replaces; delete of an
absent key succeeds. scan(space, start, end, limit) uses byte lexicographic
order, inclusive start and exclusive end; zero limit or reversed/empty intervals
return no rows. Pass undefined for no end; the limit is always explicit. Keys and values are copied, including offset views. Inputs are
snapshotted when an operation is created, not when it is eventually yielded.
All namespaces share one private IDB object store with [namespace, binaryKey]
keys. Thus projection/oplog/outbox/cursor namespaces can commit or abort together,
without defining an object store for each namespace. This is local layout v1,
not a new Roost wire/schema version or Rust RSP1 interchange format. Readonly
transactions reject mutations. Overlapping writes from different connections are
serialized by IndexedDB, including read-modify-write programs.
A thrown exception, tx.abort() (even if caught), native request failure or
invalid yield aborts the transaction. Only opaque, single-use operations belonging
to that transaction are accepted; promises, async generators and borrowed
operations fail. Native abort rejects after rollback even when earlier requests
succeeded. A request error is fatal; the generator cannot catch and recover that
individual request. Retained transaction views cannot be used after the program.
close() immediately rejects new work and waits for already-created transactions
to complete or abort. It is idempotent; existing work is not cancelled. A
versionchange event initiates close automatically, allowing another connection
to upgrade. Version 1 must contain exactly the expected key/value object store;
unknown versions or layouts are refused without resetting user data. Blocked open
fails explicitly, and a subsequently opened connection is closed instead of leaked.
There is no migration, cross-tab broadcast, quota recovery or data deletion API.
Writes request IndexedDB's strict durability hint. complete proves the browser's
commit boundary; it is not proof against device failure, browser storage eviction,
quota exhaustion or an OS wiping this origin. Pending messages and keys still need
product-level retention and recovery policies.
References: W3C transaction lifecycle, transaction scheduling. Owner: P1-03.
Durable receipt ledger
Inbox.open(storage, stream, source, limits) shares the same IndexedDB backend.
Use receive(previousCursor, nextCursor, bytes) to commit raw data and its receive
position together. null is the initial cursor; an empty byte cursor is distinct.
Exact historical retries return duplicate without rewinding the head; stale
predecessors and conflicting cursor reuse fail. Sequences and retained-byte quotas
are bigint values; lengths and page sizes must be explicit safe integers.
state() and records(afterSequence, limit) expose committed local receipts.
Source names are routing metadata, not identity proofs. No authentication, Roost import, verification or transport is performed. Record/index bytes count towards a per-namespace logical quota; physical storage and the total number of namespaces need host policy. There is no GC or implicit reset. The browser-private layout does not share Rust inbox state records.
Experimental v1 codec
encodeBatch / decodeBatch in codec.ts implement current Rust v1 bytes.
JsonValue in json.ts uses Map for objects, explicit bigint u64/i64 integers
and an explicit f64 variant (including negative zero). Native JS numbers and
plain objects are not implicitly converted; the eventual application JSON API is
not settled. UTF8 and surrogate validation is strict, scalar-key ordering is
canonical, and decode refuses noncanonical spellings rather than repairing them.
Codec acceptance is syntax only: it does not authenticate the source, validate a
seal, apply a Turn or enforce business ownership. Browser tests build the Rust
codec_interop example and compare real Rust/TS bytes in both directions. The
fixture includes all operations and finite floating-point samples, not an
exhaustive IEEE proof. Running these tests requires the root Cargo dependencies
to have been fetched; the oracle build uses --offline --locked.
Hash, signatures and IDs
crypto.ts provides nodePreimage, nodeHash, isValidOwner and
verifySealSignature; identity.ts provides an in-memory Identity signer and
nextTurnId with explicit clock, observed maximum and random-source inputs. The
strict signature wrapper uses maintained noble primitives to match Rust dalek
verification; noble's default verifier alone has different acceptance rules.
A decodable public key is not an authorized device. A valid signature over a
claimed hash is not proof that received content matches that hash.
Fixed vectors and strict counterexamples run in both browsers. These helpers do not persist keys or authenticate transports. The Store below persists admitted observed maxima atomically with message state.
Message Store
new Store(storage, allows, {clock, randomBytes, verifyHashIntegrity?}) shares the
same IndexedDB owner as Inbox. Clock returns bigint milliseconds; randomBytes
returns 16 bytes. Both callbacks are explicit, synchronous host inputs. The
allowlist callback checks a claimed owner, not proof of authorship. The user accepts
claimed-author spoofing during streaming; per-device source authentication is not
a prerequisite for importing provisional updates. Service access controls remain.
store.stream(id) offers writer(identity), importBatch, readTurn,
readTurns, scanTurns, readValue, readText, snapshot, eventsAfter,
pendingTurns, missingDependencies, exportUpdates and exportTurns.
Writer create, edit, seal and writeBatch return committed update references.
Sequences, text positions and array positions are bigint. Text positions count
Unicode scalars. Objects and numbers use the lossless JsonValue representation
above; no native JS object/Number coercion is implied.
Local batches commit content blocks, Turn headers, operation log, pending indexes, observed ID maximum, outgoing log and events in one transaction. A failed later operation rolls back earlier operations. Import isolates semantic frame rejection, but storage failures abort the entire batch. Remote import never echoes to outbox. Hash recomputation defaults on; disabling it never disables seal signature checks. Corrupt persisted metadata is an error, not an empty or repaired Turn.
Text uses 1024-scalar blocks and arrays use 128-reference pages. Tail append and text ranges access necessary blocks; variable-length middle splices may rewrite the affected suffix. Objects retain field tables. Whole snapshots and seals may read complete content. Stored bytes/depth summaries enforce the complete Turn budget before writes, including Rust-compatible metadata overhead.
subscribeOutgoing(consumer).nextBatch(limits) returns bytes plus an opaque,
Store/stream/consumer-scoped receipt; ack(receipt) advances that consumer only.
An unacknowledged prefix survives reopen. When a durable sender has pinned a
batch, ordinary ack cannot advance past it; use sender confirmation.
watch() registers a subscription before taking its consistent snapshot/event
cursor. Hints coalesce; use the durable event log or reread state after a hint.
Hints are local to this Store, not cross-tab broadcasts. Close subscriptions when
finished. store.close() rejects new work, releases subscriptions and waits for
started transactions; it does not close the shared IndexedDB owner. After all
clients (including Inbox) finish, call storage.close() separately. Host callbacks
cannot reenter or close Store, and cannot return Promises.
Scope/evidence: projection, state machine, real SQLite/browser interop.
Durable one-step sender
new DurableSender(outgoing).sendNext(limits, async bytes => { ... }) first pins
an exact batch in IndexedDB, then invokes the host outside a transaction. A fulfilled
callback asserts that the remote endpoint durably accepted those bytes under the
host's transport contract. Only then does a transaction advance the consumer and
remove the pending pair. The helper itself cannot prove remote durability or
identity. It is not a background network loop.
After failure or reopen, the same consumer retries the original bytes even after new edits or larger limits. Limits too small for the pinned batch fail without sending. Concurrent senders may deliver duplicates; a late confirmation cannot remove a later pinned batch. Remote acceptance followed by local confirmation failure retains a safe duplicate retry.
The callback receives a copy. Visible mutation or transfer/detachment followed by
success causes a local failure, retaining the original batch. Callback throws stay
transport failures with their original cause, including non-Error JS values.
SendError.kind distinguishes local from transport; neither means an earlier
remote write was rolled back. Stop the host loop and await in-flight sendNext calls
before closing shared storage. Store.close does not wait for external transport;
confirmation after close fails locally and the pinned batch survives restart.
Evidence: browser sender Owner.
Durable replay and batch framing
DurableReplay.open(inbox, stream, limits).step() processes one complete RST1
batch receipt. Import commits first; its report and processed position commit
together afterwards. A crash between them safely repeats import. Quota/storage
failures keep the receipt for retry. Processed does not mean applied or verified.
Use the exact same storage owner and stream, and await outstanding steps before
closing storage. Original receipts and reports are retained; there is no GC.
BatchFramer in framing.ts accepts windows up to 64 KiB and returns at most one
complete batch per push, with the number of consumed bytes. It handles fragmented
and concatenated batches without changing payload bytes. Call finish only at
final EOF, not on cancellation or a temporary empty response. It is an in-memory
parser; durable tail handling is provided by DurableFraming below. The actual
Streams adapter remains unfinished.
Never pass arbitrary HTTP chunks directly to DurableReplay.
Evidence: replay, framing and remaining work.
Durable fragmented receipts
DurableFraming.open(rawInbox, batchInbox) uses two namespaces on the same storage
owner and logical stream. Call step() to process at most one 64KiB window or
receipt transition. restoring rebuilds an unfinished batch after restart;
progress and batch mean more work may remain; idle means no currently saved
raw bytes remain to scan. invalid preserves the bad tail and stops.
The completed batch and scan checkpoint commit in one transaction. Use batchInbox
with DurableReplay, never rawInbox. Original raw offsets remain opaque; derived
cursors are explicitly local receipt/byte positions. Quota or native transaction
failure leaves the checkpoint for retry. Await an active step before closing the
owner; overlapping calls on one bridge return busy. Only call finish() after
confirmed final remote EOF, repeating until end/invalid. New raw data after end
is an explicit consistency error, not silently ignored.
No background transport, automatic repair or GC is included. A restart may reread one unfinished batch; the first raw record read still loads its complete body. Implementation and real-browser evidence.
Consumer entry points and pinned SDK
Import the experimental application surface from @loro-dev/roost
and synchronization from @loro-dev/roost/sync. The business adapter is
@loro-dev/roost/lody-history. Storage keys and
internal record DTOs are not application exports. Pin the exact artifact until
the Lody consumer contract is accepted. Content uses lossless JsonValue;
ordinary JavaScript Number/object conversion is not implicit.
Link.uploadCycle runs durable discovery before flushing target work.
Executor.discoverCycle reads local Changed events, so applied P2P/server imports
with older Turn IDs are discovered after restart without a discoverTurns call.
Tasks and each target's event checkpoint commit together; target coverage remains
the confirmed lower bound of exact operations, and imported updates do not enter
the core outbox. Discovery page size bounds each page, while maxSend bounds only
sends. Partial replicas keep directed Link.bridgeTurns and its waitingParent
result for missing dependencies. See the discovery and upgrade contract.
Browser HistoryReader.fetchWindow(source, budgetBytes, pageLimitBytes, stop,
{ before, completePrefixes: false }) resumes at the previous window's complete
message boundary. This option stops at the byte budget without filtering extra
prefix-repair pages into the window range. Use explicit completePrefixes to
repair pending window turns. Persist the oldest contiguous boundary only after
intake commits, and initialize Link.catchUpAt from the original window tail,
never a later head() result. The Node window API is unchanged.
pnpm pack:consumer -- /absolute/output/directory builds the npm tarball and
checks its public entries. It depends on the exact registry release
@loro-dev/[email protected], with no bundled SDK. pnpm check:consumer
installs that tarball into a fresh project with a fresh npm cache and checks
the root, ./sync, and ./lody-history exports.
IndexedDB container version is now 2. Opening a valid version-1 database upgrades only its version, retaining all records; incompatible layouts abort the upgrade. Older version-1 openers are refused. Existing connections must close on versionchange; a blocked upgrade is an error, never a reason to delete data.
The experimental ./lody-history entry provides the shared browser/Node business
envelope. NodeLodyHistory persists business bindings, diagnostics and its processed
local event cursor through the single SQLite owner. Each page consumes at most 128
events and atomically commits its derived rows and cursor, comparing the prior
cursor to reject stale concurrent work. Identity lookups use database keys; normal
reopen processes only the event backlog. Existing databases without this index
need one resumable initial rebuild. First-page reads still wait for outstanding
indexing, so their cost includes that backlog, not only the returned page.
Browser and Node batch recovery scan a persistent pending-receipt index instead of every completed receipt. P2P imports and delayed pending application enter the same local event log, even for old TurnIDs; these private indexes and cursors are not copied between peers. Imported updates still do not enter the core outbox automatically. Concurrent plans retain their original processed cursor, retrying when intervening events invalidate their absence/revision reads. Neither this entry nor the generic package automatically replaces Lody runtime storage.
commitHistoryBatch(batchId, commands, {recovery: 'local'}) explicitly keeps
pending command bodies in the authoritative lody_history_local_batch_plans_v1
journal of that owner database. Preparation creates the compact pending receipt
and journal together. Apply commits all effects, publication references, receipt
seal and an empty journal tombstone together. A lost reply therefore preserves
either recoverable pending commands or the original committed outcome. Committed
receipts and their retained updates contain no command bodies. New batches also
omit unused baseline snapshots in both modes. No historical signed data is pruned.
The default remains recovery: 'replicated', preserving recovery commands in the
receipt. Retries keep the first preparation's recovery mode. Local mode requires backing up the whole owner database to move unfinished
work; exporting Turns does not export its journal. A same-owner imported pending
local receipt is observed without executing or failing it, and an explicit retry
of that unfinished batch requires its originating database. Committed publication
still rebuilds from the verified seal and target references on other replicas.
Stop older SDKs before writing local-mode pending work; use matching browser/Node
revisions. This option changes the business adapter, not core wire framing or seals.
Imported messages now rebuild local publication membership during read-side
catch-up or recoverPendingBatches() (which returns Promise<void>). New direct
messages carry original Create intent; batch members and explicitly promoted
private messages use verified sealed publication receipts. A receiver can derive
these indexes without an admitted local signing identity, and never replays a
remote author's pending commands or adds imported operations to outbox.
Missing targets/seqs wait in a persistent reverse index. One receipt's previously
hidden members become visible together; ordinary P2P edits to already visible
messages still follow their applied prefixes. See the
format and legacy compatibility contract.
The independent publication cursor performs a one-time, resumable catch-up for
existing databases; initial reads wait for it, so the upgrade can cost a history
scan. Subsequent reopens consume only new local events. Old committed receipts
are supported, including zero-ID placeholders, but an old standalone sealed
message cannot reveal whether its author chose publish:false. It stays hidden
on a new receiver until the original author repeats accept with its original
input and synchronizes the resulting signed publication Turn. New hidden messages
also stay hidden until explicitly promoted; sealing alone does not publish them.
Recovery does not change a message's verification level. Implementation evidence:
publication recovery plan.
Published message pages use the publication index for membership/order and batch
read current Turn bodies. append and finish do not maintain a second body
snapshot. A local event-cursor fence retries reads that overlap a commit, so a
page cannot combine membership from before a batch with content from after it.
Already published incomplete Turns expose their applied prefix. Unsealed content
remains provisional; the projected sealed flag is not a verification level.
Node active-branch pages privately reuse sealed bodies after matching authoritative headers, including sequence, seal and parent edges. The per-history cache keeps at most 64 bodies and 8 MiB of source JSON UTF-16 bytes (an input budget, not exact heap usage). Oversized and unsealed bodies are not retained. Returned bodies are independent copies. Page membership, cursors, availability and event fences are still checked on every read; first hydration performs the usual JSON validation.
After Node binding/publication indexes settle at one event cursor, repeated
catch-up calls still read the journal directly. They reuse the settled checkpoint
only if that fresh read finds no event. New events or failed reads retain normal
indexing, dependency wakeup and retry behavior. A publication page that adds no
work may commit its cursor and ready marker together, with the old ready/revision
in the same CAS; pending or newly discovered work keeps the full drain path.
observedEventCursor() returns that private settled cursor after the same fresh
event check; it needs no second persisted-cursor read. A failed event query still
rejects, and a new history handle must establish its own checkpoint.
With a matching derived-indexes-v1 Native host, NodeLodyHistory.commitContentBatch
can commit edits, application receipts and private SDK checkpoints in one durable
transaction. It only plans a fast checkpoint for content-subtree edits of at most
16 already published message Turns, with settled binding/publication checkpoints,
matching Create/binding/member records and no waiting target dependency. Other
writes keep ordinary catch-up. Callers may provide derivedIndexPuts for their own
cursor-fenced projections; the SDK owns all private table names and formats.
Native independently checks the entire actual event interval before installing
any derived put. derivedIndexesApplied is false when that proof fails; complete
catch-up still processes all events. SDK reuse is installed only after a confirmed
matching reply, and never after a lost reply. This is an additive local API;
subprocess/browser hosts retain their existing paths and advertise no new support.
After confirmed application, Node retains one private checkpoint plan (at most 16 IDs and 256 KiB). Each subsequent eligible write still compares every original proof row inside Native; only the committed progress values advance. An index-only CAS change refreshes the plan once after Native proves the attempt committed no write. Unknown outcomes clear reuse and never trigger an automatic retry. Fresh plans are detached once from host index results and command IDs before reuse. Every host call receives its own copied proof; subsequent private plans can share the retained SDK evidence without copying it again after each commit.
Use the shared cache for a live list on either adapter:
const view = history.createMessageView(40);
const initial = await view.initialize(); // latest 40, oldest first
// In the host's change callback/poll loop:
const delta = await view.refresh();
// Patch delta.changed; delta.messages supplies the full retained order.
// Unchanged rows retain object identity and their bodies are not read again.
const older = await view.loadOlder(); // another page when hasMoreOlder is true
view.dispose();refresh consumes bounded pages of the database's local event log and deduplicates
affected TurnIDs before reading their published bodies. One changed message in a
40-message cache reads only that body, including when many append events were
coalesced. Edits older than the loaded interval read no bodies. Newly published
messages in the interval are added; older ones remain available through
loadOlder. New arrivals do not evict loaded rows. Methods serialize, and failures
or disposal leave the previous cache/cursor intact. Sustained concurrent commits
can return stale after four attempts; the host may retry. Treat returned rows and
nested content as read-only. The in-memory cursor belongs to this cache, not a
standalone restart checkpoint. A fresh view loads a fresh page after reopen.
readProjectedMessagesById(ids) also provides a deduplicated, consistent point
read for up to 1000 TurnIDs, excluding unpublished IDs. Hosts still own wakeup,
polling and rendering. Browser hosts should subscribe before initializing the
view; the Node adapter currently uses host polling/change hints. The external
Lody UI is not wired by this library change.
New sessions can use the adapter's active-view primitives without changing the existing LoroDoc path:
const first = await history.acceptToView({
viewId: sessionId,
expectedRevision: 0n,
expectedHead: null,
operationId: requestId,
input: { kind: 'message', businessId: userId, segmentId: 'primary', parents: [], content },
});
const page = await history.readActiveBranchPage(sessionId, { latest: true, limit: 40 });
if (!page.complete) throw new Error('Active branch is incomplete');
const older = page.cursor
? await history.readActiveBranchPage(sessionId, { before: page.cursor, limit: 40 })
: null;Use forkAndActivate for an edit/resend. Its replacement Create must include
the retained base Turn as a parent; the old suffix remains stored and immutable.
The active-view revision/head is checked with the same atomic write and event
cursor CAS as the Turn Create. Repeating an operation ID is idempotent, while a
different input under that ID is a conflict. LodyHistory and NodeLodyHistory
expose the same contract. The caller still owns queue, steer and ACP lifecycle
decisions.
For immutable input delivery, use acceptInputToView with the same
BranchAcceptInput shape, then query readInputAcceptance({ viewId, operationId,
input }). Both browser and Node adapters bind an immutable input fingerprint
and operation receipt in the same transaction as the branch mutation. Input is
copied before asynchronous work begins. The fingerprint includes owner, business/segment identity, content and attachment
identity; the receipt key binds view and operation identity. Mutable parents,
expected head and revision are excluded.
Repeating an identical accept returns its original receipt even after edits,
later appends or active-branch removal. Changed input conflicts. Read-only lookup
returns undefined when unobserved, never inserts history, and does not prove a
pending accept cannot later commit. A host may retry the identical operation
without inventing a new input. Receipts remain owner-local derived indexes;
hosts own access checks, retained-generation lookup, execution and guide recovery.
LodyHistory.readOnly(stream) indexes and projects admitted native history without
a signing identity; authoring through that instance rejects. The host still owns
stream authorization and seal verification. restoreActiveBranch is available in
both browser and Node adapters: it atomically creates and seals a non-message
branch_state record with its local branch/operation indexes. Remote indexers
reconstruct the rollback, including an empty restored view, from that native
record. branch_state is readable as an internal history identity but is excluded
from HistoryWriteIdentity, AcceptHistory and batch commands; generic authoring
rejects it at runtime as well. Use restoreActiveBranch as the only write path. It
does not edit sealed Turns or change the Rust state machine/wire.
Active-branch pages return startPosition, totalCount, hasMoreOlder, and an
opaque ancestry cursor alongside the selected messages. Positions/counts refer
to logical businessId rows: use primary for the initial segment and distinct
successor IDs for late output. Project all returned segments of a business ID
into one row. Linear branches written with this metadata read a bounded page;
legacy/nonlinear ancestry may rebuild a complete directory. readActiveBranch
remains available for explicit full snapshots, fenced by the durable event cursor
so a concurrent import cannot mix branch membership and bodies. A partial replica
missing an older boundary reports incomplete and preserves the logical count;
the host fetches or repairs that boundary instead of declaring shorter history.
Generic published-message reverse
pages include superseded messages and cannot substitute for active-branch pages.
For a confirmed Native linear creation, advanceActiveBranchDirectory(page, result,
limit) can return a directory transition with retainedFrom, one new message,
and its committed through cursor. Retain page.messages.slice(retainedFrom)
and append message; use the returned state/count/older cursor. This transforms
membership only, retaining the consumer's own checkpoint-bound old bodies. It is
not a fresh read or proof that no later event exists. The owner must still observe
and process subsequent events, including state/permission corrections. Results
and page state are matched against private host proofs; copied results, forks,
partial pages and uncertain intervals return undefined and require ordinary page
reads. Older cursors retain late successor segments when the window advances.
An append preserves older cursors while adding any intervening late successors.
A fork that removes the cursor's ancestry rejects it as stale; refresh the
loaded window and obtain a new cursor. The last reverse page can contain fewer
than limit rows and never extends past the cursor. Lody uses these APIs in its
Node owner and read-only browser replica. Static checks of that composition do
not establish live-service synchronization or product performance acceptance.
New records in the private lody_history_published_v1 table have a version: "2"
membership-only value. Current readers also accept legacy full-snapshot values;
there is no separate startup scan solely to convert these membership values
(the publication catch-up above still applies). Reading an unchanged legacy row incurs
its old index payload until republished, but never uses that payload as current
content. New receipt publication lists contain compact references; legacy lists
and batch recovery command/base snapshots remain readable. Stop older adapters
before opening stores written with this layout or the new indexChanged local
events, and ship matching browser/Node client and owner revisions.
For a reproducible SQLite comparison, build the Node owner at the repository root
with cargo +1.98.1 build --locked --bin roost-node-owner, then run in ts/:
pnpm build
node scripts/history-publication-bench.mjs --history 1000 --samples 7 --output /tmp/publication-bench.jsonThe checkout must contain Git commit 4a282c826d54434f596ab6183ead7222e5a397f4;
the script reads that local object and does not fetch it. Archive/packed or shallow
checkouts missing that commit cannot load the baseline. The example compares
that adapter with current code using the same Rust owner. It covers 4/16/64 KiB messages, 40-row reads, owner reopen, single-row
refresh and imported publication recovery. --history, --sizes, --samples
and --binary control the fixture. Output includes latency, row counts, actual
body/field/Create reads, event records and IPC requests. It asserts one body read
for a single change and zero old-event/Create reads after caught-up reopen.
The old adapter's empty imported list is a correctness failure, not equivalent
work completed faster. This measures the adapter/storage path without Lody UI.
--history sets the local page/refresh/reopen fixture size. Each import sample
uses a fresh receiver with 40 messages; the batch variant sends two receipts of
20 messages each. Reopen timing excludes owner process startup/ready and leaves
the OS filesystem cache warm. Latencies are observations, not CI thresholds.
Recorded example: 2026-09-17 results and measurement limits.
Host payload crypto may return a Promise (AsyncPayloadCrypto), including
WebCrypto. Encryption and decryption are awaited outside storage transactions;
failed transforms never fall back to plaintext. See RSE1.
