Skip to content

Documentation / @etherfold/browser / createIndexerState

Function: createIndexerState() ​

createIndexerState<ABI, ProcessResultType, ProcessorConfig>(spec, options?): object

Defined in: packages/browser/src/IndexerState.ts:613

THE BROWSER INDEXING HOOK, AND THE MAIN-THREAD indexer host (ADR-0082).

ts
// the state (and its cursor) live in a store the app chose, and a GENERATION
// builds its own: the hook is handed the factories, not their results
const indexer = createIndexerState({
  // the signal bounds the CLAIM, so a storage that never answers is a refusal
  // an app can render rather than a wait with no end (see `createState`)
  createState: async (context, {signal}) =>
    openForWriting(await createBrowserStateStore(myProcessor.entities), {signal}),
  createProcessor: (store) => fromEntityProcessor(myProcessor)(store),
});

It is a HOST, and mainThreadHost() is the port to it ​

A host owns a container and drives it; three hosting shapes exist in a browser and they differ ONLY in how a port is obtained. This function is the MAIN-THREAD one -- it owns the container, opens the store for WRITING and runs the loop -- so an app that wants the port surface rather than the reactive triple joins a wire to it and never constructs a second indexer:

ts
const port = connectToIndexerHost(indexer.mainThreadHost(), {watch: false});

What that buys is that the code an app writes AGAINST the port is identical across the three shapes, so moving the fold into a worker later is a change to one line of wiring. What it COSTS here is the whole reason a dedicated worker is the default: the fold runs on the UI thread, so a tab that hosts its own indexer janks while it renders.

There is deliberately no second main-thread constructor beside this one (ADR-0082). serveIndexerHost is the WORKER hosts' driver, reached through hostIndexerInThisWorker / hostIndexerInThisSharedWorker from inside a worker entry point; what runs on this thread is this function.

Where the state is persisted, and by whom ​

NOT here, and not by a keeper this hook holds. The processor persists through the store it CLAIMED, which writes the sync cursor in the SAME transaction as the block it describes (ADR-0027) -- which is why the cursor lives behind the storage seam at all. The invariant that buys is that a processor's state and its cursor never diverge: a reader never comes back to state that has advanced past its recorded position, or the reverse, however the tab died.

This used to be one of two persistence models, the other being a KeepState keeper that wrote {state, lastSync} as one blob because a blob has no transaction to join. That path is deleted (ADR-0037), and with it the kind tag that told the two apart and the keepState option that fed one of them.

And what a RELOAD does ​

The state comes back from the STORE, so how far it survives is a property of the backend the application chose: versioned rows in IndexedDB (the default, ADR-0024) resume from the cursor, and @etherfold/state-store-patch is memory-only by design (ADR-0023) and starts over. That is not a defect of the light store; it reports durability: 'memory-only' in its capabilities, which the read handle exposes, so an app can learn it at startup instead of from an empty tab.

Type Parameters ​

ABI ​

ABI extends Abi

ProcessResultType ​

ProcessResultType

ProcessorConfig ​

ProcessorConfig = undefined

Parameters ​

spec ​

BrowserGenerationSpec<ABI, ProcessResultType, ProcessorConfig>

options? ​

catchupThreshold? ​

number

catchUpWithinSeconds? ​

number | "always"

HOW LONG A RETURNING TAB MAY SPEND CATCHING UP before it starts from the published snapshot instead, in seconds (ADR-0096). Defaults to DEFAULT_CATCH_UP_WITHIN_SECONDS (thirty).

It applies to a tab that already HOLDS local state and whose publication names a snapshot for its generation further along than that state. Such a tab catches up from its own cursor; after every advance it estimates how long the rest of the gap to the tip will take, from the blocks the advances so far covered and the time they took, and if that estimate exceeds this budget it ABANDONS the catch-up: the generation is built again with createState handed replaceLocal: true, the existing install wipes the local state and installs the snapshot, and indexing resumes from the snapshot's cursor. The switch is reported on syncing.publication as switched / over-budget, with the estimate and this budget.

'always' means always catch up yourself, however long it takes. The snapshot is then taken only on a failure no catch-up survives: a node that refuses the catch-up as an archive refusal (ArchiveRefusedError), which switches a tab with a usable snapshot whatever this says (switched / archive-refused), and is reported exactly as before where there is none.

ONE value. Choosing it per chain (a slow chain or a rate-limited node wants a smaller one) is the APP's business, for instance from its deployment tooling's chain info: this package carries no per-chain table. A fresh tab is unaffected, since it starts from the snapshot anyway.

