Indexing in a browser app ​
The shape a template wires once and every app on it inherits: one contract, one processor, EIP-1193 from the user's own wallet, IndexedDB for the state, results consumed as stores — and a development loop where both the contract and the processor get replaced while the tab is still open.
The reference implementation is examples/browser-reference/browser/main.ts, the tab's side, with the worker entry beside it, indexer.worker.ts, where the indexer runs. It is the worker shape described below: the tab checks the wallet's chain, hands the wallet's provider to the worker as a port with the settings, and reads what the worker indexed. It is meant to be read top to bottom in one sitting and copied. This page is what you should know before you copy it.
Two things about those files are deliberate and worth stating, because they are the reason to trust them over a snippet in a document: it is typechecked by the acceptance gate, and every claim it makes is asserted against a real browser by verify/reference.spec.ts, which needs no network: the wallet and the chain are injected into the page. Run it with pnpm --filter browser-reference verify:browser. See ADR-0030 for why that split exists.
Two hazards that do not look like hazards ​
Ask the connection for the chain, never the provider ​
@etherplay/connect's connection.provider is an always-on wrapper: it routes through the chosen wallet when there is one and falls back to the chain's own endpoint when there is not, so your app has one provider either way. It is pinned to the chainInfo it was constructed with, so:
await connection.provider.request({method: 'eth_chainId'}); // ALWAYS your app's chain idA chain check written against it compares a constant with itself. It passes for a wallet sitting on Polygon, and the app then indexes a mainnet address against a Polygon node — finding nothing, and looking merely slow. That bug shipped in this repository, reviewed and built green.
// ✗ never: pinned, answers your own chainInfo whatever the wallet is set to
const chainId = await connection.provider.request({method: 'eth_chainId'});
// ✓ always: the connection state reports the WALLET's own chain
const chainId = $connection.wallet?.chainId;The provider is for reads (eth_getLogs, eth_blockNumber). It is not an authority on what the wallet is pointed at.
Wire first, subscribe last ​
A store subscription fires synchronously with the current value, before subscribe() returns. So a callback that touches anything declared after the subscribe call reads it in the temporal dead zone and throws.
// ✗ throws `Cannot access 'unsubscribe' before initialization`
// on every path where the store has ALREADY settled
const unsubscribe = connection.subscribe(($c) => {
if (settled($c)) unsubscribe();
});
// ✓ declare with let, initialise to a no-op, defer the call
let unsubscribe: () => void = () => {};
const stop = () => setTimeout(() => unsubscribe(), 0);
unsubscribe = connection.subscribe(($c) => {
if (settled($c)) stop();
});The reason this survives testing is that the paths a human clicks — a wallet picker, an accounts prompt — resolve asynchronously and work fine. The paths that break are the already-settled ones: a single auto-selected wallet, a chain mismatch, no wallet at all. That is the returning user, not the first-time one.
The same trap is not limited to the unsubscribe handle. Attach indexer.syncing.subscribe(...) and indexer.state.subscribe(...) at the end of your setup, after everything they close over exists. While writing the reference, those two calls sat above the pending-transaction map they read, and the page died on load. It typechecked perfectly; the browser run caught it.
Where the indexer runs: a dedicated worker, by default ​
Indexing is a fold over every log your contract ever emitted, and it writes to IndexedDB as it goes: 45.6 ms per block of store writes on Chromium on the measured workload, and a stream-seed install whose longest main-thread block measured 190 ms on a mid-range phone in the recommended shape and up to 632 ms in others (the finding). On the thread that paints, that is jank. So the indexer is hosted (ADR-0082): a host owns the store and the loop, your tab holds a port to it, and the default host is a dedicated worker. Every recipe below shows that shape first.
The worker shape is two pieces. The worker entry, which your bundler builds with the rest of the app, holds the CODE: the processor is code, so it is IMPORTED there and never sent as a message.
// indexer.worker.ts
import {hostIndexerInThisWorker} from '@etherfold/browser';
import {createState, createProcessor} from './indexer.js';
hostIndexerInThisWorker({createState, createProcessor});And the tab's side, which hands the host what only the tab knows: the chain the user connected, and the settings.
import {connectToIndexerHost, createProgressReadable, dedicatedWorkerHost} from '@etherfold/browser';
import {source} from './indexer.js';
const indexer = connectToIndexerHost(
dedicatedWorkerHost(() => new Worker(new URL('./indexer.worker.ts', import.meta.url), {type: 'module'})),
{
provider: window.ethereum, // the user's wallet, served to the worker by this tab
settings: {source, config: {stream: {finality: 12}}, publication: {locations: [PUBLICATION]}},
},
);
const progress = createProgressReadable(indexer); // a store, like `syncing`The host starts indexing on its own as soon as it has a provider and a source. What the tab gets is the port: typed reads (indexer.reads, or createPortReadSurface over your declarations), GraphQL if the worker entry opts in (Querying the state), control (startIndexing, stopIndexing, reconfigure), checkTxInclusion, and status PUSHED to it (indexer.onProgress, which createProgressReadable wraps). What the hook publishes on syncing.publication and syncing.streamSeed reaches the tab as progress.publication and progress.streamSeed, with the same values. A failure that stops the host (a refused bundle, a seed with no keeper) is progress.failure, beside phase: 'refused', where the main thread would have rejected init.
The provider and the settings: from the tab, or from the entry ​
A provider is an object with methods, so it cannot be cloned into a worker; a MessagePort can be transferred into one. So the provider crosses as a port speaking @eip-1193/over-port, and errors keep their code and data on the way, so a node's range hint or archive refusal is read by the fetcher exactly as from a local provider. provider takes one of three things:
- A provider object, such as a wallet's (
window.ethereum, orconnection.provider). The port serves it on a freshMessageChannelitself; every request the indexer makes passes through the tab as a message, but the fold stays in the worker. - A
MessagePortwhose other end is already served elsewhere, which is the case for a node that runs in ANOTHER worker, such aswebevm. Requests then go worker to worker and the tab relays nothing. Pass a FUNCTION that returns a fresh port, so a restarted host can be handed one again (a port can be transferred only once):
import {serveProvider} from '@eip-1193/over-port';
// the node's worker serves whatever port it is handed:
// self.addEventListener('message', (e) => e.data?.provide && serveProvider(node, e.data.provide));
function nodePort(): MessagePort {
const {port1, port2} = new MessageChannel();
nodeWorker.postMessage({provide: port1}, [port1]);
return port2;
}
const indexer = connectToIndexerHost(dedicatedWorkerHost(() => new Worker(/* ... */)), {
provider: nodePort,
settings: {source},
});- Nothing, where the worker entry builds its own provider (from an RPC URL, say), which it still may:
hostIndexerInThisWorker({createState, createProcessor, provider, source})starts at once, as it always did.
settings is the cloneable part of the spec (source, config, publication, catchUpWithinSeconds, seed, promotion), under the same names the entry and the main-thread hook use. It is checked on the tab and a value that cannot cross is refused naming its field, so what is CODE stays in the entry: createState, createProcessor, a keepStream, a fetch override. A host whose entry leaves out the provider or the source WAITS (phase: 'waiting') until a tab hands them over. The recipes below put these values in the worker entry's spec for brevity; each of the cloneable ones may come from the tab's settings instead, under the same name.
A value is given in one place. Where the entry and the tab both give one, they must agree, and a host that has started keeps the settings it started with (change the source with reconfigure). A tab that disagrees is refused, naming the fields, and the host folds what it already had; onConnect tells the tab, and the refusal is logged either way:
connectToIndexerHost(access, {
provider,
settings,
onConnect: (outcome) => {
if (!outcome.accepted) console.error(outcome.error.name, (outcome.error as {fields?: string[]}).fields);
},
});A SharedWorker host takes a provider from every tab that hands one over, folds through the first, and when that tab goes away it switches to another connected tab's provider rather than failing every request. With no tab left it retries as it does when a provider is down, and resumes through the next tab that connects.
The main thread is the documented alternative, and it is the same host, adapted: createIndexerState(spec, options) then init({provider, source, config}), with reactive syncing, status and state stores in place of the port. It is reasonable where you want the in-place hot-reload verbs below (updateProcessor, updateIndexer, which are the hook's; the warm swap beside the live generation works in both shapes), for a small state over a short history, and where your bundler cannot emit a worker. What it costs is the fold on the UI thread, measured above. indexer.mainThreadHost() gives it a port, so code written against the port is the same in both shapes. The reference implementation linked at the top of this page runs the worker shape, with connection.provider handed over as a port; nothing in it indexes on the main thread. A SharedWorker (sharedWorkerHost in the tab, hostIndexerInThisSharedWorker in the entry, with the same spec and the same tab-side options) is opt-in: one store connection across tabs, at the price of a worker that needs chrome://inspect to debug.
Starting from a published snapshot: the snapshot-only mode ​
A browser app usually cannot rebuild its state from the chain: on a public node the historical eth_getLogs a backfill needs is frequently refused outright. So what the tab holds at startup has to arrive as a published artifact, and the supported shape of that is the snapshot-only mode: state seeded from a published state snapshot, the tab indexing forward from the snapshot's own block, and no stream keeper at all, so nothing is ever written or read under the stream keyspace. It is the path most browser apps should take rather than a fallback.
It is asserted end to end by packages/browser/test/snapshotOnlyMode.test.ts, which is the thing to read next to this page: the mode lands on the same state as the same run with a keeper under it, survives a reorg inside the finality window and a reload, and the empty keyspace is proved by reading the keys of the substrate a keeper would have used, against a kept-stream control in the same case, rather than by asserting that a function was not called.
The trade, before the recipe: the generation is a leaf ​
A snapshot carries nothing below its own block. A bootstrapped store reports its retention floor there and refuses a revert reaching under it (ADR-0028), and with no stream keeper there is no stored stream beneath the fold either. The generation is a leaf, and one consequence follows that you want to know before you ship rather than at the first reconfigure.
A later processor-only change is not free. A generation built beside the live one, which is how an edited processor lands without blanking the app, fetches not one log precisely because the successor re-folds the stream that is already stored. Seeded from a snapshot there is no such stream, and a snapshot is keyed to the identity of the processor that computed it, so the successor cannot start from the snapshot you already hold either. Its state comes from a snapshot the publisher republishes with the new processor, and that wait is the price of the mode.
Adding keepStream on top does not buy it back. That is the reach that looks obvious and leaves exactly the same hole: a stream kept by a snapshot-seeded tab starts at the snapshot's block, so a successor still has nothing to fold below it. What buys the free re-fold, plus revert and as-of depth below the snapshot's floor, is a stream that reaches back to your source's own start block: either indexed from that block by this tab, which is the fetch a public node will not serve, or installed from a published stream seed, which is the next section (ADR-0063, ADR-0064, ADR-0066).
How long that wait is ​
Measured, and quotable as fact: the reference deployment's publisher republished its state snapshot 8,198 times over 357 days, measured from the git history of its public snapshot repository.
| gap between publishes | |
|---|---|
| median | 1.0 h |
| p90 / p99 | 1.2 h / 1.9 h |
| worst observed | 50.9 h |
| within 2 h / 4 h | 99.74% / 99.90% |
Read that as the floor on the wait rather than the wait itself. It measures the interval between republications of the snapshot the SAME processor computed, so a processor-only change pays it only after the publisher has deployed the new processor and re-indexed under it; the cadence bounds the last step, not the first. The tail is also what to plan the UI against rather than the median. The same cadence bounds what a freshly seeded tab fetches for itself before it is caught up: at 2.000 s/block that is 1,802 blocks at the median gap and 91,527 at the worst observed one, all of it inside the recent range a public node does serve. Every number here is from work/notes/findings/what-a-published-stream-seed-costs-to-install.md, which measured them.
One half of this is measured and the other is an account, and they are worth keeping apart. The cadence above is a measurement of a public git history, so take it as fact. That the client of that deployment then ran on the snapshot ALONE, with no stream underneath at all, is the maintainer's account of how one deployment was built and behaved: no measurement here shows it, and that deployment ran on this library's predecessor, so read it for the shape of a deployment and not for an API. It is a good reason to believe the mode is viable in production, offered as testimony rather than as data.
Publishing it: etherfold build --publish ​
The snapshot comes from the same CLI that folds your chain, so there is no producer of your own to write. A scheduled job runs one command:
etherfold build -p ./dist/processor.bundle.js -d ./deployments -n "$ETH_NODE_URI" \
--store sqlite --db file:./etherfold.db \
--publish ./web/static/indexed-statesIt folds the chain to the tip into its database, then writes that database's canonical generation out into ./web/static/indexed-states, which any static host serves as it is: a git repository, a bucket, your app's own static/ folder. etherfold publish --db file:./etherfold.db --out <dir> is the same step on its own, over a database any folding command wrote. Both are documented, flag by flag, in the CLI's README, and the reasons are ADR-0095. What lands in the directory:
- A state snapshot of the canonical generation, as
state-<sha256>.ndjson.gz, sized by your STATE and never by the stream: an app with a small state over a long history downloads what it displays. It is cut attip - finality, below the reorg window, so a tab that starts from it can absorb a reorg; you do not choose the cut.--history <blocks>(orall) also carries that many blocks of changes below the cut, so the tab can read as of, and revert to, any block from the snapshot's floor up; the default,none, carries the live rows alone. - A publication index,
publication.json, naming the latest snapshot PER GENERATION: your stream (contracts plus stream config) and your processor (the SHA-256 of the bundle bytes, ADR-0086). A republication replaces only its own generation's entry, and nothing a publication wrote is ever deleted, so users still running an OLD build of your app find the last snapshot of their own processor and index forward from it rather than being stranded. The bodies are named by their content hash and never change, so a CDN may cache them for ever, and the index is written last, so no reader sees it name a body that is not there yet. - With
--seed, a stream seed of the stream it folds (the next section), keyed in the index by stream. Off by default, because under a layout that never deletes, an hourly job would otherwise store a full copy of a long stream on every run.
A host may serve the .gz body either way, and both install. Opaque (the .gz bytes, with no Content-Encoding), or with Content-Encoding: gzip, in which case the browser inflates it before your app sees it: Vite's dev server does the second for static/**/*.gz, and most static hosts the first. The reader decides from the body's first two bytes (the gzip magic), never from a header, since a browser that decoded a body hides the header from a script, and it still installs chunk by chunk either way. Exactly one gzip layer is peeled, so a body gzipped twice is refused as unreadable-format.
Two things have to match between the job and the app, and the index is keyed on both: the source (the job's --deployments and the source your app passes to init) and the finality (the job's STREAM_FINALITY and your app's config.stream.finality). They hash into the stream digest the entry is looked up by, so a publisher running other contracts or another finality is refused by name (below) rather than installed and then discarded.
The processor's identity is the bytes of the WHOLE bundle, dependencies included. Not your handlers alone: whatever the bundler pulls in, from etherfold or from any other package, is part of the hash (ADR-0086). So an etherfold upgrade, or any other dependency bump, may move it though your processor's source did not change, and a real change in code the bundle carries (a codec it calls, say) moves it, correctly. When it moves, an app shipped with the rebuilt bundle finds none of the snapshots published so far and indexes from its start block until the job republishes. So after an upgrade, rebuild the bundle, let the snapshot job republish with it, and ship the app with that bundle; build --publish and publish print a new processor: line, naming the identities the index already held, whenever the processor they just published is not one of them. The etherfold packages a processor imports (@etherfold/processor-entities, @etherfold/state-store, @etherfold/core) declare "sideEffects": false, so etherfold code your processor never reaches (a logger in a module it does not use) is dropped by the bundler and cannot move the hash on its own.
Wiring it: the publication index, and the bundle it was computed by ​
A snapshot is keyed to the processor that computed it, and the publisher's processor is named by the hash of its bundle's bytes. So the tab runs the very bundle the job folded with, published beside your app, fetched and hashed by the tab itself (processorBundle): its identity then matches the publisher's by construction, and an app that shipped a different bundle than the job folded is told so by name. A tab running the module your bundler compiled has a different identity from the same code as a bundle, and no build step may hand it the bundle's hash instead (ADR-0095).
The factories are the same in both shapes, so write them once, in a module the worker entry imports:
// indexer.ts
import {createBrowserStateStore, type BrowserGenerationSpec, type InstantiatedProcessorBundle} from '@etherfold/browser';
import {
EntityEventProcessor,
openAndBootstrap,
openForWriting,
type EntityProcessor,
type EntityStateView,
} from '@etherfold/processor-entities';
// Locations in priority order: a rolling remote your build NAMES, then the copy
// EMBEDDED in this build at a relative path, which needs no host and is what
// makes the app start when the remote is unreachable or gone.
export const PUBLICATION = [PUBLICATION_URI, '/indexed-states/publication.json'];
export const spec: BrowserGenerationSpec<typeof abi, EntityStateView> = {
// The published bundle: the host fetches it, names this generation by the
// SHA-256 of the bytes, and instantiates the processor FROM those bytes.
processorBundle: {url: '/processor.bundle.js'},
// `published` is the snapshot the index names for THIS generation, when it
// names one: the arguments the existing bootstrap takes. Open through
// `openAndBootstrap` either way: it opens snapshot-aware first (which is what
// recovers a floor an earlier run recorded) and downloads nothing when this
// tab has already synced, unless the host asks for the local state to be
// REPLACED (`replaceLocal`, see "A returning tab" below). Forward it.
createState: async (context, {signal}, bundle, published) => {
const {store, outcome} = await openAndBootstrap(
await createBrowserStateStore(definitionOf(bundle).entities, {
databaseName: `app-${CHAIN.id}-${context.stream}`,
}),
published?.locations ?? [],
{processor: published?.processor ?? 'none', replaceLocal: published?.replaceLocal, finalityDepth: 12},
);
// A refusal is DATA rather than a throw: {status: 'bootstrapped', at, from} |
// {status: 'kept-local', at} | {status: 'not-bootstrapped', reason}.
logSeedingStatus(outcome);
// CLAIMED on the way out (ADR-0077). The signal bounds the CLAIM alone, not
// the download above, which has its own timeouts.
return openForWriting(store, {signal});
},
createProcessor: (state, _context, bundle) => new EntityEventProcessor(state, definitionOf(bundle)),
};
// The bundle's processor is the authoring object its bytes made, typed by nothing
// at compile time, so name the type you built it from.
function definitionOf(bundle?: InstantiatedProcessorBundle) {
return bundle!.processor as EntityProcessor<typeof abi>;
}The worker entry hands the host the spec, the chain, and the publication:
// indexer.worker.ts
import {hostIndexerInThisWorker} from '@etherfold/browser';
import {PUBLICATION, provider, source, spec} from './indexer.js';
hostIndexerInThisWorker({
...spec,
provider,
source,
config: {stream: {finality: 12}},
// No `keepStream`, and that ABSENCE is the whole of the snapshot-only mode:
// nothing is stored or read under `['stream', ...]`.
publication: {locations: PUBLICATION},
});And the tab holds a port to it, as above:
const indexer = connectToIndexerHost(
dedicatedWorkerHost(() => new Worker(new URL('./indexer.worker.ts', import.meta.url), {type: 'module'})),
);
const progress = createProgressReadable(indexer);On the main thread the same spec and the same option go to the hook:
const indexer = createIndexerState(spec, {publication: {locations: PUBLICATION}});
await indexer.init({provider, source, config: {stream: {finality: 12}}});From there it is an ordinary indexer: it starts at the cursor the snapshot carried, re-reads that cursor's finality window without applying anything twice, and indexes forward. By default the tab downloads the index and the one snapshot its generation can use, and nothing else, even when the index lists a seed.
What the lookup gave is on progress.publication (syncing.publication on the main thread, with the same values), and none of it stops your app: without a snapshot the tab indexes from the chain as it would with none at all. found names the snapshot createState was handed; refused says why there was none:
| reason | what it means |
|---|---|
stream-mismatch | the index has entries for your processor, but only over another stream: the job's contracts or finality differ from your app's. streams names them. Nothing beyond the index is downloaded |
no-entry | nothing was published for your processor: the job has not run with this bundle yet |
no-processor-identity | the generation had no identity before its state was built: it runs a MODULE, not a processorBundle |
unreachable / unreadable-format | no location answered, or what answered is not an index this build reads (an app or a publisher out of date) |
A returning tab: catching up within a budget, or starting from the snapshot ​
A tab that already holds state from an earlier visit does not take the snapshot on arrival: it catches up from its own cursor, which after a short absence is a few requests. After a long one it is not, and two things can go wrong, so the tab switches to the published snapshot MID-RUN instead (ADR-0096):
- The node refuses the catch-up as needing archive access (
ArchiveRefusedError), which a public node does for history it does not keep. No retry fixes that, so a tab whose publication names a snapshot further along than its own state switches to it. With no usable snapshot the refusal stops the tab exactly as it always did. - The catch-up would take too long. After every advance the tab estimates how long the rest of the gap to the tip will take, from the blocks its advances have covered so far and the time they took, and if that exceeds
catchUpWithinSecondsit abandons the catch-up. The default is 30 seconds (DEFAULT_CATCH_UP_WITHIN_SECONDS): it is a wait a user sits through looking at stale state, and a snapshot is sized by your state rather than your history, so installing one takes a few seconds.
Switching is the ordinary install and nothing else: the host builds the generation again and calls your createState with published.replaceLocal set to true, and openAndBootstrap then wipes the local state and installs the snapshot, as it does for a fresh tab. That is why the example above forwards replaceLocal: a createState that does not forward it keeps its local state, and the tab carries on as though no snapshot were published. After the switch the tab indexes forward from the snapshot's cursor, and progress.publication says so (syncing.publication on the main thread): {status: 'switched', reason, from, snapshot, at, left}, where reason is archive-refused or over-budget, left is the block the local state had reached, and an over-budget switch also carries estimateSeconds and budgetSeconds. The switch runs where the fold runs, so in the worker shape the wipe, the download and the install are all off the UI thread.
// indexer.worker.ts
hostIndexerInThisWorker({
...spec,
provider,
source,
config: {stream: {finality: 12}},
publication: {locations: PUBLICATION},
// seconds, or 'always'
catchUpWithinSeconds: chainInfo.slowLogs ? 10 : 30,
});On the main thread it is the hook option of the same name:
createIndexerState(spec, {publication: {locations: PUBLICATION}, catchUpWithinSeconds: chainInfo.slowLogs ? 10 : 30});'always' means always catch up yourself, however long it takes: the snapshot is then taken only when the node refuses the catch-up. The value is one number, and choosing it per chain is yours: a chain with fast blocks or a rate-limited node wants a smaller one, and the natural place to decide is wherever your app already keeps its chain info (its deployment tooling, say). The library carries no per-chain table. A fresh tab is unaffected (it starts from the snapshot anyway), and so is a tab whose own state is already at or ahead of the snapshot, which has nothing better to switch to.
A refused bundle is the other outcome to handle. A bundle that cannot run (not self-contained, bytes that do not load, a Content-Security-Policy that forbids instantiating from bytes) folds nothing and claims no store, and it names the reason: a worker host stops with phase: 'refused' and progress.failure carrying ProcessorBundleRefusedError and its reason, and on the main thread init rejects with that error.
The lower-level form is still there, and it is what the hook composes: openAndBootstrap(backend, locations, {processor, finalityDepth}) with the body's URL and the identity of the processor this tab actually runs. Reach for it when you publish some other way; the index is what saves you from naming, per build, which body goes with which processor.
It is asserted end to end by packages/cli/test/aBuildPublishedAppStartsFromItsOwnPublication.test.ts: build --publish over a fixture chain, and a tab running the same bundle file that starts from it, lands on the state of a tab that indexed the chain itself, absorbs a reorg, downloads a body that does not grow with the stream, re-folds a processor-only change from the seed, reverts inside published history, and keeps an old build on its own entry.
The locations are yours, and so is the risk. The library fetches where it is pointed and judges nothing: there is no allowlist and no origin check, because a client cannot be offered a snapshot from somewhere it was not pointed at (ADR-0066). So the host your build names has to be trusted the way your build pipeline is trusted, and an app that lets a URL query parameter override it (as the reference deployment's ?snapshot= does) is accepting a state source anyone with a link can choose. Nothing downstream catches that: what is checked is the processor identity, the envelope format and the reorg window, while the rows themselves are taken on trust, and a snapshot that quietly leaves some out is structurally perfect. Detecting that needs the historical logs the node will not serve (ADR-0065, whose omission residue ADR-0066 leaves standing).
Pass finalityDepth. A snapshot taken within the reorg window of the tip its producer had seen cannot absorb a reorg reaching under its own block, since it carries no history there. That has two halves: the publisher takes the snapshot at least the finality depth behind the tip, which etherfold publish does for you (it cuts at tip - finality), and the client passes finalityDepth so a snapshot that was not is refused as not-bootstrapped / inside-reorg-window instead of installed. Omit it and the check never runs. Give it the same finality your indexer runs with, which is the one the job publishes under.
Installing a published stream seed, and rendering what it did ​
The other published artifact: a stream seed puts the raw stream UNDER your state, so a later processor-only change re-folds locally instead of waiting for a republished snapshot. It is a separate decision from the snapshot above and composes with it (the snapshot seeds the fold, the seed seeds the stream), and it needs a stream keeper, which the snapshot-only mode deliberately does not have.
It is published by the same job, when asked. etherfold build --publish <dir> --seed (or etherfold publish --seed) writes the stream the canonical generation folds, cut at the snapshot's block and compacted to the final chain, as seed-<sha256>.json.gz, lists it in publication.json keyed by stream, and PRINTS its contentHash for a release to pin. It needs no node: the source identity it carries is the one the fold recorded. As with the snapshot, a host may serve the .gz seed opaque or with Content-Encoding: gzip: the install decides from the bytes, and the contentHash is over the decompressed document (ADR-0066), so it matches whichever way the seed was served.
A tab starting from the index installs it with one more field, publication: {locations, seed: true}, plus the keepStream any seed needs:
// indexer.worker.ts
hostIndexerInThisWorker({
...spec,
provider,
source,
config: {stream: {finality: 12}},
keepStream: keepStreamOnIndexedDB('token'),
publication: {locations: PUBLICATION, seed: true},
});
// or, on the main thread
createIndexerState(spec, {keepStream: keepStreamOnIndexedDB('token'), publication: {locations: PUBLICATION, seed: true}});The seed listed for your stream is then installed before the generation loads, and it is what makes a processor-only change cheap: a tab running a NEW bundle, for which no snapshot has been published yet (no-entry), re-folds the installed stream locally and only asks the chain for the blocks above the seed. Seeds are per stream, not per processor, so an old build on the same stream takes the newest seed too. The seed option below is the same install for a seed published anywhere else; a boot installs ONE seed, so the two are refused together at init.
The wiring, which has two shapes and one behaviour ​
The documented default is the host's seed option: it sequences the install for you, inside the host and before the generation loads, and publishes what it did on the surface you already read.
// indexer.worker.ts
import {hostIndexerInThisWorker, keepStreamOnIndexedDB} from '@etherfold/browser';
// Ordered, freshest first, and BOTH of these live in your BUILD: a rolling
// remote your build names, then the copy embedded in the build at a relative,
// hostless path, which needs no host and is what makes the app start when the
// remote is gone.
const SEED_LOCATIONS = ['https://seeds.example/token.seed.json.gz', '/seeds/token.seed.json.gz'];
hostIndexerInThisWorker({
createState,
createProcessor,
provider,
source,
config: {stream: {finality: 12}},
keepStream: keepStreamOnIndexedDB('token'),
seed: {locations: SEED_LOCATIONS},
// For an IMMUTABLE, release-tied artifact, add the hash the producer PRINTED:
// seed: {locations: SEED_LOCATIONS, expectedContentHash: 'sha256:…'},
});On the main thread, the same two options go to the hook, where the install blocks the thread that paints for the time measured at the top of this page:
const indexer = createIndexerState(
{createState, createProcessor},
{keepStream: keepStreamOnIndexedDB('token'), seed: {locations: SEED_LOCATIONS}},
);
await indexer.init({provider, source, config: {stream: {finality: 12}}});The direct path is the same capability with the sequencing in your hands, and it is fully supported:
import {installStreamSeed, resolveStreamConfig} from '@etherfold/core';
const keeper = keepStreamOnIndexedDB('token');
const outcome = await installStreamSeed(keeper, SEED_LOCATIONS, {
source,
// RESOLVED, and the same stream config your indexer runs with: it is half of
// the address the stream is stored under, so `{finality: 12}` as a user spelled
// it is not what to pass.
streamConfig: resolveStreamConfig({finality: 12}),
});Either order is correct, before init() or after it, because the install carries its own resolved stream config and sets it on the keeper before it addresses anything (ADR-0067). So the hook option is ergonomics, not a safety mechanism. What is NOT free is installing after the tab has started indexing: the install writes only into an EMPTY subtree, so it comes back not-installed / subtree-not-empty and leaves what is there alone. That is loud and it is data, so clear the stream deliberately if you meant to replace it.
What reaches your stores ​
No new reactive shape. Across the port it is a field on the progress the host pushes:
indexer.onProgress(({streamSeed}) => {
// undefined when no seed was asked for; `installing`, then the outcome
if (streamSeed?.status === 'installing') showSpinner('Installing history…');
if (streamSeed?.status === 'seeded') show(`history from ${streamSeed.from}, up to block ${streamSeed.at}`);
// `subtree-not-empty` is the ORDINARY case on every visit after the first: see below
if (streamSeed?.status === 'refused' && streamSeed.reason !== 'subtree-not-empty') {
show(explain(streamSeed.reason, streamSeed.direction));
}
});On the main thread it is a field on syncing and a value in the status phase enum:
indexer.status.subscribe(($status) => {
if ($status.state === 'InstallingStreamSeed') showSpinner('Installing history…');
});
indexer.syncing.subscribe(($syncing) => {
const seed = $syncing.streamSeed; // undefined when no seed was asked for
if (seed?.status === 'seeded') show(`history from ${seed.from}, up to block ${seed.at}`);
// `subtree-not-empty` is the ORDINARY case on every visit after the first: see below
if (seed?.status === 'refused' && seed.reason !== 'subtree-not-empty') show(explain(seed.reason, seed.direction));
});subtree-not-empty is the steady state, not a problem — do not render it. The install writes only into an empty subtree, so it succeeds ONCE. Every later page load finds the stream already there and comes back not-installed / subtree-not-empty, which is truthful (this boot installed nothing, for exactly that reason) and costs nothing (the emptiness check runs before any download, so a returning visitor fetches no artifact at all). It is the signal that seeding WORKED and is still working. An app that renders every refused alike will therefore show a failure banner to a perfectly healthy returning user, which is the inverse of the point — so treat this one reason as a non-event, as the snippet above does. The refusals worth showing a user are the ones about the artifact: a direction, an integrity mismatch, incoherence, a capture too close to the tip.
A refusal is not an error, and it does not stop your app. State still comes up from your published snapshot and indexes forward from the tip; what is lost is the stream underneath, so the generation is a leaf again (ADR-0064). That is why it is its own field and not syncing.error: an app that renders error as a crash must not render one for an ordinary outcome.
The direction is yours to interpret, and only yours. A seed for another stream is refused with a reason, and where the reason names a direction it is repeated as direction: seed-covers-more (the publisher indexes more than this build does) or seed-covers-less (this build indexes something the seed lacks). Your app may render "a newer version of this app may be available"; the library will not, because a deliberately narrower client is indistinguishable from a stale one and only you know which yours is.
There is no byte-level progress, deliberately. installing and one terminal state is the whole surface: the install itself is about a second on a mid-range phone in this shape, and the variable part is the download rather than the write (the finding).
The locations are yours, and so is the risk, which is the same sentence as for snapshots and for a sharper reason. The loader fetches where it is pointed and judges nothing, an optional expectedContentHash only exists for an immutable release-tied artifact, and OMISSION is not detectable at all: a seed that quietly leaves logs out is structurally perfect, and a stored stream is re-folded by every later generation, so the poison is inherited. Name a host you trust the way you trust your build pipeline (ADR-0066).
It is asserted end to end by packages/browser/test/streamSeeding.test.ts, against the real IndexedDB substrate.
When another tab takes the store: your tab becomes a reader ​
A user with your app open in two tabs is not an exotic deployment, and neither is a backgrounded tab that wakes up an hour later holding a cursor that stopped being true. Every mutation is checked against a claim inside the transaction that writes it (ADR-0075), so the loser writes NOTHING — and what it does about that is demote itself to a reader: it stops fetching, drops the in-memory cursor that is now a lie, and goes on answering reads from the store the other tab is writing.
indexer.syncing.subscribe(($syncing) => {
if ($syncing.demotion) showQuietly('another tab is indexing; this one is up to date and reading');
});It is not an error and there is nothing to retry. The data on screen stays correct — it is the store's, and the store is being written by whoever holds it now — which is why it is its own field rather than syncing.error, exactly as a refused seed is. Nothing loops: the auto-index loop stops and is not re-armed, and startAutoIndexing() on a demoted tab answers false rather than fetching a chain in order to be refused by every write it makes.
An advance answers nothing once demoted. indexMore(), indexMoreAndCatchupIfNeeded() and indexToLatest() resolve to undefined, which means demoted and means nothing else; syncing.demotion says why. A caller that ignores the return value is unaffected.
Getting the write duty back is a fresh start, deliberately. A store that lost is never re-claimed (ADR-0077), so indexing again is dispose() and a fresh init() over a store built fresh — which re-reads everything, which is what makes the recovered writer correct.
One tab indexes and the others read: opt into the tab election ​
Demotion keeps two tabs correct; it does not stop them both FETCHING, and on a rate-limited provider that is the expensive half. So give your host a tab election and a reader factory, and only one tab indexes (ADR-0097). Name the store's constructor ONCE and let stateFactoriesFrom (@etherfold/processor-entities) derive both factories from it:
hostIndexerInThisWorker({
// ONE constructor: `createState` claims it (the writer), `openState` opens it for READING (the reader)
...stateFactoriesFrom({
open: (context, entities) => createBrowserStateStore(entities, {databaseName: `app-${context.stream}`}),
entities: processor.entities,
}),
createProcessor: (state) => fromEntityProcessor(processor)(state),
// ONE Web Lock per APP, named by you: tabs giving the same name elect one indexing tab
tabElection: {name: 'my-app'},
});One constructor is the point, not a shortcut. The election is correct only while the reader opens the SAME database the leader writes, and two hand-written factories drift: a databaseName changed in one of them leaves a reader answering from an empty store while looking healthy. What the helper derives is exactly what you would write by hand, so nothing about either factory changes: createState opens the store, starts it from the published snapshot when the host hands one (openAndBootstrap, forwarding replaceLocal, with the finalityDepth and fetch you give the helper and an optional onBootstrap to render what it did), and claims it with openForWriting, handing the host's signal to the CLAIM alone; openState opens the same store snapshot-aware and then with openForReading, and wraps it in an EntityStateView. A reader is therefore read-only by type, never downloads or installs a snapshot, and keeps the floor an installed snapshot recorded. With a processorBundle, the store is declared from the bundle's own processor.entities, so leave entities out; it is for a processor imported as a module.
The two factories remain yours to write when you need something the helper does not do (the hot-update recipe below still writes its own createState, because each save folds into a database of its own). They are then the same constructor twice, openForWriting in one and openForReading in the other. On the main thread the same two things go to createIndexerState: the helper's factories spread into the spec, and {tabElection: {name}} in its options.
The first tab leads; every other tab is a reader. A reader claims nothing and fetches nothing: its reads answer the store the leader writes, its onStateMoved fires when the leader's fold moves, and its progress is the leader's, so "syncing, 400 blocks behind" renders in every tab. progress.election (on a port) and syncing.election (on the hook) say which seat a tab holds: {name, role: 'reader' | 'writer', tookOver}, plus takeoverReason, displaced and visibility for the takeover below.
Closing, or crashing, the indexing tab hands the work over. The lock is released by the browser when the tab or its worker goes away, with no heartbeat or timeout, and the next tab becomes the writer on its own: it builds its generation through createState, claims, and indexes forward from the stored cursor. tookOver then reads true. On the main thread, a reader remembers startAutoIndexing() and starts the loop when it takes over; until then its advances answer undefined.
It is an optimisation, never a correctness mechanism. Without navigator.locks, or without both the name and openState, a host behaves exactly as before. Two tabs that both believe they lead are still settled by the writer claim, and the loser demotes as described above.
The tab you are looking at takes the work from one in the background. A browser throttles a hidden tab's timers and freezes one hidden for long, and a lock does not notice, so a backgrounded leader would index slowly or not at all while the tab on screen reads data that falls behind. So when a reader's tab has stayed VISIBLE for two seconds while the leader is HIDDEN (or has never answered, which is what a frozen tab looks like), the reader takes the lock with steal. The old leader stops, abandons the batch it was fetching, and becomes a reader of the store the new leader writes: on the main thread through the same demotion as above (syncing.demotion.reason === 'lease-lost'), with election.displaced reading true. It queues for the lock again, so it takes over once more when the tab that displaced it closes. The new leader's seat says why it leads: takeoverReason is 'leader-backgrounded' here and 'leader-gone' after a close or a crash, and every leader publishes election.visibility beside its progress. A visible leader is never displaced, and switching tabs faster than the settle time moves nothing.
Visibility is read from the tab's document. A dedicated-worker host has none, so connectToIndexerHost reports the tab's visibility to it over the port, with nothing for you to wire. A SharedWorker host does not take part: it is one indexer for all its tabs rather than a background tab, so it is never displaced and never takes the lease. The takeover is on whenever the election is; turn it off with tabElection: {name: 'my-app', foregroundTakeover: false} (the leader then keeps the lock until it closes), or change the settle time with foregroundTakeover: {settleMs}.
And you can ask for it. indexer.demoteToReader('lease-lost') is the same code path, for an app that elects one indexing tab itself and wants the others to read. A tab demoted this way (or by a refused write) gives the election's lock back, so a reader can take over. Note that it is not the opposite of promote(): that moves the canonical pointer between generations, this drops the write duty over the storage they all fold into (ADR-0078).
How your app learns the state moved ​
Everything above tells you how the state gets there. This is how your UI finds out it did, without inventing a polling interval.
The side that applied the block tells the sides that are reading, and what it says is the same in every deployment (ADR-0083). Three things can be said, and they are a discriminated union on kind rather than one shape with optional fields, so a handler that reads block off a withdrawal does not compile:
type StateMoved =
| {kind: 'applied'; block: number; hash: string; coherence: string; entities: readonly string[]; generation: string}
| {kind: 'retracted'; forkPoint: number; coherence: string; generation: string}
| {kind: 'repointed'; coherence: string; generation: string};An applied block is named by its number AND its hash. hash is the hash the store recorded for that block, lower-case hex, which is exactly the extensions.blockHash a GraphQL answer names when it is read while that block is the tip. So you can pin a re-read to exactly the block you were told about (block: {hash}, see Re-query when the state moves), match a notification to an answer, or key a cache on it. A number cannot do any of that: a reorg puts another block at the same height.
Your whole rule is two lines. coherence is an opaque coherence token: compare it, never parse it.
let held: string | undefined;
port.onStateMoved((moved) => {
if (moved.coherence !== held) {held = moved.coherence; return invalidateEverything();}
if (moved.kind === 'applied') for (const entity of moved.entities) invalidate(entity);
});Who needs this, and who does not. An app holding createIndexerState(...) directly has the indexer in its own heap and already has state to subscribe to — that is the case that always worked, and it is why the hook itself has no onStateMoved. The signal is for the readers that heap does not reach: a tab whose indexer is in a worker (the default, and the port above), a tab that is not the one indexing, and an app pointed at a hosted indexer. On the main thread the port is connectToIndexerHost(indexer.mainThreadHost(), {watch: false}), which is a wire to the host that is already there rather than a second one.
Note what those two lines do not have to do. A reorg arrives with a rotated token, so the first line already covers it. A promotion (a different fold now answering your reads) is announced at once, as repointed with a token you have never held, so the first line covers it too, and it arrives even when the chain has gone quiet; the block after it carries that same token, so you invalidate everything once. And a notification you missed is covered by the same line at the next one, which is why nothing is buffered for you and why a producer's memory does not grow with the number of open tabs.
Three transports, one handler ​
Which object you attach it to is a deployment choice, and the handler does not change:
port.onStateMoved(handler); // a tab's port to its worker (@etherfold/browser)
tabs.onStateMoved(handler); // the cross-tab channel, in a tab with no host of its own
// GET /{indexer}/state-moved -- server-sent events, from a hosted indexer, same JSONThat is not a promise made in prose: one parameterised suite (@etherfold/state-moved-conformance) runs the same cases over all three and fails on the transport that drifted.
A handler you attach part way through is told nothing until the fold moves again. A notification is a thing that happened, so there is nothing current to hand you and replaying the last one would report a move that landed some time ago. What a freshly attached reader does instead is read — which is what it was going to do with the notification anyway. (onProgress is the opposite and deliberately so: how far the fold has got is a state, so subscribing to it answers with the current value.)
A tab that is not indexing gets the same signal over the cross-tab channel. The channel is named from the storage identity the fold writes into — the databaseName you passed to createBrowserStateStore — so two tabs of one app hear each other and two unrelated indexers on one origin never do:
const tabs = openStateMovedAcrossTabs({databaseName});
tabs.onStateMoved(handler); // every tab
port.onStateMoved(tabs.publish); // and, in a tab that holds a host, forward what it is toldSync progress rides that same channel (port.onProgress(tabs.publishProgress) / tabs.onProgress), so a reader tab renders "syncing, 400 blocks behind" from the host's own numbers rather than a second mechanism.
Wiring it to a cache you already use ​
Every client library's invalidation API is a plain callback, which is why the signal is a plain callback. With TanStack Query it is the two lines above and nothing else:
let held: string | undefined;
port.onStateMoved((moved) => {
if (moved.coherence !== held) {
held = moved.coherence;
return void queryClient.invalidateQueries(); // token changed: everything you hold may be wrong
}
if (moved.kind === 'applied') {
for (const entity of moved.entities) void queryClient.invalidateQueries({queryKey: [entity]});
}
});etherfold does not depend on TanStack Query, Apollo, urql or Houdini, and will not: which cache your app uses is your decision, and this is the whole of the integration.
One thing to know before you write the narrow half. entities carries entity names — 'token', 'counter' — which is your processor's vocabulary, and no cache library knows it. The coarse line composes with every library as it stands (queryClient.invalidateQueries(), Apollo's client.refetchQueries({include: 'active'}), urql's reexecuteOperation), because "invalidate everything" needs no vocabulary at all. The narrow line needs a mapping from an entity name to that library's own unit of invalidation, and how cheap that is depends on which library you picked: a query key you already control (TanStack Query, the example above — free, as long as you key your queries by entity name), a list of query names (Apollo), or the operations you chose to re-execute (urql). None of them offers "invalidate everything of type X" for nothing. Declare the mapping once, beside your queries; do not try to derive it. See work/notes/findings/what-the-state-moved-payload-costs-a-normalised-cache.md for why the payload is entity names rather than ids, and what that buys and costs.
Do not apply the delta by hand. The signal says what moved so that you re-read through the surface you already hold; it carries no rows, no mutations and no state handle, deliberately. A reader handed a delta applies it by hand, and applying a delta by hand is exactly what goes wrong at the next reorg.
Querying the state: the read surface, or GraphQL ​
There are two ways to read what the worker indexed, and they answer different questions (ADR-0099).
The read surface, for a few entities by id. createPortReadSurface(indexer, declarations) is typed from your entity declarations and reads one entity by id (current, or as of a block) or lists the rows under an id prefix, a declared relation's children included, in id order. It costs your bundle nothing, and for an app that shows a counter, a balance or the row the user is looking at, it is the whole answer:
const reads = createPortReadSurface(indexer, processor.entities);
const counter = await reads.counter.getCurrent({name: 'transfers'});It has no predicate and no ordering, deliberately (ADR-0021): IndexedDB has no query planner, so a read that looks cheap on it is a scan.
GraphQL, for a list. A list view wants a FILTER on a declared field, an ORDER on it, and a NESTED relation, and that is one GraphQL document. The schema is built from the same declarations, with no SDL and no codegen, and the relation is the one you declare on the child (ADR-0098):
entities: [
{name: 'account', id: ['address'], fields: {holds: 'integer'}},
// the child's leading id column is the parent's whole id; `as` names the collection on the parent
{name: 'holding', id: ['address', 'id'], fields: {}, parent: {entity: 'account', as: 'holdings'}},
],The resolvers need the store and the store is in the worker, so GraphQL is answered THERE. The worker entry opts in with one line, and the tab holds an executor over the port it already has:
// indexer.worker.ts
import {graphqlQueryHandler} from '@etherfold/graphql/worker';
hostIndexerInThisWorker({createState, createProcessor, query: graphqlQueryHandler()});
// the tab
import {workerExecutor} from '@etherfold/graphql/worker';
const execute = workerExecutor(indexer); // the port from connectToIndexerHost
const {data, errors, extensions} = await execute({
query: `query Holders($min: SafeInt!) {
account(where: {holds: {gte: $min}}, orderBy: {field: holds, direction: desc}, first: 10) {
address
holds
holdings(orderBy: {field: id, direction: asc}, first: 10) { id }
}
}`,
variables: {min: 1},
});
extensions; // {generation, block, blockHash}: which generation answered, and the one block every field was read as ofEvery list field takes where (per column eq, ne, lt, lte, gt, gte, in, isNull, combined with _and and _or), orderBy and a required first; a root field also takes block, to answer as of an earlier one. A nested collection is read for the whole page of parents in one batched read, bounded per parent. Every operation pins ONE block, so a parent and its children never come from two blocks, a reorg included. A u256 arrives as a decimal string (U256), because JSON has no bigint. The reference runs exactly this query against its worker, asserted in a real browser: examples/browser-reference.
A reader tab (above) answers the same queries from the store its leader writes, so a second tab is not a broken tab.
What GraphQL costs, which is how you choose ​
The worker pays for it, and only if you ask. @etherfold/browser never imports GraphQL: a worker entry that passes no query handler bundles none of it (asserted). Passing graphqlQueryHandler() adds 191.0 KiB minified, 48.3 KiB gzipped to the worker bundle, measured with esbuild (the measurement). That is off the thread that paints and off the first-paint path, since a worker's bundle is fetched when the worker starts.
The tab pays next to nothing. workerExecutor is a module of its own that imports only the executor contract, and @etherfold/graphql declares no import-time side effects, so a tab that imports it from @etherfold/graphql/worker bundles no graphql runtime and no schema (asserted). Measured on the reference with vite build, the tab chunk is 172.0 kB, 63.7 kB gzipped, against 63.2 kB gzipped before the tab imported the executor: about half a kilobyte, the executor and its error codes.
So the rule of thumb: an app that reads a few entities by id stays on the read surface and pays nothing; an app with a list view that filters, orders or nests takes GraphQL and pays about 48 KiB gzipped in its worker. You can use both side by side, as the reference does.
The same document, against a server ​
Which executor you inject is a deployment choice, not a code change. A hosted indexer (etherfold serve, and run and node) answers GraphQL at /graphql from the same schema, and httpExecutor is its executor:
import {httpExecutor} from '@etherfold/graphql';
import {workerExecutor} from '@etherfold/graphql/worker';
const execute = LOCAL ? workerExecutor(indexer) : httpExecutor('https://indexer.example/graphql');Nothing below that line changes, and neither does the answer: both executors serialise identically (U256 as a decimal string, Bytes as hex), share one error formatter and one set of codes, and report extensions: {generation, block, blockHash}. That is checked, not promised: one query conformance suite (@etherfold/graphql/conformance) runs the same requests against the in-process, HTTP and worker executors and requires the same JSON text byte for byte.
An executor never rejects. A refusal is a coded error in errors[].extensions.code. A transport that failed (an HTTP 500, a body that is not JSON, a network error, a closed port, a worker host that died) is normalised to ONE shape: no data, one error coded transport-failure naming its reason, which isTransportFailure(result) detects. So the error handling you write once holds for the mode that can return a 500 and the mode that cannot.
Bring your own client. etherfold does not pick urql, Apollo or Houdini for you. A client that takes a fetch takes executorToFetch(execute), which answers any request through the executor, the worker one included:
import {executorToFetch} from '@etherfold/graphql';
const client = new Client({url: '/graphql', fetch: executorToFetch(execute), exchanges: [fetchExchange]}); // urqlRe-query when the state moves ​
There are no subscriptions. The state-moved signal (above) is what tells you to re-read, and a GraphQL query is re-read by running the document again, PINNED to the block the signal named:
indexer.onStateMoved((moved) => void render(moved.kind === 'applied' ? moved.hash : undefined));
void render(); // the signal is silent on attaching, so read once by hand, at the tipPin it by the signal's hash, which the document takes as an optional BlockAddress variable, $block (omitted, the answer is read at the tip; BlockAddress is {hash} or {number}, unlike the bare Bytes32 hash the compose recipe below takes):
const HOLDERS = `query Holders($min: SafeInt!, $block: BlockAddress) {
account(block: $block, where: {holds: {gte: $min}}, orderBy: {field: holds, direction: desc}, first: 10) { address holds }
}`;The answer is then read at exactly the block you were told about, not at whatever the tip is by the time the query lands. If that block is replaced before the query runs (a reorg), the pinned query is REFUSED with block-not-recorded rather than answered from the replacement, so you read everything again (the retraction's rotated token is on its way too). Without the pin, a reader re-reading on a reorg can read the replacement block and briefly compose parts from two branches until the rotated token arrives.
Draw only the latest render's answer. Each signal starts a render, so on a busy chain several are in flight at once, and the worker answers them concurrently: the query pinned to block 4 can come back after the one pinned to block 5. Written as it lands, the older answer overwrites the newer one, and the page shows a past block until the chain moves again. Number the renders and drop an answer a later render has superseded (not by comparing extensions.block, which a reorg legitimately lowers):
let latestRender = 0;
async function render(at?: string) {
const mine = ++latestRender;
const variables = at === undefined ? {min: 1} : {min: 1, block: {hash: at}};
const {data, errors} = await execute({query: HOLDERS, variables}); // HOLDERS: the document above
if (mine !== latestRender) return; // a later render is drawing a newer answer
if (at !== undefined && errors?.[0]?.extensions?.code === 'block-not-recorded') return void render(); // replaced: read again
draw(data, errors);
}Compose several queries into one view by HASH, not by number. Every answer names the block it was read at, by number and by hash (extensions.block, extensions.blockHash), and every applied notification names its block the same two ways, with the same hash. To read a second part of the view at exactly that block, pin the follow-up to the hash, pool(block: {hash: $at}, first: 10) with $at: Bytes32! set to the first answer's blockHash. A number is a height, and a reorg can put another block at it between the two queries, which would mix two chains in one view without a word; a hash names one block of one chain, so the follow-up reads that block or is refused with block-not-recorded. Within ONE operation you need none of this: every field is read at one block, and an operation during which the store reverts is retried and never answered from two branches.
A client cache wires it the same way as any other read: the coherence-token rule from Wiring it to a cache you already use, with the entity names in entities mapped to the queries that read them.
The browser refuses past a bound, rather than getting slower ​
IndexedDB has no query planner, so the browser answers a where or an orderBy by scanning a key range and filtering in memory. That scan is BOUNDED by the number of rows it examines, 25,000 by default (the measured live set of a real deployment is about 4,000 rows), and past it the query is REFUSED with the code rows-examined-bound, naming the entity and the bound. It refuses rather than degrades so that a growing dataset produces an error you see in development, not a UI that gets slower every week. The bound counts rows, not milliseconds, so it refuses the same query identically on a laptop and a phone.
if (errors?.[0]?.extensions?.code === 'rows-examined-bound') {
// narrow the query (a tighter `where`, a smaller `first`), or raise the bound in the worker entry:
}
hostIndexerInThisWorker({createState, createProcessor, query: graphqlQueryHandler({accessor: {rowsExaminedBound: 100_000}})});A nested collection is bounded per parent, so one prolific parent cannot starve the others. A query with block: (a number or a hash) is served as the current rows plus the churn since that block, under the same bound, and that churn is the whole database's rather than the queried entity's: an as-of query on a quiet entity can be refused because other entities changed a lot. A block the store no longer retains is block-not-retained, the same code a server answers.
This is a documented difference between deployments, not a parity rule. A server answers through SQLite, which has a query planner and no such bound, so the same document can be answered by a server and refused by a browser. The conformance suite asserts each executor against the bound its deployment declares, so the difference is tested rather than discovered.
Telling whether the state already accounts for your transaction ​
Before an app lays an optimistic update over indexed state, it has to know whether the indexed state already contains the transaction's effects — because applied on top of a state that already has it, a non-idempotent update (a counter, a balance, an append) is counted twice.
const verdicts = indexer.checkTxInclusion([{txHash, minedAtBlock}]);
if (verdicts[txHash].status === 'included') dropOverlay(txHash); // else KEEP itYour own receipt cannot answer this. A block height is a local opinion about a chain rather than an identity, and the receipt's block hash is the wrong identity: a reorg can re-include the same transaction in a different block, so comparing hashes reports "not indexed" for a transaction that is indexed — producing exactly the double count the check exists to prevent. The question is about the indexer's own chain, so only the indexer answers it.
Three statuses, not two. 'unknown' is a real answer and collapsing it is what makes a wrong UI: treated as 'included' it double-counts, treated as 'absent' the effect briefly vanishes. Keep the overlay on anything that is not 'included'.
The pairing with state is safe in one direction. Within one update the hook sets syncing before state, so the cursor can be one statement ahead of the rows and never behind. An overlay dropped a moment early flickers; one dropped late is counted twice. A subscriber that reads both after an update sees them agree.
state publishes a HANDLE, not the state. On the entities path the value the store carries is a read handle with deliberately stable identity: the same object every time, so a caller that keeps one is not defeated by an update. An update is therefore a NOTIFICATION TO RE-READ rather than a delivery of new rows, and two things follow. Read your rows through the handle inside the subscriber, rather than treating the published value as data. And do not try to diff or snapshot it: the previous value is the same object as the current one, so "what changed since last time" is not a question this store can answer, and a value captured in a closure changes underneath you. The same is true of syncing, which is mutated in place. If you need a before/after, derive and keep what you care about yourself at the moment you are notified.
Two documented limits, both following from the unconfirmed window being sparse (event-bearing blocks only): a transaction that emitted no indexed event can never hit, and 'absent' means "not in the window", so do not ask about a transaction older than it. Pass minedAtBlock when you have a receipt to close both.
Hot reload: two independent axes ​
A template with hot contract replacement has two things that get replaced while the tab runs, and they fail differently.
Axis one — the processor was edited ​
updateProcessor decides whether your state survives by comparing the two folds' identities, and you do not get to state one (ADR-0086). A processor your dev server handed the tab arrives as a module object and has no bytes to hash, so it is named by a derivation over its handler sources.
So editing a reducer is a different fold, always: the swap is applied, the state is rebuilt under the new logic, and there is nothing to remember. Saving a file you did not change is the same fold, and the outcome says so.
const outcome = await indexer.updateProcessor(next);
outcome.stateDiscarded; // true => your edit is running, over state rebuilt for it
// false => nothing changed, and your warm fold was keptA rebuild replays from the start block, because the core cannot know which part of the state your edit invalidated, and "all of it" is the only answer that cannot be wrong. (addGeneration, below, is how to pay that without a blank app.)
What the derivation can and cannot see. It is taken over the handler source text, so it survives reformatting and re-ordering your handlers, and it does not survive minification or a change of transpiler — which is exactly why it names a module a dev server handed you and never a deployed build. A production app arrives as a self-contained bundle and is named by the SHA-256 of those octets, so the same code has a different identity as a module than as a bundle: a dev iteration and a deployed build are different generations either way.
In the other direction, it does not move for a change the source text does not carry — an edited helper your handler imports, an entity declaration you changed, behaviour decided by a value the handler captured. Pass {force: true} when you know better; it costs the same rebuild.
The same edit, without the blank app ​
updateProcessor reconfigures the generation that is answering reads, so the rebuild it costs is time your app has nothing to render. addGeneration is the other shape: it builds a generation beside the live one, which goes on answering every read while the new fold catches up.
// A store name per GENERATION, so the new fold cannot land in the live one's.
let saves = 0;
await indexer.addGeneration({
// ITS OWN store, and CLAIMED: folding is writing.
createState: async (context) =>
openForWriting(await createBrowserStateStore(next.entities, {databaseName: `app-${context.stream}-${++saves}`})),
createProcessor: (store) => fromEntityProcessor(next)(store),
});The new fold needs a store of its OWN, and that is the line to get right when you copy this. createBrowserStateStore with no databaseName opens etherfold-state, which is the database the live generation is already folding into, and two generations sharing one databaseName are ONE store by IndexedDB's own definition, so they would collide on the rows and on the sync cursor until the writer claim demoted one of them (ADR-0075, ADR-0077). So name it per generation: context.stream is what keeps two apps on one origin apart, and the counter is what keeps this fold apart from the one it replaces. openForWriting is the other half of the same rule: createState hands back a store this generation has CLAIMED, and that call is the only way to obtain one. The hot-update recipe below is these same two lines, driven by your bundler's saves.
A processor change does not move the fetch filter, so the new generation folds the stream that is already there and fetches not one log.
When the pointer moves to it is the promotion policy, passed to createIndexerState and defaulting to on-catch-up in every runtime:
| policy | when the new generation starts answering |
|---|---|
on-catch-up (default) | once it reaches the cursor the canonical one had — the app never shows state going backwards |
immediate | at once, before it has caught up — what you want while iterating, and an opt-in |
manual | only when you call promote(id) |
There is deliberately no development-versus-production default: nothing in a browser build can detect which it is in, so the safe value is the default everywhere and immediate is chosen explicitly.
Under immediate the new generation is canonical having folded nothing, so reads are incomplete for a moment — and checkTxInclusion says so, answering 'unknown' / 'not-synced' rather than reporting inclusion from the generation that no longer answers. Keep your overlays on anything that is not 'included' and this costs you nothing.
The old generation is kept, so promote(indexer.generations[0].record) moves the pointer back with no re-index and no fetch.
Wiring that to your bundler's hot update ​
reconfigureFromHotUpdate is the same thing with the outcome your reload indicator needs. Call it from your own import.meta.hot.accept(...) handler, with the module that handler receives:
import {reconfigureFromHotUpdate} from '@etherfold/browser';
let saves = 0;
if (import.meta.hot) {
import.meta.hot.accept('./processor.js', async (module) => {
if (!module) return;
const next = module.tokenProcessor;
const report = await reconfigureFromHotUpdate(indexer, {
// ITS OWN store. The successor folds beside the incumbent, which goes on
// writing its own rows, and two generations sharing one `databaseName`
// are one store by IndexedDB's own definition.
createState: async (context) =>
openForWriting(
await createBrowserStateStore(next.entities, {
databaseName: `app-${context.stream}-${++saves}`,
}),
),
createProcessor: (state) => fromEntityProcessor(next)(state),
});
switch (report.outcome) {
case 'registered':
return show(`your edit is folding beside the running generation (${report.generation.processor})`);
case 'unchanged':
return show(`nothing changed: the handlers are the fold already running, so your warm state was kept`);
case 'failed':
return show(`that save did not build, and nothing changed: ${report.message}`);
}
});
}This library subscribes to nothing. There is no reference to import.meta.hot anywhere in @etherfold/browser, and that is deliberate: noticing a change is your job, which is the same rule the server side follows — whatever watches a file stays outside the process. Your bundler is already the watcher. So a build with no HMR at all is unaffected by construction rather than by a guard, and because this is a plain function rather than a method on the indexer, the production build that eliminates your if (import.meta.hot) block drops it too.
Three outcomes, because "I saved and nothing happened" otherwise has three causes. ReconfigureReport is the same shape the server's POST /{indexer}/admin/upload answers when a bundle is uploaded to a running etherfold node, so the two arrivals share one contract (ADR-0085):
| outcome | what happened |
|---|---|
registered | your edit moved the identity, and it is folding beside the generation still answering your reads |
unchanged | the handler sources are the ones already running — a success, and rare, not a failure |
failed | the processor could not be built (you saved mid-edit). Nothing was registered and the tab is exactly as it was: same generations, same pointer, still folding, still answering |
A failed is the ordinary case in an editing loop, so it is data rather than an exception: the next save repairs it.
A burst stays bounded with nothing to do on your side. The successor slot holds at most one, so the fourth save replaces the third rather than landing beside it, and the count never climbs towards the browser's cap of two generations (ADR-0084).
There is no {force} here, and there cannot be: forcing means registering a generation beside one of the same name, and the name is the generation. For a change the handler text does not carry, updateProcessor(next, {force: true}) is the in-place verb — and it costs the rebuild this call exists to avoid.
When the indexer runs in a worker ​
In the worker shape the processor is code the worker imports, so the edited module has to reach the worker, and it does: under Vite a module worker (dedicated or shared) is an HMR client of its own, and an import.meta.hot.accept in the worker entry is handed the edited module with no page reload (the measurement). Hand it to the host, which is the same reconfigureFromHotUpdate run against the worker's indexer:
// indexer.worker.ts
import {createBrowserStateStore, hostIndexerInThisWorker, keepStreamOnIndexedDB} from '@etherfold/browser';
import {fromEntityProcessor, openForWriting} from '@etherfold/processor-entities';
import {tokenProcessor} from './processor.js';
const host = hostIndexerInThisWorker({
createState: async (context, {signal}) =>
openForWriting(await createBrowserStateStore(tokenProcessor.entities, {databaseName: `app-${context.stream}`}), {signal}),
createProcessor: (state) => fromEntityProcessor(tokenProcessor)(state),
// The logs, kept: the successor re-folds them instead of fetching the history again.
keepStream: keepStreamOnIndexedDB('app-stream'),
});
if (import.meta.hot) {
let saves = 0;
import.meta.hot.accept('./processor.js', (module) => {
if (!module) return;
const next = module.tokenProcessor;
void host.reconfigureFromHotUpdate({
// ITS OWN store, for the reason above.
createState: async (context, {signal}) =>
openForWriting(
await createBrowserStateStore(next.entities, {databaseName: `app-${context.stream}-${++saves}`}),
{signal},
),
createProcessor: (state) => fromEntityProcessor(next)(state),
});
});
}Everything above holds unchanged: the edit folds beside the live generation, which answers every read until the promotion policy moves the pointer, a burst stays bounded, and the processor is built inside the worker from the module the worker received, so its identity is derived there and nothing on the tab computes it. hostIndexerInThisSharedWorker returns the same host, so one save reaches the one host that serves every tab.
Your tab learns the outcome from the progress push, because the tab did not make the call. progress.hotUpdate is {count, report}: report is the same ReconfigureReport as above, and count goes up by one per update, so two saves with the same outcome are still two pushes.
let shown = 0;
indexer.onProgress((progress) => {
if (progress.hotUpdate && progress.hotUpdate.count !== shown) {
shown = progress.hotUpdate.count;
show(progress.hotUpdate.report); // the same switch as above
}
});Every module that imports the processor has to accept the update, or the save reloads the page. Vite sends an update through every importer, and one that does not accept it is a full reload, so the worker never gets its warm swap. A tab that imports the processor module for its ABI or its entity declarations (the reference does) accepts it and does nothing with it:
// in the tab
if (import.meta.hot) import.meta.hot.accept('./processor.js', () => {});A switch is announced on onStateMoved. When the pointer moves, reads answer from the new generation at once, and the move is published at once as a repointed notification with a rotated token, with no block to wait for. So a tab that re-reads on onStateMoved renders the new generation even on a chain that has gone quiet, and there is nothing to poll. The reference re-reads on that signal alone, for both axes.
The processor-bundle arrival (processorBundle) and a production build are untouched: the if (import.meta.hot) block is eliminated there, and a bundler without worker HMR simply never calls the verb, so a save reloads the page and the new worker folds the edit from the start.
Axis two — the contract was redeployed ​
On a local chain these apps deploy behind a proxy, so a redeploy does not move the address. What moves is the implementation and therefore the generated ABI — and the ABI is hashed into the indexing source, so handing the new source over is enough:
const outcome = await indexer.updateIndexer({source: {chainId, contracts: [next]}});reset() is not also required; calling it would be a second full rebuild.
If the ABI did not change, nothing is discarded — and that is correct rather than a gap. The same signatures over the same address still mean what the indexed rows say they mean.
The case that looks like it needs a third branch — an implementation that changed what its events mean while keeping their signatures — does not, because it cannot happen without a processor change. New meaning has to be implemented by new handler code, and writing that is the developer's job. So it travels axis one: edit the handlers, and the swap discards and re-indexes.
| what changed | the response |
|---|---|
| ABI changed, same address | updateIndexer({source}) — discards and re-indexes |
| event meaning changed | edit the processor — axis one |
| genesis hash changed (a different chain) | reload the page |
That last row is why a template's deployments store forces location.reload() only on a genesis change: a different chain invalidates the provider, the cursor and the store at once, and no in-place reconfigure covers that. Everything else takes the reactive path.
An upgrade that only adds events needs no feature ​
Worth knowing before you go looking for one. Decoding is by topic0 against the ABI you supply, so an upgraded implementation that emits a new event is indexed from block 0 simply by giving the source the union of both ABIs. Two entries at the same address also work, and merge:
contracts: [
{abi: [Transfer], address: X, startBlock: 0},
{abi: [Transfer, Approval], address: X, startBlock: 500},
]
// -> events indexed at that address: Transfer, ApprovalAdding the event does move the source hash, so it costs one re-index. That is the conservative default and it is correct: the indexer cannot know whether that event could already have been emitted in the blocks it has, and if it could, those logs were never fetched, because the topic was not in the filter.
The current limit is an upgrade that changes an existing event's signature. Two events sharing a name but not their inputs are refused today, even though their topic0s differ. Tracked in work/specs/tasked/an-upgraded-contract-is-indexable-from-its-first-block.md.
The one thing a rebuild does not do for you ​
A discard replays the whole history, including the blocks the previous implementation wrote. So a handler that merely implements the new meaning silently reinterprets pre-upgrade events under post-upgrade rules.
The upgrade block is your own knowledge, and spending it is ordinary handler code — event.blockNumber is on every event:
async onTransfer(state, event) {
const weight = event.blockNumber >= UPGRADE_BLOCK ? next : previous;
// ...
}A local chain restarted with each deploy never meets this. A chain that keeps its history always does.
Both axes report what they did ​
updateProcessor, updateIndexer and reset return a ReconfigureOutcome. Branch on it rather than re-deriving the rule — the rule includes force, the entity declarations and the source hashes, and a caller who gets that derivation wrong fails silently.
stateDiscarded: boolean is the whole of it for most apps. Use it to tell the user their data is being rebuilt. You do not need it to clear your own copy of the state: the hook re-seeds its state store at the moment of a discard, so subscribers never see state the core has thrown away.
sourceInvalidation is the verdict that one bit was collapsed from, and it is there for the caller who wants to do something other than discard:
const outcome = await indexer.updateIndexer({source});
outcome.sourceInvalidation; // SourceInvalidation | undefined
// {state: {valid: false, invalidFromBlock: 780, reason: 'entry-added'}, stream: {valid: true}}Two halves, because the fetch and the fold do not depend on the same thing. stream is about the raw logs, fetched under a topic-and-address filter, so it survives anything that did not GROW that filter; state is about the fold over decoded events, so it dies whenever the decoding shape moved. A renamed non-indexed parameter is the case that proves they are two questions: topic0 hashes types and not names, so every cached log is still right and every cached args is filed under a key the handler no longer reads. Each half names the block it stopped being valid from.
It is undefined on updateProcessor and reset, which ask no source question: a processor swap moves neither the filter nor the decode, and reset is a discard by fiat that also clears the cached stream.
What it is not is a digest comparison. streamDigestOf MOVES when an event is appended above the cursor, and that append is free — the verdict is what decides whether a reconfigure invalidates anything, and the digest only decides which stream a result belongs to.