Skip to content

Releases: wighawag/etherfold

etherfold@0.7.0

Choose a tag to compare

@github-actions github-actions released this 26 Aug 11:55

Minor Changes

  • 047cd73: Switch the build from tsup to tsc and ship ESM-only output. The CommonJS build (dist/*.cjs) and the main field have been removed; packages are now consumed via the module/exports ESM entrypoints only. Module resolution moves to NodeNext (relative imports now carry explicit .js extensions, JSON imports use import attributes).

  • bc5d71a: Update all dependencies to their latest versions and fix the resulting build.

    Dependency updates (notable):

    • viem 1.x → ^2.52.0 (major), abitype^1.2.4
    • pouchdb / pouchdb-find^9.0.0, commander^15.0.0, koa^3.2.1
    • typescript^6.0.3, vitest^4.1.8, plus various @types/*, eip-1193, named-logs, fs-extra, etc.

    Fixes required by the updates:

    • @etherfold/core: handle viem v2's stricter encodeEventTopics return type ((Hex | Hex[] | null)[]) and the generic eventName returned by decodeEventLog over AbiEvent[].
    • @etherfold/browser: align LastSync/ExistingStream generic vs. base Abi usage that broke under viem v2's tighter DecodeEventLogReturnType.
    • @etherfold/fs-cache: spread typed event args safely; make the package explicitly ESM (type: module) with .js import extensions.
    • All published packages: add a standard exports map (ESM-only, no main) so modern bundlers/test runners (Vite/Vitest v4) resolve the package entry correctly.

    JS processor authoring keeps full ABI-derived type safety (event.args typed from the ABI).

  • 086de7b: Adds the platform-agnostic indexer-server and its Node host, and a serve command to the CLI.

    @etherfold/server is a Hono app that receives its database and environment by injection ({getDB, getEnv}) and imports no runtime: no Node built-ins, no Cloudflare types, no concrete driver. It ships the fixed-table schema and a /status route reporting database reachability, whether the schema is applied and at which version, and the last error this process saw. POST /admin/setup applies the schema. A test asserts the package names no runtime, so the property is checked rather than trusted.

    @etherfold/platform-nodejs is the Node host: a libSQL-backed RemoteSQL, environment from the process, served over HTTP. It applies the schema at startup by default (one process owning one file), which autoSetup: false disables.

    The CLI gains etherfold serve, which runs that host, so a project can start an indexer-server without wiring anything. etherfold index remains the default command, so existing etherfold -p <processor> -f <folder> invocations are unchanged.

    A Cloudflare Worker host also exists, at platforms/cf-worker, and is not published: it is a deployable, not a library.

    The server is a skeleton. It serves status and schema only: no chain logic, no store wiring, no feed. Those arrive with the tasks that follow ADR-0003.

  • e0e5832: Renamed to the @etherfold scope (ADR-0017). ethereum-indexer is now @etherfold/core, and ethereum-indexer-browser, -js-processor, -fs, -fs-cache and -utils are now @etherfold/browser, @etherfold/js-processor, @etherfold/fs, @etherfold/fs-cache and @etherfold/utils. The two previously unpublished @ethereum-indexer/* packages move to @etherfold/*.

    The CLI is the one exception to the scope: ethereum-indexer-cli becomes the flat package etherfold, because it is the package that installs the etherfold command.

    No API changed: update the package name in your imports and the exports are identical.

    You must migrate to keep receiving updates. There is no re-export shim under the old names, so nothing further will be published as ethereum-indexer* and no version of an old name forwards to the new one. Already-published versions stay installable indefinitely, so existing pins keep resolving, but they are frozen.

    The CLI command is renamed: the CLI installs etherfold instead of ei, so npm i -g etherfold then etherfold -p <processor>. Update any script that shells out to ei.

    named-logs namespaces follow the package names, so any log filter matching ethereum-indexer* needs updating to @etherfold/*. The CLI is the exception: its namespaces follow the command, so ei and ei:keepState become etherfold and etherfold:keepState.

    ethereum-indexer-server and ethereum-indexer-db-utils are deliberately NOT renamed: both are on the retirement path set by ADR-0010, and they have since moved to archive/ in the repository, outside the workspace. Their published versions stay installable and are not deprecated here.

  • 0ac08c0: One BigInt convention, and it identifies a BigInt instead of guessing at one. Every storage adapter now tags: {"__bigint__": "123"}, the codec the wire and the sync cursor already used. bnReplacer, bnReviver and isBigIntLiteral are removed from @etherfold/core, and bnReviver is removed from @etherfold/browser.

    "123n" was both what 123n serializes to and a perfectly legal string for a contract to emit, so the decoder could not tell them apart and silently changed the type of whichever it got wrong. That is silent in both directions: a real BigInt read back as a string breaks arithmetic downstream, a string read back as a BigInt breaks comparisons (including === against a hash) and JSON round-trips. It is not hypothetical, and both kinds genuinely coexist in one payload: LastSync.unconfirmedBlocks carries decoded LogEvents whose args hold a BigInt per uint256, and the same document carries the context digests. 535ccc1 stopped that decoder THROWING on values that were never numbers and gave simple_hash a leading h; both were containment, and the guess itself is what this removes.

    Moved onto the tag: etherfold's snapshot keeper, @etherfold/browser's keepStateOnIndexedDB and keepStateOnLocalStorage, @etherfold/fs's file keeper, and @etherfold/core's captured stream fixture. @etherfold/processor-entities was already on it.

    The legacy suffix form is not read, anywhere, and there is no fallback. Translating it would be the same guess under a new name, and refusing every string of digits ending in n would refuse legitimate event data, so a "123n" string is now simply a string. Where a persisted artifact carries a FORMAT number the number was bumped instead, so a file written under the old convention is refused AS A FILE rather than half-decoded:

    • STREAM_FIXTURE_FORMAT is 2. parseStreamFixture refuses a format-1 fixture, naming the file.
    • etherfold's SNAPSHOT_FORMAT is 2, and older snapshots are no longer read. A snapshot at format 1, or in the bare pre-envelope form, is logged and treated as absent, which cold starts. That is deliberate: its BigInts cannot be recovered by this reader, so resuming from it would resume from state whose every uint256 had become a string, and re-indexing is the existing recovery for a snapshot that cannot be read. Delete the snapshot folder, or re-index once.
    • The two artifacts with no format number of their own -- @etherfold/fs's keeper blob and keepStateOnLocalStorage's -- are caches whose recovery is a re-index, so a stale one reads back with its BigInts as the "123n" strings they now are. Call clear(), or clear site data.

    keepStateOnIndexedDB needed the codec only on its REMOTE reads: the local half hands the object to idb-keyval, and IndexedDB's structured clone stores a BigInt as a BigInt.

    The "123n" rendering survives in exactly one place, simple_hash, which uses it to have bytes to hash. Nothing decodes those bytes, so there is no guess to make, and changing it would change every digest ever persisted.

Patch Changes

  • 535ccc1: Stop the "123n" BigInt convention from mangling the hashes stored beside it.

    Six copies of the same reviver decided a string was a BigInt by testing its FIRST and LAST character, then called BigInt() on everything in between:

    (v.startsWith('-') ? !isNaN(parseInt(v.charAt(1))) : !isNaN(parseInt(v.charAt(0)))) && v.charAt(v.length - 1) === 'n';

    That admits 1x9tbhn, which is not a BigInt literal but an ordinary base36 simple_hash digest, and context.processor, context.config and context.source[].hash are all made of those. BigInt('1x9tbh') throws, from inside JSON.parse. In the CLI, whose keepState.fetch catches parse failures, that meant a perfectly good snapshot being read as corrupt and the whole state re-indexed from scratch, permanently, for roughly 1.25% of config hashes, with a log line blaming the file. The copies without a try/catch simply threw.

    • The predicate now lives once, in @etherfold/core as isBigIntLiteral (with bnReplacer / bnReviver beside it), and every live copy uses it: the CLI, both browser adapters (including keepStateOnIndexedDB, the in-browser path ADR-0002 calls primary) and the fs adapter. A dead copy in @etherfold/js-processor's history.ts was deleted.
    • simple_hash now prefixes every digest with h, so all hashes change. A guard cannot rescue a digest of all digits ending in n (8918n), because that genuinely IS the convention's shape: such a digest came back from storage as a BigInt, and processorHash === context.processor then compared a string to a BigInt and discarded state that was fine. The prefix makes the shape unreachable instead of unlikely.
    • simple_hash no longer drops falsy values. It filtered with a bare if (value), so {fee: 0} hashed identically to {} and {enabled: false} identically to {}: a config change to a falsy value could not invalidate the state computed under the old one. undefined is still dropped, matching JSON.stringify, so a value hashed before and after a round trip still agree.
    • simple_hash also accepts BigInt values instead of throwing on them, which a processor config holding a uint256 would previously have ...
Read more

@etherfold/utils@0.7.0

Choose a tag to compare

Minor Changes

  • aeb7843: createIndexerState takes an entity processor, so a tab can index into the store the application chose.

    The two halves existed and nothing joined them: createBrowserStateStore built a browser StateStore and was referenced by nothing except its own test, while the hook's processor type was EventProcessorWithInitialState — the free-form-object interface — so an entity processor could not be handed to it at all.

    const store = await createBrowserStateStore(myProcessor.entities); // one line picks the backend
    const indexer = createIndexerState({kind: 'entities', processor: fromEntityProcessor(myProcessor)(store)});

    Both kinds are accepted and the caller SAYS which, in a tag the compiler checks (ProcessorKind = 'js-object' | 'entities', TaggedProcessor, IndexerStateProcessor). A bare EventProcessorWithInitialState still means 'js-object' and every existing call site keeps working untouched; passing the wrong processor under a tag is a compile error rather than a missing method three calls later. The discrimination is deliberately never a sniff for createInitialState, which a wrapper, a proxy or a decorator can make wrong in silence.

    • The free-form path CREATES its initial state; the entity path READS its store through the handle the processor already exposes (processor.state), because there is nothing to seed — the state is in the store.
    • keepState on the entity path is refused, with a message naming the store: an entity deployment persists through its StateStore, cursor included (ADR-0027), so a keeper there is a second place to persist rather than a second opinion. keepState stays optional and unchanged for the free-form path.
    • updateProcessor takes either kind, tagged the same way.
    • options.createIndexer now receives the processor as EventProcessor<ABI, ProcessResultType> — what new EthereumIndexer(...) takes, and the one thing both kinds have in common. A caller that annotated that parameter as EventProcessorWithInitialState has to widen it.

    Reload continuity is the browser-specific risk and it is now tested on a real engine. pnpm --filter @etherfold/browser test:browser runs the hook through a captured stream in Chromium, Firefox and WebKit, including a REAL page reload: a tab that indexed, closed and reopened resumes from its cursor rather than re-indexing from the start block. On @etherfold/state-store-patch a reload legitimately starts over (memory-only, ADR-0023), and the store says so in capabilities.durability before it happens.

    @etherfold/browser bundles for a browser again, and @etherfold/utils gained a ./indexer subpath to make that true. The barrel re-exports the CLI-side modules, whose top-level node:fs / node:path / node:module imports made import '@etherfold/browser' unresolvable for esbuild and for vite, before tree-shaking could help. storage/state/OnIndexedDB.ts now imports contextFilenames from @etherfold/utils/indexer (platform-free by construction), and a test bundles the package with platform: 'browser' on every commit so it cannot come back. @etherfold/utils' existing barrel is unchanged.

  • 047cd73: Switch the build from tsup to tsc and ship ESM-only output. The CommonJS build (dist/*.cjs) and the main field have been removed; packages are now consumed via the module/exports ESM entrypoints only. Module resolution moves to NodeNext (relative imports now carry explicit .js extensions, JSON imports use import attributes).

  • bc5d71a: Update all dependencies to their latest versions and fix the resulting build.

    Dependency updates (notable):

    • viem 1.x → ^2.52.0 (major), abitype^1.2.4
    • pouchdb / pouchdb-find^9.0.0, commander^15.0.0, koa^3.2.1
    • typescript^6.0.3, vitest^4.1.8, plus various @types/*, eip-1193, named-logs, fs-extra, etc.

    Fixes required by the updates:

    • @etherfold/core: handle viem v2's stricter encodeEventTopics return type ((Hex | Hex[] | null)[]) and the generic eventName returned by decodeEventLog over AbiEvent[].
    • @etherfold/browser: align LastSync/ExistingStream generic vs. base Abi usage that broke under viem v2's tighter DecodeEventLogReturnType.
    • @etherfold/fs-cache: spread typed event args safely; make the package explicitly ESM (type: module) with .js import extensions.
    • All published packages: add a standard exports map (ESM-only, no main) so modern bundlers/test runners (Vite/Vitest v4) resolve the package entry correctly.

    JS processor authoring keeps full ABI-derived type safety (event.args typed from the ABI).

  • e0e5832: Renamed to the @etherfold scope (ADR-0017). ethereum-indexer is now @etherfold/core, and ethereum-indexer-browser, -js-processor, -fs, -fs-cache and -utils are now @etherfold/browser, @etherfold/js-processor, @etherfold/fs, @etherfold/fs-cache and @etherfold/utils. The two previously unpublished @ethereum-indexer/* packages move to @etherfold/*.

    The CLI is the one exception to the scope: ethereum-indexer-cli becomes the flat package etherfold, because it is the package that installs the etherfold command.

    No API changed: update the package name in your imports and the exports are identical.

    You must migrate to keep receiving updates. There is no re-export shim under the old names, so nothing further will be published as ethereum-indexer* and no version of an old name forwards to the new one. Already-published versions stay installable indefinitely, so existing pins keep resolving, but they are frozen.

    The CLI command is renamed: the CLI installs etherfold instead of ei, so npm i -g etherfold then etherfold -p <processor>. Update any script that shells out to ei.

    named-logs namespaces follow the package names, so any log filter matching ethereum-indexer* needs updating to @etherfold/*. The CLI is the exception: its namespaces follow the command, so ei and ei:keepState become etherfold and etherfold:keepState.

    ethereum-indexer-server and ethereum-indexer-db-utils are deliberately NOT renamed: both are on the retirement path set by ADR-0010, and they have since moved to archive/ in the repository, outside the workspace. Their published versions stay installable and are not deprecated here.

  • 47252ad: Add a shared resolveProcessorAndSource helper (plus the smaller loadProcessorModule, instantiateProcessor and resolveSource building blocks) that turns a processor module path + options into {processor, processorModule, source}. This extracts the near-identical processor/source setup that was previously copy-pasted between the CLI's init() and the server's setupIndexing() (LOW-4 in the server/CLI batch audit), removing the divergence risk between the two copies.

    Behaviour is the superset of the previous copies: module resolution keeps the server's createRequire(...).resolve() fallback for bare package specifiers (the CLI lacked it), and the processor-factory argument is now an explicit processorConfig parameter so the intentional CLI/server difference (CLI calls the factory with no args, the server passes its folder) is documented rather than accidental. The helpers are pure and unit-tested (module-resolution paths, the contractsDataPerChain/contractsData resolution, the provided-source path, and the no-factory / no-chainId / no-contracts error cases).

Patch Changes

  • bc118e4: Declare the packages the published types import, so installing them actually typechecks.

    A type-only import is erased from the emitted .js but survives in the emitted .d.ts. These packages name types from abitype, eip-1193 and @etherfold/core in their public declarations while listing those as devDependencies, so a consumer installing them got declaration files importing packages that were never installed.

    Moved to dependencies: abitype and eip-1193 in @etherfold/core, eip-1193 in @etherfold/browser, and @etherfold/core in @etherfold/utils.

    Measured against a packed tarball installed under pnpm's isolated linker with hoist=false, tsc --strict --skipLibCheck false reported 11 errors (6 for abitype, 5 for eip-1193) before and none after.

    The bug was hard to see from inside the workspace, which is why it lasted. pnpm keeps a hoisted fallback directory holding every transitive package, so an undeclared import still resolves as long as anything else in the tree depends on it: abitype was masked that way by viem and failed only with hoisting off, while eip-1193, which nothing else depends on, failed everywhere. skipLibCheck: true, which most consumers set, suppresses the diagnostics entirely and silently degrades the affected types instead.

    A test now asserts, for every package in the workspace, that each bare specifier in its built .d.ts files is a declared dependency. It found the @etherfold/utils case, which a search for the two known package names had missed.

  • Updated dependencies [6c875dd]

  • Updated dependencies [535ccc1]

  • Updated dependencies [0957f8c]

  • Updated dependencies [c681b79]

  • Updated dependencies [9d21d67]

  • Updated dependencies [ca6f981]

  • Updated dependencies [31833b6]

  • Updated dependencies [047cd73]

  • Updated dependencies [eba61c3]

  • Updated dependencies [dece521]

  • Updated dependencies [939364a]

  • Updated dependencies [d24872f]

  • Updated dependencies [78d8377]

  • Updated dependencies [3de4c35]

  • Updated dependencies [bc118e4]

  • Updated dependencies [bc5d71a]

  • Updated dependencies [e0a6480]

  • Updated dependencies [9738f1c]

  • Updated dependencies [33afc5b]

  • Updated dependencies [4097ccd]

  • Updated dependencies [e0e5832]

  • Updated dependencies [3a78285]

  • Updated dependencies [0ac08c0]

  • Updated dependencies [cefe0de]

    • @etherfold/core@0.7.0

@etherfold/state-store@0.1.0

Choose a tag to compare

Minor Changes

  • ff393f7: The last two reads that answered plausibly now refuse, or answer whole.

    Both were the same bug wearing two hats: a read that could not be served came back as undefined, which at this seam is not a shrug but a STATEMENT -- the block is fine and the entity was absent from it -- and it is what a caller acts on normally.

    An at that is not a block number is refused (InvalidBlockNumberError, @etherfold/state-store). getAsOf('token', {id: '1'}, {hash: '0x64'}) on a backend with no addressing layer used to pass the retention check, compare an object against every version range, match nothing, and report the token as absent at a block nobody had named. The guard is assertBlockNumber, called first thing inside assertRetained, so it is written ONCE and every backend whose as-of reads take a block number inherits it (memory, patch, IndexedDB) across getAsOf and listAsOf alike, rather than three copies drifting.

    • It is a TypeError, deliberately outside the BlockUnavailableError family. Every member of that family is a fact about the STORE (the address resolved to no block; the versions are outside retention), and a caller acts on one by re-pinning or widening retention. A non-number at is a fact about the CALL: no store configuration makes it answerable, so it is a programmer error and it does not get swallowed by a catch (e) { if (e instanceof BlockUnavailableError) ... } written for the other thing. It comes BEFORE the retention check for the same reason: a revert-only store answering "not retained" would send its caller off to widen a window that was never the problem.
    • @etherfold/state-store-sqlite keeps its richer addressing (a height, {hash}, {timestamp}), because it resolves to a block number before the seam sees one. Its HEIGHT axis now throws the same InvalidBlockNumberError (via the seam's shared isBlockNumber) instead of a bare Error, with the same message it had; NoSuchBlockError still answers an address that resolves to no recorded block.

    MutationContext.get answers with a WHOLE row for a key staged in the same block. It returned {...staged.values}, which is only what the handler passed to set, so an id column and a declared field the write did not list were undefined for a row written earlier in the SAME block and present for one written in an earlier block: the shape of a row depended on when it was read. get now builds a staged row through stagedRow, the construction list already used for exactly this reason, so the two cannot drift apart again and a handler cannot read a field that is only sometimes there.

    Both are in the conformance suite (@etherfold/state-store-conformance), so a new backend inherits them: versioned reads gains the refusal (asked of every backend, whatever addressing sits above it), and read-your-writes within a block gains the row shape plus a case pinning that get and list agree about a staged row.

  • 4e75014: An entity store can start from state somebody else computed, and it stays honest about the history it never received (ADR-0028).

    This is the entity path's half of a capability the free-form path has always had: keepStateOnIndexedDB(name, remote) takes one or more published locations, asks each how far it has got, uses the furthest, prefers LOCAL state when local is already ahead, and skips an unreachable mirror rather than dying. A client that bootstraps comes up near the tip instead of replaying every log the contract ever emitted.

    @etherfold/state-store gains the snapshot envelope and the store handle that keeps it honest:

    • StateSnapshot -- {format, processor, savedAt, takenAt, cursor, rows}, deliberately shaped like the CLI's file envelope so a reader of one recognises the other. rows are the LIVE rows at takenAt, and SnapshotHead is the same envelope without them, which is what a client fetches to choose between mirrors.
    • openSnapshotAware(store) -- the handle a deployment that may bootstrap uses on EVERY boot (it migrates the store itself). .bootstrap(snapshot, {processor}) installs the rows and their cursor as one applyBlock, and records where the contents came from under a second cursor-port key (SNAPSHOT_ORIGIN_KEY), so a reload is as honest as the first run.
    • The honesty: a bootstrapped store reports its retention as a window whose oldest block is the snapshot's, never the unbounded a freshly migrated store would claim, and an as-of read below that block is refused with BlockNotRetainedError instead of answering undefined -- which would read as "the entity was absent then", an ordinary answer a caller acts on normally, and wrong. The floor is intersected with whatever the deployment configured, and a store that answers no historical read at all is left saying exactly that.
    • RevertBeyondSnapshotError -- a reorg reaching below the snapshot is refused loudly and changes nothing. There are no superseded versions under the snapshot to reopen at any price, and a partly undone reorg is a plausible state nothing downstream can tell apart from a correct one.
    • SnapshotProcessorMismatchError / SnapshotFormatError -- a snapshot computed by another processor version, or in an envelope this build does not read, is refused rather than loaded.

    @etherfold/processor-entities gains the client side:

    • bootstrapFromSnapshot(store, locations, {processor, finalityDepth?, fetch?}) -- mirrors, most-advanced-wins, prefer-local, fail over on error. Two deliberate differences from the free-form keeper: failover walks EVERY remaining candidate in descending order (the keeper tries the winner and one more), and a snapshot from another processor version is not a candidate at all. Given a finalityDepth, a snapshot taken inside the reorg-eligible window of the tip its producer had observed is declined, so the revert that could not be undone is avoided as well as refused. It returns a BootstrapOutcome rather than throwing when nothing is usable: indexing from the start block is the correct answer to "no snapshot is available".
    • openAndBootstrap(store, locations, options) -- the boot path, which keeps the SAFE order the short one: open snapshot-aware first, then bootstrap only if the store has never synced.
    • createSnapshot(...) -- the MINIMAL producer, and it says so. Publishing snapshots as a first-class artifact (a publish command, format versioning, mirror layout, pruning old ones) is a design of its own.

    @etherfold/browser gains no API and one piece of documentation that matters: createBrowserStateStore now says how a browser deployment bootstraps, and that the store must be opened through openSnapshotAware on EVERY boot rather than only on the boot that installs a snapshot. The mechanism deliberately does not live here -- deciding whether local is already ahead means reading lastToBlock out of a stored cursor, and the cursor's codec belongs to the entity runtime (ADR-0027), which this package does not depend on so that it stays free of any one processor package.

    @etherfold/state-store-conformance gains a bootstrapping from a snapshot group, so every backend inherits the obligation rather than rediscovering the trap in somebody's browser tab: rows and cursor installing as one unit, the origin surviving a fresh handle over the same storage, the revert refusal, the wipe still working, and -- selected on what the backend claims -- the floor being refused below and answered at and above.

  • ce8f7d2: A handler can now ask about a SET of rows: the bounded id-prefix listing.

    // entity: {name: 'placement', id: ['epoch', 'position', 'playerIndex'], fields: {player: 'text'}}
    const {rows, truncated} = await state.list('placement', {epoch: 7}, 8);

    That is the one read the entity model was missing, and it is what makes a one-to-many expressible the way a subgraph's @derivedFrom does it: children are their own entity keyed by their parent, and the collection is DERIVED WHEN READ. Nothing is maintained at write time. MutationContext gains list; StateStore gains listCurrent and listAsOf, which every backend must implement.

    The bound is the decision, not an implementation detail. A listing takes a PREFIX of the declared id (a leading run of its id columns, at least one) plus a REQUIRED limit, and takes no where, no orderBy and no offset. A handler runs once per event on every backend, including the ones with no query planner, so the seam gets the one shape that is an indexed range scan everywhere: a key-prefix range with a bound. An accidental full scan is therefore impossible to EXPRESS rather than merely discouraged. @etherfold/state-store-sqlite's queryCurrent / queryAsOf, which do take caller-supplied SQL, are the server-side read layer and are unchanged. See docs/adr/0021.

    • Truncation is reported, never inferred. A listing answers {rows, truncated}, and every backend reads one row more than the limit to fill it in, because rows.length === limit cannot tell an exact answer from a cut-off one and a cascade delete that guesses wrong leaves orphans silently.
    • Order is the id's own, ascending, which is what a range scan gives for free, and therefore LEXICOGRAPHIC over the stringified id: '10' sorts before '9'. Key ordered children by something naturally unique and ordered (an event ordinal, or (blockNumber, logIndex)) and make a numeric key fixed-width. If arrival order is wanted, that is a modelling answer, not a parameter.
    • Read-your-writes holds for a listing too: a child written earlier in the block appears and one deleted earlier in the block does not, which means merging the block's staging area into the scan rather than falling through to the store. The fetch budget accounts for staged deletes, so a limit is still filled from beyond them.
    • **In SQLite it is one indexed range s...
Read more

@etherfold/state-store-sqlite@0.1.0

Choose a tag to compare

Minor Changes

  • df47021: State can now be read as of a block hash, a height or a timestamp.

    getAsOf and queryAsOf take a BlockAddress (101, {number}, {hash} or {timestamp}) where they took a block number. All three axes resolve to a block number through the canonical _blocks table this package already writes, and then run the same as-of predicate, so they answer identically when they identify the same block. Widening a parameter, so existing calls by number are unaffected.

    • Hash is the identifier a consumer should store. Pinning a height means a reorg silently changes what "state at 18,000,123" refers to; pinning the hash makes the lookup answer "no such block", which is itself the signal that whatever was derived from it is invalid.
    • "No such block" is a distinct answer from "block known, entity absent." An address that resolves to no block throws NoSuchBlockError (with a reason of unknown-hash or no-recorded-block-at-or-before), while undefined keeps its ordinary meaning. resolveBlockNumber(address) is the soft form, answering undefined and throwing nothing, and getBlock(address) returns the recorded row so a consumer can turn a time or a height into the hash to pin. See docs/adr/0015.
    • A timestamp resolves to the latest recorded block at or before T, and to nothing before the first recorded block, never to the first block. Ties are broken by the highest block number.
    • Rows exist only for blocks that carry our logs, which is the caller's judgement: every block handed to applyBlock is recorded, including one with no mutations, since a block can carry a log of ours that changes nothing and a consumer can legitimately pin its hash. A height needs no row and stays readable regardless; a hash needs one.
    • normalizeBlockTimestamp reads blockTimestamp off a log in either encoding clients return (0x-prefixed hex per the spec, or decimal), and refuses anything else rather than defaulting to 0. Block hashes are folded to lower case on write and on lookup, so an echoed-back upper-case hash cannot masquerade as a reorg.
  • ce8f7d2: A handler can now ask about a SET of rows: the bounded id-prefix listing.

    // entity: {name: 'placement', id: ['epoch', 'position', 'playerIndex'], fields: {player: 'text'}}
    const {rows, truncated} = await state.list('placement', {epoch: 7}, 8);

    That is the one read the entity model was missing, and it is what makes a one-to-many expressible the way a subgraph's @derivedFrom does it: children are their own entity keyed by their parent, and the collection is DERIVED WHEN READ. Nothing is maintained at write time. MutationContext gains list; StateStore gains listCurrent and listAsOf, which every backend must implement.

    The bound is the decision, not an implementation detail. A listing takes a PREFIX of the declared id (a leading run of its id columns, at least one) plus a REQUIRED limit, and takes no where, no orderBy and no offset. A handler runs once per event on every backend, including the ones with no query planner, so the seam gets the one shape that is an indexed range scan everywhere: a key-prefix range with a bound. An accidental full scan is therefore impossible to EXPRESS rather than merely discouraged. @etherfold/state-store-sqlite's queryCurrent / queryAsOf, which do take caller-supplied SQL, are the server-side read layer and are unchanged. See docs/adr/0021.

    • Truncation is reported, never inferred. A listing answers {rows, truncated}, and every backend reads one row more than the limit to fill it in, because rows.length === limit cannot tell an exact answer from a cut-off one and a cascade delete that guesses wrong leaves orphans silently.
    • Order is the id's own, ascending, which is what a range scan gives for free, and therefore LEXICOGRAPHIC over the stringified id: '10' sorts before '9'. Key ordered children by something naturally unique and ordered (an event ordinal, or (blockNumber, logIndex)) and make a numeric key fixed-width. If arrival order is wanted, that is a modelling answer, not a parameter.
    • Read-your-writes holds for a listing too: a child written earlier in the block appears and one deleted earlier in the block does not, which means merging the block's staging area into the scan rather than falling through to the store. The fetch budget accounts for staged deletes, so a limit is still filled from beyond them.
    • In SQLite it is one indexed range scan: equality on the leading id columns plus ORDER BY the declared id rides the entity's id index with no sort and no table scan. Pinned by the generated statement's shape AND by EXPLAIN QUERY PLAN, since no behavioural assertion can tell a range scan from a table scan that returns the same rows.
    • The conformance suite gained a group for it, so a new backend is held to the same answers, and @etherfold/processor-entities gained a test that models the real ordered bounded collection from work/notes/findings/sqlite-in-the-browser.md (a window of seven, evicting the oldest and everything nested under it) with no stored array, no CSV index and no count, on both backends.
  • b61de79: A declaration is now legal on every backend or refused on every backend, and the rest of the "same declaration, different meaning" class is closed.

    entity-identifier-sql-keyword fixed the SQL-KEYWORD half by quoting in @etherfold/state-store-sqlite, deliberately keeping one engine's reserved-word list out of the shared seam. This finishes the audit it opened. It contains source-compatibility breaks (named below); breaking an existing declaration was accepted where the rule is right.

    Two names that differ only in CASE are now refused at declaration time, on every backend (@etherfold/state-store, breaking). {name: 'token'} and {name: 'Token'} were accepted (normalizeEntities de-duplicated by exact string) and then meant two different things: SQLite folds identifier case even inside quotes, so CREATE TABLE IF NOT EXISTS "Token" matched the existing token and was silently SKIPPED, leaving ONE table with token's columns and getCurrent('Token', ...) answering with token's row, while the memory, patch and IndexedDB backends kept two entities. Quoting cannot reach this one, and no spelling of the DDL makes the engines agree, so the answer had to be the declaration's: two names a backend could confuse are one name.

    Unlike a keyword list, this belongs at the seam, and the distinction is written out where the rule lives (src/entities.ts): a keyword list is one engine's VOCABULARY, which quoting removes entirely; case is IDENTITY, and the backends disagree about it, so it is a PORTABILITY rule. The rule applies at all three levels -- entity names, id columns and fields -- with id columns and fields treated as one namespace, since they are the columns of one row. The message names both spellings and says to rename one. A repeated id column (id: ['id', 'id'], previously accepted here and duplicate column name at migrate() on SQLite) is refused too.

    An entity named like another entity's derived index no longer breaks migrate() (@etherfold/state-store-sqlite, breaking for a stored database, not for a declaration). An index and a table share ONE namespace in SQLite, so declaring token beside token_open was two ordinary entities everywhere else and SQLITE_ERROR: there is already an index named token_open here. The derived index names now carry the store's _ prefix (_token_open, _token_history, _token_lower, _token_upper), which the seam already keeps declarations out of, so the collision is impossible by construction rather than refused by a new rule -- a declaration's legality must not depend on which other entities were declared beside it. An existing database re-migrates cleanly and keeps its old, now-redundant indexes under their old names; drop them by hand or rebuild.

    An entity name in SQLite's own sqlite_ namespace is refused when the store is CONSTRUCTED (@etherfold/state-store-sqlite, breaking), rather than at migrate(). SQLite refuses sqlite_-prefixed object names however they are quoted, so this is genuinely one engine's limit and does not become a seam rule; what it does become is a DECLARATION-time failure, where the store was built, instead of a deploy-time one. sqlite_-prefixed COLUMN names stay legal, because SQLite allows them and narrowing the seam for no engine reason is the same defect pointing the other way.

    Audited and left alone. Identifier LENGTH: no backend imposes one (SQLite stores a 2,000-character table name and its indexes; the others hold names as JS strings), so the seam invents none. NON-ASCII and NFC/NFD collisions: already unreachable, because the shape rule /^[A-Za-z][A-Za-z0-9_]*$/ is ASCII-only, so café and cafe + U+0301 are both refused as identifiers and can never become the case collision in another alphabet. That is also why the case fold is exact rather than approximate, and the regex now says so.

    @etherfold/state-store-conformance carries every new rule, so a future backend inherits the obligation instead of rediscovering it. The a declaration means the same thing on every backend group gains the case cases (entity, id column, field, and across the id/field boundary), the non-ASCII case, and DECLARATION_PROBES: an exported list of legally-shaped names that strain some engine (sqlite_ entity and column names, a 200-character entity name and column names, and an entity named like a derived index), each asserting only that the store REFUSES when it is constructed or stores and reads the row back -- never accepted-then-fatal-at-migrate(). Adding a probe is how a newly found engine limit becomes every backend's problem at once.

    The _ prefix rule, the shape rule and entity-identifier-sql-keyword's...

Read more

@etherfold/state-store-patch@0.1.0

Choose a tag to compare

Minor Changes

  • c359dcb: The light state store, behind the same seam: @etherfold/state-store-patch.

    A new package. Current state as a plain object, history as immer reverse patches, reorg revert by replaying them backwards. It is the cheapest legitimate implementation of the storage seam, so a browser tab that only needs current state and reorg safety pays nothing for versioned rows while running the SAME processor as the server:

    const store = new PatchStateStore(processor.entities, {retention: 'revert-only', finalityDepth: 64});
    await applyEventStream(store, processor, eventStream, config); // the same processor as on SQLite

    packages/processor-entities/test/patch-backend.test.ts asserts that equality against @etherfold/state-store-sqlite on the same input, and the store passes @etherfold/state-store-conformance under its own claim.

    It advertises revert-only, and that is a MEASURED result rather than a limitation. Backwards replay is correct wherever the patches exist (matched the recorded state at every depth to 64 on Chromium, Firefox, WebKit and node, at a cost linear in depth). What withdraws the capability is SPARSITY: history is pruned by BLOCK-NUMBER distance from the tip, while a real stream carries only event-bearing blocks, which on the launched stratagems game on Base are median 429 blocks apart. At a finality of 64 exactly one block's reversals survive, the tip's, and no tuning returns it. So revertTo works and is the reason this backend exists, while every as-of read throws BlockNotRetainedError at every depth — never the tip value, which is the single failure mode this design exists to prevent because it is plausible. Asking this store for a window is refused where it is configured rather than downgraded quietly.

    A revert it cannot perform is an error, not a partial revert. Once a block's reverse patches have been pruned, revertTo throws RevertBeyondPatchHistoryError (naming the blocks it cannot undo, the deepest revert still available and the declared depth) and leaves the state untouched, because a half-undone reorg is the write-path twin of a historical read served from the tip. store.retainedReversals() reports the depth still available — on a sparse stream, one block.

    Memory-only, and the capability report says so (durability: 'memory-only'): a reload is an empty store. Persisting is deliberately left to the seams that own it — the whole-state KeepState path above, and the row-level IndexedDB backend beside. See ADR-0023.

    prune() drops the reverse patches at or below tip - finalityDepth and is a call the host schedules (ADR-0022), never a side effect of a write, which is the deliberate difference from @etherfold/js-processor's History.

  • 5854d60: The storage seam gains a sync-cursor port, and applyBlock can write the cursor with the block (ADR-0027).

    StateStore gains readCursor(key) / writeCursor(key, value) / clearCursor(key) over an opaque string, and applyBlock(block, mutations, cursor?) takes an optional {key, value} that is written in the SAME transaction as the block. This reverses an explicit "deliberately absent" on the interface: a cursor that could only be a SQL table stopped one-processor-several-backends at the first deployment that was not SQLite, and only the store holds the transaction the block write happens in, so only the store can stop a crash from leaving state ahead of the cursor.

    It stays a STRING and never a typed LastSync: that is a @etherfold/core type, and typing the port with it would make this package depend on core, invert ADR-0016 and drag viem into every storage primitive. @etherfold/state-store still declares no dependencies at all.

    Per backend:

    • @etherfold/state-store-sqlite: a new fixed _cursor (key, value) table, created by migrate() alongside _blocks, and the cursor statement rides in the same batch([...]) as the block. CURSOR_TABLE, readCursorStatement, writeCursorStatement and clearCursorStatement are exported like the rest of the SQL.
    • @etherfold/state-store-indexeddb: a new cursors object store, written inside the block's own transaction. The package's schema version moved from 1 to 2; the upgrade is additive and contains-guarded, so an existing database gains the store and keeps every row. A processor declaring another entity is still not a migration.
    • @etherfold/state-store-patch: an in-memory map, written after the point where anything can still refuse. Its durability: 'memory-only' already says what that means for the cursor: it goes with the process, exactly as the state does.
    • MemoryStateStore: the same, as the executable definition.

    @etherfold/state-store-conformance gains a the sync cursor group: the round trip, the clear, the opacity of the value, and the one that matters — a store never reports a cursor ahead of its last applied block, asserted through a refused block and through a re-applied height. The suite's own tests gain a backend that writes the cursor before the block, so the new group is proven to catch it.

    A cursor is deliberately NOT reverted by revertTo and not touched by prune: how far the caller got is not entity state.

Patch Changes

@etherfold/state-store-indexeddb@0.1.0

Choose a tag to compare

Minor Changes

  • d45f11d: The browser backend, behind the same seam: @etherfold/state-store-indexeddb, and it is the browser DEFAULT.

    A new package. Versioned rows in IndexedDB, so a tab keeps history, reverts a reorg, starts cold by reading one row instead of all of them, and pays a write cost proportional to what CHANGED. The same processor that runs on a server against @etherfold/state-store-sqlite runs on it unchanged, and @etherfold/browser gains the one line where a browser deployment chooses:

    const store = await createBrowserStateStore(processor.entities); // IndexedDB, the default
    const light = await createBrowserStateStore(processor.entities, {
    	backend: (entities) => new PatchStateStore(entities, {retention: 'revert-only', finalityDepth: 64}),
    });

    Choosing the second one touches no processor code: a processor is entity declarations plus on<EventName> handlers over a MutationContext, and it names no backend.

    The default is a CONDITION, not a preference, and it is written down as one (docs/adr/0024). On the real workload (the launched stratagems game on Base: 31,332 events, 4,072 live rows) IndexedDB beat wasm SQLite on writes by 1.6x to 6.9x and on reads by 4x to 14x on every engine that can run both, WebKit cannot run the SQLite route at all, and three of four tabs FAIL AT OPEN on both SQLite VFSs. The ADR records the four things that would all have to be true for wasm SQLite to win, and the five that would overturn the choice, from work/notes/findings/sqlite-in-the-browser.md.

    It is not a speed-up. The incumbent whole-state blob (keepStateOnIndexedDB) is the FASTEST writer at today's sizes: 2.0 ms/block on Chromium against 45.6 for row-level writes, a 20x throughput loss at 4,072 live rows. What row-level writes buy is what the blob cannot do at any speed: an as-of read, a revert, a bounded cold start, and a per-write cost that stops tracking total state.

    • It passes @etherfold/state-store-conformance under all three retention claims, in node under fake-indexeddb on every commit and in Chromium, Firefox and WebKit via pnpm --filter @etherfold/state-store-indexeddb test:browser (the same suite, not a browser-flavoured copy). Evidence in docs/spikes/indexeddb-row-backend-browser-default/results/.
    • The bounded id-prefix listing is one IDBKeyRange.bound([entity, ...prefix], [entity, ...prefix, []]) cursor, asserted rather than assumed: the tests record the range the store handed IndexedDB and how many records it walked, because a scan-and-filter returns the same rows.
    • Retention is enforced on both halves, so what it reports is what it does: an as-of read outside the window throws BlockNotRetainedError and never the tip value, and prune walks the upper index — where a LIVE version cannot appear at all, because null is not a valid IndexedDB key — so the row that IS the current state cannot be dropped however old it is. The window is measured against the tip read from the database, so it is right after a reload and right when another tab moved it.
    • revertTo is two index range scans (drop what the fork opened, reopen what it closed) rather than a per-block undo journal, and getAsOf is one backwards cursor over that key's versions.
    • Four tabs against one database complete with zero row mismatches, which is the case both wasm-SQLite VFSs fail at open.
  • 5854d60: The storage seam gains a sync-cursor port, and applyBlock can write the cursor with the block (ADR-0027).

    StateStore gains readCursor(key) / writeCursor(key, value) / clearCursor(key) over an opaque string, and applyBlock(block, mutations, cursor?) takes an optional {key, value} that is written in the SAME transaction as the block. This reverses an explicit "deliberately absent" on the interface: a cursor that could only be a SQL table stopped one-processor-several-backends at the first deployment that was not SQLite, and only the store holds the transaction the block write happens in, so only the store can stop a crash from leaving state ahead of the cursor.

    It stays a STRING and never a typed LastSync: that is a @etherfold/core type, and typing the port with it would make this package depend on core, invert ADR-0016 and drag viem into every storage primitive. @etherfold/state-store still declares no dependencies at all.

    Per backend:

    • @etherfold/state-store-sqlite: a new fixed _cursor (key, value) table, created by migrate() alongside _blocks, and the cursor statement rides in the same batch([...]) as the block. CURSOR_TABLE, readCursorStatement, writeCursorStatement and clearCursorStatement are exported like the rest of the SQL.
    • @etherfold/state-store-indexeddb: a new cursors object store, written inside the block's own transaction. The package's schema version moved from 1 to 2; the upgrade is additive and contains-guarded, so an existing database gains the store and keeps every row. A processor declaring another entity is still not a migration.
    • @etherfold/state-store-patch: an in-memory map, written after the point where anything can still refuse. Its durability: 'memory-only' already says what that means for the cursor: it goes with the process, exactly as the state does.
    • MemoryStateStore: the same, as the executable definition.

    @etherfold/state-store-conformance gains a the sync cursor group: the round trip, the clear, the opacity of the value, and the one that matters — a store never reports a cursor ahead of its last applied block, asserted through a refused block and through a re-applied height. The suite's own tests gain a backend that writes the cursor before the block, so the new group is proven to catch it.

    A cursor is deliberately NOT reverted by revertTo and not touched by prune: how far the caller got is not entity state.

Patch Changes

@etherfold/state-store-conformance@0.1.0

Choose a tag to compare

Minor Changes

  • ff393f7: The last two reads that answered plausibly now refuse, or answer whole.

    Both were the same bug wearing two hats: a read that could not be served came back as undefined, which at this seam is not a shrug but a STATEMENT -- the block is fine and the entity was absent from it -- and it is what a caller acts on normally.

    An at that is not a block number is refused (InvalidBlockNumberError, @etherfold/state-store). getAsOf('token', {id: '1'}, {hash: '0x64'}) on a backend with no addressing layer used to pass the retention check, compare an object against every version range, match nothing, and report the token as absent at a block nobody had named. The guard is assertBlockNumber, called first thing inside assertRetained, so it is written ONCE and every backend whose as-of reads take a block number inherits it (memory, patch, IndexedDB) across getAsOf and listAsOf alike, rather than three copies drifting.

    • It is a TypeError, deliberately outside the BlockUnavailableError family. Every member of that family is a fact about the STORE (the address resolved to no block; the versions are outside retention), and a caller acts on one by re-pinning or widening retention. A non-number at is a fact about the CALL: no store configuration makes it answerable, so it is a programmer error and it does not get swallowed by a catch (e) { if (e instanceof BlockUnavailableError) ... } written for the other thing. It comes BEFORE the retention check for the same reason: a revert-only store answering "not retained" would send its caller off to widen a window that was never the problem.
    • @etherfold/state-store-sqlite keeps its richer addressing (a height, {hash}, {timestamp}), because it resolves to a block number before the seam sees one. Its HEIGHT axis now throws the same InvalidBlockNumberError (via the seam's shared isBlockNumber) instead of a bare Error, with the same message it had; NoSuchBlockError still answers an address that resolves to no recorded block.

    MutationContext.get answers with a WHOLE row for a key staged in the same block. It returned {...staged.values}, which is only what the handler passed to set, so an id column and a declared field the write did not list were undefined for a row written earlier in the SAME block and present for one written in an earlier block: the shape of a row depended on when it was read. get now builds a staged row through stagedRow, the construction list already used for exactly this reason, so the two cannot drift apart again and a handler cannot read a field that is only sometimes there.

    Both are in the conformance suite (@etherfold/state-store-conformance), so a new backend inherits them: versioned reads gains the refusal (asked of every backend, whatever addressing sits above it), and read-your-writes within a block gains the row shape plus a case pinning that get and list agree about a staged row.

  • 4e75014: An entity store can start from state somebody else computed, and it stays honest about the history it never received (ADR-0028).

    This is the entity path's half of a capability the free-form path has always had: keepStateOnIndexedDB(name, remote) takes one or more published locations, asks each how far it has got, uses the furthest, prefers LOCAL state when local is already ahead, and skips an unreachable mirror rather than dying. A client that bootstraps comes up near the tip instead of replaying every log the contract ever emitted.

    @etherfold/state-store gains the snapshot envelope and the store handle that keeps it honest:

    • StateSnapshot -- {format, processor, savedAt, takenAt, cursor, rows}, deliberately shaped like the CLI's file envelope so a reader of one recognises the other. rows are the LIVE rows at takenAt, and SnapshotHead is the same envelope without them, which is what a client fetches to choose between mirrors.
    • openSnapshotAware(store) -- the handle a deployment that may bootstrap uses on EVERY boot (it migrates the store itself). .bootstrap(snapshot, {processor}) installs the rows and their cursor as one applyBlock, and records where the contents came from under a second cursor-port key (SNAPSHOT_ORIGIN_KEY), so a reload is as honest as the first run.
    • The honesty: a bootstrapped store reports its retention as a window whose oldest block is the snapshot's, never the unbounded a freshly migrated store would claim, and an as-of read below that block is refused with BlockNotRetainedError instead of answering undefined -- which would read as "the entity was absent then", an ordinary answer a caller acts on normally, and wrong. The floor is intersected with whatever the deployment configured, and a store that answers no historical read at all is left saying exactly that.
    • RevertBeyondSnapshotError -- a reorg reaching below the snapshot is refused loudly and changes nothing. There are no superseded versions under the snapshot to reopen at any price, and a partly undone reorg is a plausible state nothing downstream can tell apart from a correct one.
    • SnapshotProcessorMismatchError / SnapshotFormatError -- a snapshot computed by another processor version, or in an envelope this build does not read, is refused rather than loaded.

    @etherfold/processor-entities gains the client side:

    • bootstrapFromSnapshot(store, locations, {processor, finalityDepth?, fetch?}) -- mirrors, most-advanced-wins, prefer-local, fail over on error. Two deliberate differences from the free-form keeper: failover walks EVERY remaining candidate in descending order (the keeper tries the winner and one more), and a snapshot from another processor version is not a candidate at all. Given a finalityDepth, a snapshot taken inside the reorg-eligible window of the tip its producer had observed is declined, so the revert that could not be undone is avoided as well as refused. It returns a BootstrapOutcome rather than throwing when nothing is usable: indexing from the start block is the correct answer to "no snapshot is available".
    • openAndBootstrap(store, locations, options) -- the boot path, which keeps the SAFE order the short one: open snapshot-aware first, then bootstrap only if the store has never synced.
    • createSnapshot(...) -- the MINIMAL producer, and it says so. Publishing snapshots as a first-class artifact (a publish command, format versioning, mirror layout, pruning old ones) is a design of its own.

    @etherfold/browser gains no API and one piece of documentation that matters: createBrowserStateStore now says how a browser deployment bootstraps, and that the store must be opened through openSnapshotAware on EVERY boot rather than only on the boot that installs a snapshot. The mechanism deliberately does not live here -- deciding whether local is already ahead means reading lastToBlock out of a stored cursor, and the cursor's codec belongs to the entity runtime (ADR-0027), which this package does not depend on so that it stays free of any one processor package.

    @etherfold/state-store-conformance gains a bootstrapping from a snapshot group, so every backend inherits the obligation rather than rediscovering the trap in somebody's browser tab: rows and cursor installing as one unit, the origin surviving a fresh handle over the same storage, the revert refusal, the wipe still working, and -- selected on what the backend claims -- the floor being refused below and answered at and above.

  • ce8f7d2: A handler can now ask about a SET of rows: the bounded id-prefix listing.

    // entity: {name: 'placement', id: ['epoch', 'position', 'playerIndex'], fields: {player: 'text'}}
    const {rows, truncated} = await state.list('placement', {epoch: 7}, 8);

    That is the one read the entity model was missing, and it is what makes a one-to-many expressible the way a subgraph's @derivedFrom does it: children are their own entity keyed by their parent, and the collection is DERIVED WHEN READ. Nothing is maintained at write time. MutationContext gains list; StateStore gains listCurrent and listAsOf, which every backend must implement.

    The bound is the decision, not an implementation detail. A listing takes a PREFIX of the declared id (a leading run of its id columns, at least one) plus a REQUIRED limit, and takes no where, no orderBy and no offset. A handler runs once per event on every backend, including the ones with no query planner, so the seam gets the one shape that is an indexed range scan everywhere: a key-prefix range with a bound. An accidental full scan is therefore impossible to EXPRESS rather than merely discouraged. @etherfold/state-store-sqlite's queryCurrent / queryAsOf, which do take caller-supplied SQL, are the server-side read layer and are unchanged. See docs/adr/0021.

    • Truncation is reported, never inferred. A listing answers {rows, truncated}, and every backend reads one row more than the limit to fill it in, because rows.length === limit cannot tell an exact answer from a cut-off one and a cascade delete that guesses wrong leaves orphans silently.
    • Order is the id's own, ascending, which is what a range scan gives for free, and therefore LEXICOGRAPHIC over the stringified id: '10' sorts before '9'. Key ordered children by something naturally unique and ordered (an event ordinal, or (blockNumber, logIndex)) and make a numeric key fixed-width. If arrival order is wanted, that is a modelling answer, not a parameter.
    • Read-your-writes holds for a listing too: a child written earlier in the block appears and one deleted earlier in the block does not, which means merging the block's staging area into the scan rather than falling through to the store. The fetch budget accounts for staged deletes, so a limit is still filled from beyond them.
    • **In SQLite it is one indexed range s...
Read more

@etherfold/server@0.1.0

Choose a tag to compare

Minor Changes

  • 086de7b: Adds the platform-agnostic indexer-server and its Node host, and a serve command to the CLI.

    @etherfold/server is a Hono app that receives its database and environment by injection ({getDB, getEnv}) and imports no runtime: no Node built-ins, no Cloudflare types, no concrete driver. It ships the fixed-table schema and a /status route reporting database reachability, whether the schema is applied and at which version, and the last error this process saw. POST /admin/setup applies the schema. A test asserts the package names no runtime, so the property is checked rather than trusted.

    @etherfold/platform-nodejs is the Node host: a libSQL-backed RemoteSQL, environment from the process, served over HTTP. It applies the schema at startup by default (one process owning one file), which autoSetup: false disables.

    The CLI gains etherfold serve, which runs that host, so a project can start an indexer-server without wiring anything. etherfold index remains the default command, so existing etherfold -p <processor> -f <folder> invocations are unchanged.

    A Cloudflare Worker host also exists, at platforms/cf-worker, and is not published: it is a deployable, not a library.

    The server is a skeleton. It serves status and schema only: no chain logic, no store wiring, no feed. Those arrive with the tasks that follow ADR-0003.

  • b40298e: Asking where the next batch starts is now POST /ingest/expected-from-block, not GET /ingest.

    Answering that question can WRITE: it reconciles a persisted cursor belonging to a different source, config or processor version by calling processor.clear(), exactly as load() does in the single-process shape. A GET that writes is a trap whatever its justification — proxies, browser prefetch, link scanners and retrying clients all assume a GET is safe, and HTTP says it is — so the method now matches what it does.

    The token guard is registered on BOTH /ingest and /ingest/*: Hono matches /ingest exactly and would not have covered the new sub-path, which would have left half the fetcher-facing surface open while looking guarded. A test asserts a 401 on each.

  • e0a6480: The log ingestion endpoint, and the receiving half of the wire contract (ADR-0004).

    @etherfold/core gains StreamBuilder: the stream-builder of ADR-0003, as an object. It takes contiguous ranges of raw logs from a stateless log-fetcher, derives every retraction itself, drives an EventProcessor, and is authoritative about where the next range must start. It makes no chain calls at all, which is why it is not EthereumIndexer: that class opens load() with eth_chainId, so the half of a split deployment that hosts the processor could never use it. It reads the persisted cursor on every call rather than caching one, because the intended host is serverless and an in-memory cursor is one isolate's private opinion of a value the database owns.

    @etherfold/server gains GET and POST /ingest, behind an INGEST_TOKEN bearer token. The stream-builder is injected exactly like the database (getIngestion alongside getDB / getEnv), so which processor runs against which source stays a deployment's choice; a server with none answers 501 rather than pretending to have a cursor.

    The cursor is the idempotency key, so there is no dedupe table and no idempotency header. A batch whose fromBlock is not the server's expectedFromBlock is refused with 409 carrying that value, and the sender re-sends from there; a batch re-sent after a lost acknowledgement takes exactly that path, so at-least-once on the wire is exactly-once in effect. 409 is the only resumable refusal: a foreign {source, config}, a malformed range, or a payload that is not the range it claims are 400, because no block number makes them right and a sender must not retry them forever.

    generateStreamToAppend now throws a typed UnexpectedFromBlockError carrying expectedFromBlock, instead of an Error whose message had to be parsed. Same rule, same message, one place: the HTTP layer reads the number off the error rather than re-deriving it, so the wire and the engine cannot drift apart.

    A revert concluded from absence is surfaced and counted apart from one concluded from a hash contradiction. Absence is an inference and is indistinguishable from a sender that under-delivered a range, so /status now reports reorgs: {absence, contradiction, last} from the database (not from process memory, since a rate is the point and isolates are recycled), and an absence-driven revert is logged at error level naming the range. It does not make the server unhealthy: it is a signal to investigate, not a fault.

    Wire batches are serialized with serializeWireBatch / parseWireBatch, which tag BigInts as {__bigint__: "..."}. A decoded log's args hold a BigInt for every uint256 an ABI declares and JSON.stringify throws on those, while the older "123n" suffix convention would revive a contract-emitted string ending in n as a number. The tagged codec now lives once, in @etherfold/core (taggedBnReplacer / taggedBnReviver), and @etherfold/processor-entities' sync-cursor codec uses it instead of its own copy.

Patch Changes

@etherfold/processor-sqlite@0.1.0

Choose a tag to compare

Minor Changes

  • 5854d60: EntityEventProcessor: run an entity processor against ANY StateStore.

    The runtime the storage seam was built for and the one thing that was missing from it. new EntityEventProcessor(store, processor) is an EventProcessor the core drives, with the store INJECTED, so the same processor definition (entity declarations plus on<EventName> handlers over a MutationContext) indexes to SQLite on a server, to IndexedDB in a browser tab, to the light patch store or to memory in a test, with nothing about the processor changed. fromEntityProcessor(processor, options)(store) is the factory form, mirroring fromJSProcessor.

    process() hands back an EntityStateView: the seam's four reads (getCurrent / getAsOf / listCurrent / listAsOf) plus the capability report. queryCurrent / queryAsOf are deliberately NOT on it, and not stubbed to throw either, so asking a backend-neutral handle for caller-supplied SQL is a compile error in the editor rather than a runtime throw in a browser tab. VersionedStateView (@etherfold/processor-sqlite) is the tier that has them.

    The sync cursor moved behind the storage seam (ADR-0027) and is written in the same transaction as the block it describes. serializeLastSync / deserializeLastSync now live here, alongside SYNC_CURSOR_KEY, parseStoredCursor and syncedThrough; @etherfold/processor-sqlite re-exports all four from its sync.ts, and its _sync table is gone, so the SQL that reached it goes with it: SYNC_TABLE, SYNC_ROW_ID, SYNC_SCHEMA_DDL, readLastSync, writeLastSyncStatement and deleteLastSyncStatement are removed from @etherfold/processor-sqlite's surface. The storage is @etherfold/state-store-sqlite's neutral _cursor (key, value) table, reached through StateStore.readCursor / writeCursor / clearCursor. This closes a live defect: the cursor used to be a second round trip after the blocks, and a crash in that window left state ahead of the cursor, which is not self-healing — the restart replayed a block the store already held and applyBlock refused it, so the indexer wedged until a human intervened.

    applyEventStream takes an optional cursor ({key, lastSync}) and applies each block together with the cursor that describes THAT block, because one process call carries many blocks and each is its own transaction. A stream with no blocks still records the range it scanned.

    VersionedStateEventProcessor is unchanged in behaviour and is now a thin SQLite flavour of EntityEventProcessor: it builds a VersionedStateStore from a RemoteSQL, keeps the SQL read tier, and delegates the rest. Revert-then-apply, the block grouping, the version hash, the code fingerprint and the retention reconciliation exist once rather than twice.

  • 879c4fe: Lift the processor authoring API out of the SQLite packages, so one processor runs against several storage backends.

    Two new packages. @etherfold/state-store is the seam: entity declarations, MutationContext (now including update as sugar over get-then-spread-then-set), the StateStore interface a backend implements (migrate / applyBlock / getCurrent / getAsOf / revertTo), the capabilities it declares, and MemoryStateStore, a reference implementation in versioned rows over a Map. It declares no dependencies at all, which is what lets a storage primitive depend on it. @etherfold/processor-entities is the ABI-typed authoring surface (EntityProcessor, the on<EventName> handler map) plus the revert-then-apply engine (applyEventStream), written once against StateStore rather than per backend. ADR-0018 records why this is two packages and not one.

    A backend now reports what it can do as data, readable before migrate and before any read: a retention kind (revert-only, a window of N BLOCK NUMBERS, or unbounded) and whether it answers as-of reads. @etherfold/state-store-sqlite reports unbounded because that is what is true of it: the package has no pruning, and it deliberately takes no retention option, since a store that accepted a window it cannot enforce would be making exactly the claim the report exists to prevent.

    @etherfold/state-store-sqlite implements StateStore nominally, which it already did structurally: only capabilities was added. Its entity, mutation and block-pointer vocabulary is now defined at the seam and re-exported from here, so there is one definition rather than two; ColumnType is a deprecated alias of FieldType. Its block addressing (getBlock, hash and timestamp axes, NoSuchBlockError) and its SQL query surface (queryCurrent / queryAsOf) are unchanged and stay backend-specific on purpose.

    @etherfold/processor-sqlite consumes the authoring types rather than defining them. SQLProcessor is kept as a deprecated alias of EntityProcessor, so existing processors compile unchanged; the type never had anything SQL in it.

    The claim is asserted, not stated: processor-entities/test/two-backends.test.ts runs one processor, unmodified, against a real libSQL database and against the in-memory store, with the same declarations and the same handlers, and pins that the resulting state is identical, that read-your-writes composes two events in one block, and that a reorg makes a counter go back down on both.

  • 33afc5b: A processor's version is now REQUIRED, and the indexer reports when the declared version no longer matches the code.

    Breaking for processor authors, in both authoring surfaces. version becomes a required field on JSProcessor (@etherfold/js-processor) and on SQLProcessor (@etherfold/processor-sqlite), and a processor without a non-empty one now throws at construction, naming the processor by its handlers. Add a version to each processor object, ideally generated (as examples/event-processor-nfts does, from a hash of its own built file) so it cannot be forgotten.

    Breaking for EventProcessor implementors. getCodeFingerprint(): string | undefined is a REQUIRED method, not an optional one. An optional method would be a hole with a polite name: an implementation that never wrote one, or a wrapper that forgot to forward it, would lose drift detection with nothing to show for it. Returning undefined is still a valid answer and means "cannot tell", which is never reported as drift. Both cache wrappers (EventCache, ProcessorFilesystemCache) forward it.

    Breaking for stored state: every version hash changes, so existing state is discarded once. Both implementations dropped their fallback constants entirely rather than merely making them unreachable. ${version || 'unknown'} is gone with the optional version, and configHash || 'not-configured' is gone too: the config is now hashed the same way whether or not configure() was called, so an unconfigured processor and one configured with undefined no longer get different hashes and no longer discard each other's state.

    New: advisory drift detection for the version an author forgot to bump. getCodeFingerprint() is derived from the processor's own handler sources and persisted as LastSync.context.processorFingerprint. On load, when the version hash is UNCHANGED but the fingerprint is not, the core reports at error level through named-logs and through a new indexer.onProcessorDrift callback, and keeps going. Set strictProcessorDrift: true in the indexer config to refuse to start instead.

    • The fingerprint is deliberately NOT part of getVersionHash(). A minifier or a transpiler change moves it without changing behaviour, and folding that in would force a full state rebuild on a deploy that changed no logic.
    • Absence is never drift. A cursor with no fingerprint, and a processor that answers undefined, both report nothing.
    • processorCodeFingerprint(processor) and assertProcessorVersion(processor, implementation) are exported from @etherfold/core for anyone implementing their own EventProcessor.
    • ProcessorContext.version is now required, since every processor has one.
  • 01ab642: A configured retention window is now ENFORCED against storage, so a store that declares one stops growing.

    StateStore.prune(options?) is the new verb, on the seam and implemented by both shipped stores. It deletes the versions the declared retention no longer covers and reports what went: {tip, floor, versionsDeleted, complete}. Assert on versionsDeleted, never on reported bytes: navigator.storage.estimate() is quantised and lags badly enough that the spike measured it reporting MORE space used after a prune that dropped nothing.

    It is a call the HOST schedules, and that is a decision rather than an omission (ADR-0022). A prune plus VACUUM measured 1.1 seconds at 62,553 versions while a block on the same real stream carries a median of 7 mutations, so folding it into applyBlock would stall whichever block happened to cross a threshold, for work that block did not cause, and would have a store picking a maintenance cadence for a browser tab, a backfilling CLI and a long-running server alike. An amortised policy is prune({maxVersions: n}) on your own schedule (watch complete); a background policy is prune() on a timer. Pruning a store with nothing to enforce (unbounded, or revert-only with no declared finalityDepth) is a NO-OP and not an error, so a host may schedule it unconditionally. VersionedStateEventProcessor.prune() is the same call for a deployment that configured retention through the processor; it is on the processor and not on the read-only state view, because it is a write.

    The LIVE version of an entity is never dropped, however old it is. A row written once at block 12,082,307 and never touched again is still the current state, and on the real measured stream (event-bearing blocks median 429 apart, rows written once and never revisited) that is...

Read more

@etherfold/processor-entities@0.1.0

Choose a tag to compare

Minor Changes

  • 5854d60: EntityEventProcessor: run an entity processor against ANY StateStore.

    The runtime the storage seam was built for and the one thing that was missing from it. new EntityEventProcessor(store, processor) is an EventProcessor the core drives, with the store INJECTED, so the same processor definition (entity declarations plus on<EventName> handlers over a MutationContext) indexes to SQLite on a server, to IndexedDB in a browser tab, to the light patch store or to memory in a test, with nothing about the processor changed. fromEntityProcessor(processor, options)(store) is the factory form, mirroring fromJSProcessor.

    process() hands back an EntityStateView: the seam's four reads (getCurrent / getAsOf / listCurrent / listAsOf) plus the capability report. queryCurrent / queryAsOf are deliberately NOT on it, and not stubbed to throw either, so asking a backend-neutral handle for caller-supplied SQL is a compile error in the editor rather than a runtime throw in a browser tab. VersionedStateView (@etherfold/processor-sqlite) is the tier that has them.

    The sync cursor moved behind the storage seam (ADR-0027) and is written in the same transaction as the block it describes. serializeLastSync / deserializeLastSync now live here, alongside SYNC_CURSOR_KEY, parseStoredCursor and syncedThrough; @etherfold/processor-sqlite re-exports all four from its sync.ts, and its _sync table is gone, so the SQL that reached it goes with it: SYNC_TABLE, SYNC_ROW_ID, SYNC_SCHEMA_DDL, readLastSync, writeLastSyncStatement and deleteLastSyncStatement are removed from @etherfold/processor-sqlite's surface. The storage is @etherfold/state-store-sqlite's neutral _cursor (key, value) table, reached through StateStore.readCursor / writeCursor / clearCursor. This closes a live defect: the cursor used to be a second round trip after the blocks, and a crash in that window left state ahead of the cursor, which is not self-healing — the restart replayed a block the store already held and applyBlock refused it, so the indexer wedged until a human intervened.

    applyEventStream takes an optional cursor ({key, lastSync}) and applies each block together with the cursor that describes THAT block, because one process call carries many blocks and each is its own transaction. A stream with no blocks still records the range it scanned.

    VersionedStateEventProcessor is unchanged in behaviour and is now a thin SQLite flavour of EntityEventProcessor: it builds a VersionedStateStore from a RemoteSQL, keeps the SQL read tier, and delegates the rest. Revert-then-apply, the block grouping, the version hash, the code fingerprint and the retention reconciliation exist once rather than twice.

  • 4e75014: An entity store can start from state somebody else computed, and it stays honest about the history it never received (ADR-0028).

    This is the entity path's half of a capability the free-form path has always had: keepStateOnIndexedDB(name, remote) takes one or more published locations, asks each how far it has got, uses the furthest, prefers LOCAL state when local is already ahead, and skips an unreachable mirror rather than dying. A client that bootstraps comes up near the tip instead of replaying every log the contract ever emitted.

    @etherfold/state-store gains the snapshot envelope and the store handle that keeps it honest:

    • StateSnapshot -- {format, processor, savedAt, takenAt, cursor, rows}, deliberately shaped like the CLI's file envelope so a reader of one recognises the other. rows are the LIVE rows at takenAt, and SnapshotHead is the same envelope without them, which is what a client fetches to choose between mirrors.
    • openSnapshotAware(store) -- the handle a deployment that may bootstrap uses on EVERY boot (it migrates the store itself). .bootstrap(snapshot, {processor}) installs the rows and their cursor as one applyBlock, and records where the contents came from under a second cursor-port key (SNAPSHOT_ORIGIN_KEY), so a reload is as honest as the first run.
    • The honesty: a bootstrapped store reports its retention as a window whose oldest block is the snapshot's, never the unbounded a freshly migrated store would claim, and an as-of read below that block is refused with BlockNotRetainedError instead of answering undefined -- which would read as "the entity was absent then", an ordinary answer a caller acts on normally, and wrong. The floor is intersected with whatever the deployment configured, and a store that answers no historical read at all is left saying exactly that.
    • RevertBeyondSnapshotError -- a reorg reaching below the snapshot is refused loudly and changes nothing. There are no superseded versions under the snapshot to reopen at any price, and a partly undone reorg is a plausible state nothing downstream can tell apart from a correct one.
    • SnapshotProcessorMismatchError / SnapshotFormatError -- a snapshot computed by another processor version, or in an envelope this build does not read, is refused rather than loaded.

    @etherfold/processor-entities gains the client side:

    • bootstrapFromSnapshot(store, locations, {processor, finalityDepth?, fetch?}) -- mirrors, most-advanced-wins, prefer-local, fail over on error. Two deliberate differences from the free-form keeper: failover walks EVERY remaining candidate in descending order (the keeper tries the winner and one more), and a snapshot from another processor version is not a candidate at all. Given a finalityDepth, a snapshot taken inside the reorg-eligible window of the tip its producer had observed is declined, so the revert that could not be undone is avoided as well as refused. It returns a BootstrapOutcome rather than throwing when nothing is usable: indexing from the start block is the correct answer to "no snapshot is available".
    • openAndBootstrap(store, locations, options) -- the boot path, which keeps the SAFE order the short one: open snapshot-aware first, then bootstrap only if the store has never synced.
    • createSnapshot(...) -- the MINIMAL producer, and it says so. Publishing snapshots as a first-class artifact (a publish command, format versioning, mirror layout, pruning old ones) is a design of its own.

    @etherfold/browser gains no API and one piece of documentation that matters: createBrowserStateStore now says how a browser deployment bootstraps, and that the store must be opened through openSnapshotAware on EVERY boot rather than only on the boot that installs a snapshot. The mechanism deliberately does not live here -- deciding whether local is already ahead means reading lastToBlock out of a stored cursor, and the cursor's codec belongs to the entity runtime (ADR-0027), which this package does not depend on so that it stays free of any one processor package.

    @etherfold/state-store-conformance gains a bootstrapping from a snapshot group, so every backend inherits the obligation rather than rediscovering the trap in somebody's browser tab: rows and cursor installing as one unit, the origin surviving a fresh handle over the same storage, the revert refusal, the wipe still working, and -- selected on what the backend claims -- the floor being refused below and answered at and above.

  • 879c4fe: Lift the processor authoring API out of the SQLite packages, so one processor runs against several storage backends.

    Two new packages. @etherfold/state-store is the seam: entity declarations, MutationContext (now including update as sugar over get-then-spread-then-set), the StateStore interface a backend implements (migrate / applyBlock / getCurrent / getAsOf / revertTo), the capabilities it declares, and MemoryStateStore, a reference implementation in versioned rows over a Map. It declares no dependencies at all, which is what lets a storage primitive depend on it. @etherfold/processor-entities is the ABI-typed authoring surface (EntityProcessor, the on<EventName> handler map) plus the revert-then-apply engine (applyEventStream), written once against StateStore rather than per backend. ADR-0018 records why this is two packages and not one.

    A backend now reports what it can do as data, readable before migrate and before any read: a retention kind (revert-only, a window of N BLOCK NUMBERS, or unbounded) and whether it answers as-of reads. @etherfold/state-store-sqlite reports unbounded because that is what is true of it: the package has no pruning, and it deliberately takes no retention option, since a store that accepted a window it cannot enforce would be making exactly the claim the report exists to prevent.

    @etherfold/state-store-sqlite implements StateStore nominally, which it already did structurally: only capabilities was added. Its entity, mutation and block-pointer vocabulary is now defined at the seam and re-exported from here, so there is one definition rather than two; ColumnType is a deprecated alias of FieldType. Its block addressing (getBlock, hash and timestamp axes, NoSuchBlockError) and its SQL query surface (queryCurrent / queryAsOf) are unchanged and stay backend-specific on purpose.

    @etherfold/processor-sqlite consumes the authoring types rather than defining them. SQLProcessor is kept as a deprecated alias of EntityProcessor, so existing processors compile unchanged; the type never had anything SQL in it.

    The claim is asserted, not stated: processor-entities/test/two-backends.test.ts runs one processor, unmodified, against a real libSQL database and against the in-memory store, with the same declarations and the same handlers, and pins that the resulting state is identical, that read-your-writes composes two events in one block, and that a reorg makes a counter go back down on both.

  • 18c6876: The read su...

Read more