claimWithinSeconds? ​

number

How long this indexer waits for a generation's WRITER CLAIM, in seconds. Defaults to DEFAULT_CLAIM_WITHIN_SECONDS (ten).

It is handed to createState as a signal to forward to openForWriting, and it exists because a claim CAN hang: a store whose storage never answers leaves an application in waiting for ever with nothing to render (utils/claim.ts says why, and which engine made it necessary). Ten seconds is a thousand times a healthy claim.

Raise it for a storage that is genuinely slow to take a claim. It does NOT bound the rest of createState -- a snapshot install is not measured by it -- and it cannot be enforced, because the factory is the application's: a createState that ignores the signal waits exactly as long as it used to.

createIndexer? ​

(provider, processor, source, config, processorIdentity) => IndexerGeneration<ABI, ProcessResultType>

keepStream? ​

ExistingStream<ABI>

logRequests? ​

boolean

promotion? ​

PromotionConfig

WHEN the canonical pointer moves to a generation added beside the live one.

PASSED THROUGH and never defaulted here. on-catch-up is the default in every runtime, and this hook deliberately does not select one of its own: the axis that would justify a browser-specific value is DEVELOPMENT versus PRODUCTION, and nothing in a browser build can detect which it is in, so a runtime default would be a guess with immediate's consequences. A developer who wants their edit to answer straight away says {promotion: {policy: 'immediate'}}; a shipped app says nothing and gets the safe one.

pruneBudget? ​

number

How many versions ONE of this loop's scheduled prunes may delete. Defaults to DEFAULT_PRUNE_BUDGET.

The budget is per PASS, and the loop comes back on its next cycle for whatever a pass could not finish, so this is a smoothness knob rather than a limit on what is reclaimed. Lower it for a tab doing animation work beside its indexing; raise it for one that has just been given a window after running unbounded and wants the backlog gone sooner.

What it is NOT is a way to turn pruning off. A store prunes because its retention states a FLOOR, and a deployment that wants nothing dropped says so where retention is configured (unbounded, which is the default) rather than by starving the schedule -- a store bounded in what it answers and unbounded in what it holds is strictly worse than either honest position.

publication? ​

BrowserPublicationOptions

START FROM A PUBLICATION INDEX (publication.json, ADR-0095): the list of locations a build publishes one at, failed over between in order.

At init the hook reads the first index any location serves and picks the STATE SNAPSHOT entry for the generation it builds, by that generation's stream digest AND processor identity: the entry is handed to createState (its fourth argument), which starts from it through the existing bootstrap. An entry for this processor over another stream is refused by name and nothing is installed. The STREAM SEED the index lists for this stream is installed into keepStream ONLY when seed asks for it; by default only the index and the snapshot are fetched. What the lookup gave is on syncing.publication.

The snapshot entry is chosen by identity before the state is built, so the generation needs one by then: run the published bundle (processorBundle).

seed? ​

BrowserStreamSeedOptions

INSTALL A PUBLISHED STREAM SEED into keepStream at init, before the generation loads.

The documented default way to seed a stream, and a convenience over calling installStreamSeed yourself: this hook sequences the call and publishes what it did on syncing.streamSeed and status.state. See BrowserStreamSeedOptions for the trust contract, which is the caller's.

It needs a keepStream, because a seed is a stream and there is nothing to install into without one; asking for a seed with no keeper RAISES at init rather than being reported as a refusal, since it is a wiring mistake in the caller's own source and no location makes it right.

tabElection? ​

TabElection

ONE TAB INDEXES AND THE OTHERS READ (ADR-0097): the name of the Web Lock the app's tabs elect their indexing tab with. ON only together with the spec's reader factory (openState) and where navigator.locks exists; otherwise this hook behaves exactly as it did without it.

A tab that finds the lock free is the writer and init resolves as it always did. A tab that finds it held is built from openState instead: init resolves with no container, state answers from the shared store, reads over mainThreadHost() answer from it too, and the port reports the LEADER's progress. startAutoIndexing() is remembered rather than run, and every advance answers undefined. When the lock is released (the leader's tab closed or crashed) this tab takes over: it builds its generation through createState, claims, and indexes forward from the stored cursor, starting the auto-index loop if it was asked for.

With foregroundTakeover on (the default), this tab's document.visibilityState decides the rest: a reader visible past the settle time while the leader is hidden takes the lease, and a leader whose lease is taken DEMOTES (syncing.demotion.reason === 'lease-lost'), abandons its in-flight batch, reads again from openState and queues for the lock once more.

trackNumRequests? ​

boolean

Returns ​

checkTxInclusion ​

checkTxInclusion: (queries) => Record<string, TxInclusionVerdict>

Does the state in $state already account for these transactions?

The reconciliation an app needs before it lays an optimistic update over indexed state: applied twice, a non-idempotent update (a counter, a balance, an append) is wrong. See checkTxInclusion in @etherfold/core for what the verdicts mean, why the caller's own receipt cannot answer this, and what it cannot tell you.

Answered against the CURRENT cursor and the indexer's own configured finality depth, so a caller never has to keep a second copy of either. The pairing with $state is close but not transactional: the core writes the state through the processor BEFORE it publishes the cursor, and this hook then sets syncing before state, so within one synchronous update the cursor can be one statement ahead of the state store and never behind. That direction is the safe one -- an overlay dropped a moment early flickers, one dropped late is counted twice -- and a subscriber that reads both after the update sees them agree.

Parameters ​

queries ​

readonly TxInclusionQuery[]

Returns ​

Record<string, TxInclusionVerdict>

dispose ​

dispose: () => void

Returns ​

void

indexMore ​

indexMore: () => Promise<LastSync<ABI> | undefined>

Returns ​

Promise<LastSync<ABI> | undefined>

indexMoreAndCatchupIfNeeded ​

indexMoreAndCatchupIfNeeded: () => Promise<LastSync<ABI> | undefined>

Returns ​

Promise<LastSync<ABI> | undefined>

indexToLatest ​

indexToLatest: () => Promise<LastSync<ABI> | undefined>

Index to the tip, retrying a TRANSIENT failure on a timer -- and stopping on everything else.

The distinction is the whole reason whileWriting exists here: this loop swallows failures and comes back a second later, which is right for a rate limit and catastrophic for a refusal that will be repeated for ever.

There are two kinds of "else" and they leave by different doors. A DEMOTED run answers undefined and returns; syncing.demotion says why. A NON-RETRYABLE refusal (isRetryable, read structurally off the error) is re-thrown to the caller, because it is neither transient nor a lost race: the store is telling this writer that the write itself is wrong -- a height the tip has passed, a block already recorded -- and a store does not move on its own, so the same offer is refused identically for ever. Swallowing it here is what turned a permanent refusal into a silent infinite re-fetch.

Returns ​

Promise<LastSync<ABI> | undefined>

init ​

init: InitFunction<ABI, ProcessorConfig>

reset ​

reset: () => Promise<ReconfigureOutcome>

Throw the computed state away and rebuild it from the start block.

A one-line delegation, because the CONTAINER publishes the discard (Indexer.publishDiscard, @etherfold/core): reset IS a discard, and the copy this hook holds goes at the same moment the fold's does, through the ordinary state notification. This hook used to fill that silence itself, back when it could be handed one already-built processor and there was no container underneath to know.

Returns ​

Promise<ReconfigureOutcome>

startAutoIndexing ​

startAutoIndexing: (intervalInSeconds) => Promise<boolean>

Parameters ​

intervalInSeconds? ​

number = 4

Returns ​

Promise<boolean>

state ​

state: object

state.subscribe ​

subscribe: (this, run, invalidate?) => Unsubscriber = readableState.subscribe

Subscribe on value changes.

Parameters ​
this ​

void

run ​

Subscriber<ProcessResultType>

subscription callback

invalidate? ​

Invalidator<ProcessResultType>

cleanup callback

Returns ​

Unsubscriber

state.$state ​

Get Signature ​

get $state(): ProcessResultType

Defined in: packages/browser/src/IndexerState.ts:2510

Returns ​

ProcessResultType

status ​

status: object

status.subscribe ​

subscribe: (this, run, invalidate?) => Unsubscriber = readableStatus.subscribe

Subscribe on value changes.

Parameters ​
this ​

void

run ​

Subscriber<StatusState>

subscription callback

invalidate? ​

Invalidator<StatusState>

cleanup callback

Returns ​

Unsubscriber

status.$state ​

Get Signature ​

get $state(): StatusState

Defined in: packages/browser/src/IndexerState.ts:2516

Returns ​

StatusState

stopAutoIndexing ​

stopAutoIndexing: () => boolean

Returns ​

boolean

syncing ​

syncing: object

syncing.subscribe ​

subscribe: (this, run, invalidate?) => Unsubscriber = readableSyncing.subscribe

Subscribe on value changes.

Parameters ​
this ​

void

run ​

Subscriber<SyncingState<ABI>>

subscription callback

invalidate? ​

Invalidator<SyncingState<ABI>>

cleanup callback

Returns ​

Unsubscriber

syncing.$state ​

Get Signature ​

get $state(): SyncingState<ABI>

Defined in: packages/browser/src/IndexerState.ts:2504

Returns ​

SyncingState<ABI>

canonical ​

Get Signature ​

get canonical(): HeldGeneration<ABI, ProcessResultType> | undefined

The generation that answers reads right now.

Returns ​

HeldGeneration<ABI, ProcessResultType> | undefined

demotion ​

Get Signature ​

get demotion(): Demotion | undefined

WHY this tab stopped writing, or undefined while it still is one.

Returns ​

Demotion | undefined

generations ​

Get Signature ​

get generations(): readonly HeldGeneration<ABI, ProcessResultType>[]

Every generation this indexer holds, in the order it built them.

Returns ​

readonly HeldGeneration<ABI, ProcessResultType>[]

promotion ​

Get Signature ​

get promotion(): UsedPromotionConfig | undefined

The promotion policy in force, resolved.

Reported rather than re-derived, so "which value is this app running under" is answered by the container that applies it and not by a second copy of the default living here. undefined before init, because the container that holds it does not exist yet.

Returns ​

UsedPromotionConfig | undefined

addGeneration() ​

addGeneration(generation, processorConfig?): Promise<HeldGeneration<ABI, ProcessResultType>>

RECONFIGURE WITHOUT AN OUTAGE: build a generation BESIDE the live one.

This is what a reconfigure is under the generation model, and it is why one is not an outage: the new generation folds alongside the canonical one, which goes on answering every read until the promotion policy moves the pointer (stories 1 and 3). A generation on the SAME stream -- a processor change, which is the common case -- fetches not one log: it re-folds the stream that is already there and then follows it (ADR-0044).

Distinct from updateProcessor, which reconfigures the canonical generation IN PLACE and therefore still costs the discard-and-rebuild it always did.

When the pointer moves is the POLICY's (promotion), not this call's: on-catch-up (the default everywhere) moves it once the new generation reaches the cursor the canonical one had, immediate moves it here and now, and manual waits for promote.

Parameters ​

generation ​
createProcessor ​

(state, context, bundle?) => EntityEventProcessorLike<ABI, ProcessResultType, ProcessorConfig> | Promise<EntityEventProcessorLike<ABI, ProcessResultType, ProcessorConfig>>

createState ​

(context, patience, bundle?, published?) => WritableStateStore | Promise<WritableStateStore>

processorBundle? ​

ProcessorBundleSource

A published bundle to run instead, named by its own bytes (see BrowserGenerationSpec.processorBundle).

processorIdentity? ​

string

WHAT NAMES THIS GENERATION, from the arrival that produced it: the same value init takes and with the same meaning (ADR-0086). A reconfigure is a NEW fold beside the live one, so it arrives on its own and carries its own identity rather than inheriting the running generation's.

processorConfig? ​

ProcessorConfig

Returns ​

Promise<HeldGeneration<ABI, ProcessResultType>>

demoteToReader() ​

demoteToReader(reason?): Demotion

STOP BEING A WRITER: drop the cursor, stop fetching, go on answering reads.

It is not the inverse of promote. That moves the canonical POINTER between generations of this indexer; this drops the WRITE DUTY over the storage they fold into, and every generation is a reader afterwards.

The hook calls it for itself when the store refuses a write ('write-refused'), which is a lost race and not an application error. A caller calls it with 'lease-lost' when this tab is told another one holds the write duty -- the one code path for both, which is what leader election needs of this package (work/specs/tasked/one-tab-indexes-and-the-others-read.md) and the whole of what it needs.

It is ONE-WAY for this container: a store that lost is never re-claimed (ADR-0077), so indexing again means dispose() and a fresh init over a store built fresh -- which re-reads everything, as it must.

Parameters ​

reason? ​

DemotionReason = 'lease-lost'

Returns ​

Demotion

mainThreadHost() ​

mainThreadHost(options?): HostAccess

OBTAIN A PORT TO THE INDEXER RUNNING ON THIS THREAD: the MAIN-THREAD hosting shape (ADR-0082).

ts
const indexer = createIndexerState({createState, createProcessor});
await indexer.init({provider, source});
const port = connectToIndexerHost(indexer.mainThreadHost(), {watch: false});

The third of the three shapes, and the only one that constructs nothing: the two worker shapes obtain a port by BUILDING a host (dedicatedWorkerHost, sharedWorkerHost, each handed the line that constructs a worker), while the host on this thread is THIS OBJECT and already exists. That asymmetry is the decision, not an accident: a top-level mainThreadHost(spec) would be a second way to build a main-thread indexer with no rule for choosing between them.

What crosses is what crosses to a worker, because the wire is a real MessageChannel: the same envelope, the same structured-clone refusals, the same four reads projected by the same code. What an app writes AGAINST the port is therefore identical across the three shapes, which is what makes moving the fold off the UI thread later a change to one line of wiring.

Three things worth knowing before reaching for it ​

The fold is on the UI thread. That is the cost, and it is why the guide leads with a dedicated worker.

Pass {watch: false}. A host on this thread cannot die independently of the tab holding the port, so the liveness probe has nothing to find; the access carries no reopen either, so a port that somehow concluded a death would honestly report restarting: false.

It may be called more than once, and each call is its own wire with its own subscription -- the same thing a SharedWorker does for several tabs. Letting a port go (close()) releases only that wire; the indexer goes on folding, because it belongs to the app and not to the port. dispose() is what stops it.

It answers queries only if told how (ADR-0099): pass {query} (for GraphQL, graphqlQueryHandler() from @etherfold/graphql/worker) and hold workerExecutor(port); without it the port's query is refused.

Parameters ​

options? ​

HostQueryOptions = {}

Returns ​

HostAccess

promote() ​

promote(id): Promise<GenerationRecord>

MOVE THE CANONICAL POINTER by hand: forwards it promotes, backwards it REVERTS.

Never gated by the promotion policy, under any of its values: the policy decides the move this library makes ON ITS OWN, and manual means "only when asked" rather than "never". The revert is exact and costs no re-index, because the generation it names was never touched.

Parameters ​

id ​

GenerationId

Returns ​

Promise<GenerationRecord>

updateIndexer() ​

updateIndexer(update): Promise<ReconfigureOutcome>

Parameters ​

update ​
provider? ​

EIP1193ProviderWithoutEvents

source? ​

IndexingSource<ABI>

streamConfig? ​

ProvidedStreamConfig

Returns ​

Promise<ReconfigureOutcome>

updateProcessor() ​

updateProcessor(newProcessor, options?): Promise<ReconfigureOutcome>

Swap the processor in place.

It takes the same shape the hook does, so a live-reload that rebuilds a processor does not have to unwrap it by hand. The core is handed the EventProcessor and decides whether the state survives by comparing the two folds' IDENTITIES -- the one running here, and the one derived from the module being handed over. An edited handler is therefore a different fold and the swap is APPLIED, with no version to bump and nothing for an author to remember; a save that changed nothing is the same fold and is SKIPPED, which the outcome says (stateDiscarded: false) rather than leaving a developer guessing.

When the core does discard, $state is republished at that moment rather than left holding the old value until the next event overwrites it -- a wait that used to be unbounded, since a processor swapped in against a freshly redeployed contract has nothing to replay. The CONTAINER is what does that now (Indexer.publishDiscard, @etherfold/core), so it reaches every consumer of one and not this hook's subscribers alone.

WHY THERE IS NO processorIdentity HERE, when a generation spec has one ​

ADR-0086 derives an identity PER ARRIVAL, and what arrives at this call is a MODULE OBJECT a dev server handed the tab: there are no bytes to hash, so an app cannot supply what a bundle's arrival supplies, and letting it state one anyway would be the author-declared identity coming back through the one door left open -- silent, by construction, whenever it is wrong. So this call derives the identity ITSELF, from the module's handler sources (moduleProcessorIdentity, which records what that derivation survives and why a dev server is the only runtime it is sound in), and takes nothing from the caller but the processor and {force}.

{force: true} is what an integrator reaches for when they know the fold changed in a way the SOURCE TEXT does not carry -- an edited helper the handler imports, or behaviour decided by a captured value. It costs the same full rebuild a different identity costs.

Parameters ​

newProcessor ​

EntityEventProcessorLike<ABI, ProcessResultType, ProcessorConfig>

options? ​
force? ​

boolean

Returns ​

Promise<ReconfigureOutcome>

withHooks() ​

withHooks(react): object

Parameters ​

react ​

ReactHooks

Returns ​

canonical ​

canonical: HeldGeneration<ABI, ProcessResultType> | undefined

checkTxInclusion ​

checkTxInclusion: (queries) => Record<string, TxInclusionVerdict>

Does the state in $state already account for these transactions?

The reconciliation an app needs before it lays an optimistic update over indexed state: applied twice, a non-idempotent update (a counter, a balance, an append) is wrong. See checkTxInclusion in @etherfold/core for what the verdicts mean, why the caller's own receipt cannot answer this, and what it cannot tell you.

Answered against the CURRENT cursor and the indexer's own configured finality depth, so a caller never has to keep a second copy of either. The pairing with $state is close but not transactional: the core writes the state through the processor BEFORE it publishes the cursor, and this hook then sets syncing before state, so within one synchronous update the cursor can be one statement ahead of the state store and never behind. That direction is the safe one -- an overlay dropped a moment early flickers, one dropped late is counted twice -- and a subscriber that reads both after the update sees them agree.

Parameters ​
queries ​

readonly TxInclusionQuery[]

Returns ​

Record<string, TxInclusionVerdict>

demotion ​

demotion: Demotion | undefined

dispose ​

dispose: () => void

Returns ​

void

generations ​

generations: readonly HeldGeneration<ABI, ProcessResultType>[]

indexMore ​

indexMore: () => Promise<LastSync<ABI> | undefined>

Returns ​

Promise<LastSync<ABI> | undefined>

indexMoreAndCatchupIfNeeded ​

indexMoreAndCatchupIfNeeded: () => Promise<LastSync<ABI> | undefined>

Returns ​

Promise<LastSync<ABI> | undefined>

indexToLatest ​

indexToLatest: () => Promise<LastSync<ABI> | undefined>

Index to the tip, retrying a TRANSIENT failure on a timer -- and stopping on everything else.

The distinction is the whole reason whileWriting exists here: this loop swallows failures and comes back a second later, which is right for a rate limit and catastrophic for a refusal that will be repeated for ever.

There are two kinds of "else" and they leave by different doors. A DEMOTED run answers undefined and returns; syncing.demotion says why. A NON-RETRYABLE refusal (isRetryable, read structurally off the error) is re-thrown to the caller, because it is neither transient nor a lost race: the store is telling this writer that the write itself is wrong -- a height the tip has passed, a block already recorded -- and a store does not move on its own, so the same offer is refused identically for ever. Swallowing it here is what turned a permanent refusal into a silent infinite re-fetch.

Returns ​

Promise<LastSync<ABI> | undefined>

init ​

init: InitFunction<ABI, ProcessorConfig>

promotion ​

promotion: UsedPromotionConfig | undefined

reset ​

reset: () => Promise<ReconfigureOutcome>

Throw the computed state away and rebuild it from the start block.

A one-line delegation, because the CONTAINER publishes the discard (Indexer.publishDiscard, @etherfold/core): reset IS a discard, and the copy this hook holds goes at the same moment the fold's does, through the ordinary state notification. This hook used to fill that silence itself, back when it could be handed one already-built processor and there was no container underneath to know.

Returns ​

Promise<ReconfigureOutcome>

startAutoIndexing ​

startAutoIndexing: (intervalInSeconds) => Promise<boolean>

Parameters ​
intervalInSeconds? ​

number = 4

Returns ​

Promise<boolean>

state ​

state: object

state.subscribe ​

subscribe: (this, run, invalidate?) => Unsubscriber = readableState.subscribe

Subscribe on value changes.

Parameters ​
this ​

void

run ​

Subscriber<ProcessResultType>

subscription callback

invalidate? ​

Invalidator<ProcessResultType>

cleanup callback

Returns ​

Unsubscriber

state.$state ​
Get Signature ​

get $state(): ProcessResultType

Defined in: packages/browser/src/IndexerState.ts:2510

Returns ​

ProcessResultType

status ​

status: object

status.subscribe ​

subscribe: (this, run, invalidate?) => Unsubscriber = readableStatus.subscribe

Subscribe on value changes.

Parameters ​
this ​

void

run ​

Subscriber<StatusState>

subscription callback

invalidate? ​

Invalidator<StatusState>

cleanup callback

Returns ​

Unsubscriber

status.$state ​
Get Signature ​

get $state(): StatusState

Defined in: packages/browser/src/IndexerState.ts:2516

Returns ​

StatusState

stopAutoIndexing ​

stopAutoIndexing: () => boolean

Returns ​

boolean

syncing ​

syncing: object

syncing.subscribe ​

subscribe: (this, run, invalidate?) => Unsubscriber = readableSyncing.subscribe

Subscribe on value changes.

Parameters ​
this ​

void

run ​

Subscriber<SyncingState<ABI>>

subscription callback

invalidate? ​

Invalidator<SyncingState<ABI>>

cleanup callback

Returns ​

Unsubscriber

syncing.$state ​
Get Signature ​

get $state(): SyncingState<ABI>

Defined in: packages/browser/src/IndexerState.ts:2504

Returns ​

SyncingState<ABI>

useState ​

useState: () => ProcessResultType

Returns ​

ProcessResultType

useStatus ​

useStatus: () => StatusState

Returns ​

StatusState

useSyncing ​

useSyncing: () => SyncingState<ABI>

Returns ​

SyncingState<ABI>

addGeneration() ​

addGeneration(generation, processorConfig?): Promise<HeldGeneration<ABI, ProcessResultType>>

RECONFIGURE WITHOUT AN OUTAGE: build a generation BESIDE the live one.

This is what a reconfigure is under the generation model, and it is why one is not an outage: the new generation folds alongside the canonical one, which goes on answering every read until the promotion policy moves the pointer (stories 1 and 3). A generation on the SAME stream -- a processor change, which is the common case -- fetches not one log: it re-folds the stream that is already there and then follows it (ADR-0044).

Distinct from updateProcessor, which reconfigures the canonical generation IN PLACE and therefore still costs the discard-and-rebuild it always did.

When the pointer moves is the POLICY's (promotion), not this call's: on-catch-up (the default everywhere) moves it once the new generation reaches the cursor the canonical one had, immediate moves it here and now, and manual waits for promote.

Parameters ​
generation ​
createProcessor ​

(state, context, bundle?) => EntityEventProcessorLike<ABI, ProcessResultType, ProcessorConfig> | Promise<EntityEventProcessorLike<ABI, ProcessResultType, ProcessorConfig>>

createState ​

(context, patience, bundle?, published?) => WritableStateStore | Promise<WritableStateStore>

processorBundle? ​

ProcessorBundleSource

A published bundle to run instead, named by its own bytes (see BrowserGenerationSpec.processorBundle).

processorIdentity? ​

string

WHAT NAMES THIS GENERATION, from the arrival that produced it: the same value init takes and with the same meaning (ADR-0086). A reconfigure is a NEW fold beside the live one, so it arrives on its own and carries its own identity rather than inheriting the running generation's.

processorConfig? ​

ProcessorConfig

Returns ​

Promise<HeldGeneration<ABI, ProcessResultType>>

demoteToReader() ​

demoteToReader(reason?): Demotion

STOP BEING A WRITER: drop the cursor, stop fetching, go on answering reads.

It is not the inverse of promote. That moves the canonical POINTER between generations of this indexer; this drops the WRITE DUTY over the storage they fold into, and every generation is a reader afterwards.

The hook calls it for itself when the store refuses a write ('write-refused'), which is a lost race and not an application error. A caller calls it with 'lease-lost' when this tab is told another one holds the write duty -- the one code path for both, which is what leader election needs of this package (work/specs/tasked/one-tab-indexes-and-the-others-read.md) and the whole of what it needs.

It is ONE-WAY for this container: a store that lost is never re-claimed (ADR-0077), so indexing again means dispose() and a fresh init over a store built fresh -- which re-reads everything, as it must.

Parameters ​
reason? ​

DemotionReason = 'lease-lost'

Returns ​

Demotion

mainThreadHost() ​

mainThreadHost(options?): HostAccess

OBTAIN A PORT TO THE INDEXER RUNNING ON THIS THREAD: the MAIN-THREAD hosting shape (ADR-0082).

ts
const indexer = createIndexerState({createState, createProcessor});
await indexer.init({provider, source});
const port = connectToIndexerHost(indexer.mainThreadHost(), {watch: false});

The third of the three shapes, and the only one that constructs nothing: the two worker shapes obtain a port by BUILDING a host (dedicatedWorkerHost, sharedWorkerHost, each handed the line that constructs a worker), while the host on this thread is THIS OBJECT and already exists. That asymmetry is the decision, not an accident: a top-level mainThreadHost(spec) would be a second way to build a main-thread indexer with no rule for choosing between them.

What crosses is what crosses to a worker, because the wire is a real MessageChannel: the same envelope, the same structured-clone refusals, the same four reads projected by the same code. What an app writes AGAINST the port is therefore identical across the three shapes, which is what makes moving the fold off the UI thread later a change to one line of wiring.

Three things worth knowing before reaching for it ​

The fold is on the UI thread. That is the cost, and it is why the guide leads with a dedicated worker.

Pass {watch: false}. A host on this thread cannot die independently of the tab holding the port, so the liveness probe has nothing to find; the access carries no reopen either, so a port that somehow concluded a death would honestly report restarting: false.

It may be called more than once, and each call is its own wire with its own subscription -- the same thing a SharedWorker does for several tabs. Letting a port go (close()) releases only that wire; the indexer goes on folding, because it belongs to the app and not to the port. dispose() is what stops it.

It answers queries only if told how (ADR-0099): pass {query} (for GraphQL, graphqlQueryHandler() from @etherfold/graphql/worker) and hold workerExecutor(port); without it the port's query is refused.

Parameters ​
options? ​

HostQueryOptions = {}

Returns ​

HostAccess

promote() ​

promote(id): Promise<GenerationRecord>

MOVE THE CANONICAL POINTER by hand: forwards it promotes, backwards it REVERTS.

Never gated by the promotion policy, under any of its values: the policy decides the move this library makes ON ITS OWN, and manual means "only when asked" rather than "never". The revert is exact and costs no re-index, because the generation it names was never touched.

Parameters ​
id ​

GenerationId

Returns ​

Promise<GenerationRecord>

updateIndexer() ​

updateIndexer(update): Promise<ReconfigureOutcome>

Parameters ​
update ​
provider? ​

EIP1193ProviderWithoutEvents

source? ​

IndexingSource<ABI>

streamConfig? ​

ProvidedStreamConfig

Returns ​

Promise<ReconfigureOutcome>

updateProcessor() ​

updateProcessor(newProcessor, options?): Promise<ReconfigureOutcome>

Swap the processor in place.

It takes the same shape the hook does, so a live-reload that rebuilds a processor does not have to unwrap it by hand. The core is handed the EventProcessor and decides whether the state survives by comparing the two folds' IDENTITIES -- the one running here, and the one derived from the module being handed over. An edited handler is therefore a different fold and the swap is APPLIED, with no version to bump and nothing for an author to remember; a save that changed nothing is the same fold and is SKIPPED, which the outcome says (stateDiscarded: false) rather than leaving a developer guessing.

When the core does discard, $state is republished at that moment rather than left holding the old value until the next event overwrites it -- a wait that used to be unbounded, since a processor swapped in against a freshly redeployed contract has nothing to replay. The CONTAINER is what does that now (Indexer.publishDiscard, @etherfold/core), so it reaches every consumer of one and not this hook's subscribers alone.

WHY THERE IS NO processorIdentity HERE, when a generation spec has one ​

ADR-0086 derives an identity PER ARRIVAL, and what arrives at this call is a MODULE OBJECT a dev server handed the tab: there are no bytes to hash, so an app cannot supply what a bundle's arrival supplies, and letting it state one anyway would be the author-declared identity coming back through the one door left open -- silent, by construction, whenever it is wrong. So this call derives the identity ITSELF, from the module's handler sources (moduleProcessorIdentity, which records what that derivation survives and why a dev server is the only runtime it is sound in), and takes nothing from the caller but the processor and {force}.

{force: true} is what an integrator reaches for when they know the fold changed in a way the SOURCE TEXT does not carry -- an edited helper the handler imports, or behaviour decided by a captured value. It costs the same full rebuild a different identity costs.

Parameters ​
newProcessor ​

EntityEventProcessorLike<ABI, ProcessResultType, ProcessorConfig>

options? ​
force? ​

boolean

Returns ​

Promise<ReconfigureOutcome>

withHooks() ​

withHooks(react): { useState: () => ProcessResultType; useSyncing: () => SyncingState<ABI>; useStatus: () => StatusState; syncing: { subscribe: (this: void, run: Subscriber<SyncingState<ABI>>, invalidate?: Invalidator<...> | undefined) => Unsubscriber; readonly $state: SyncingState<...>; }; ... 21 more ...; withHooks(react: ReactHook...

Parameters ​
react ​

ReactHooks

Returns ​

{ useState: () => ProcessResultType; useSyncing: () => SyncingState<ABI>; useStatus: () => StatusState; syncing: { subscribe: (this: void, run: Subscriber<SyncingState<ABI>>, invalidate?: Invalidator<...> | undefined) => Unsubscriber; readonly $state: SyncingState<...>; }; ... 21 more ...; withHooks(react: ReactHook...