Releases: wighawag/etherfold
Release list
etherfold@0.7.0
Minor Changes
-
047cd73: Switch the build from
tsuptotscand ship ESM-only output. The CommonJS build (dist/*.cjs) and themainfield have been removed; packages are now consumed via themodule/exportsESM entrypoints only. Module resolution moves toNodeNext(relative imports now carry explicit.jsextensions, JSON imports use import attributes). -
bc5d71a: Update all dependencies to their latest versions and fix the resulting build.
Dependency updates (notable):
viem1.x →^2.52.0(major),abitype→^1.2.4pouchdb/pouchdb-find→^9.0.0,commander→^15.0.0,koa→^3.2.1typescript→^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 stricterencodeEventTopicsreturn type ((Hex | Hex[] | null)[]) and the genericeventNamereturned bydecodeEventLogoverAbiEvent[].@etherfold/browser: alignLastSync/ExistingStreamgeneric vs. baseAbiusage that broke under viem v2's tighterDecodeEventLogReturnType.@etherfold/fs-cache: spread typed event args safely; make the package explicitly ESM (type: module) with.jsimport extensions.- All published packages: add a standard
exportsmap (ESM-only, nomain) so modern bundlers/test runners (Vite/Vitest v4) resolve the package entry correctly.
JS processor authoring keeps full ABI-derived type safety (
event.argstyped from the ABI). -
086de7b: Adds the platform-agnostic indexer-server and its Node host, and a
servecommand to the CLI.@etherfold/serveris 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/statusroute reporting database reachability, whether the schema is applied and at which version, and the last error this process saw.POST /admin/setupapplies the schema. A test asserts the package names no runtime, so the property is checked rather than trusted.@etherfold/platform-nodejsis the Node host: a libSQL-backedRemoteSQL, environment from the process, served over HTTP. It applies the schema at startup by default (one process owning one file), whichautoSetup: falsedisables.The CLI gains
etherfold serve, which runs that host, so a project can start an indexer-server without wiring anything.etherfold indexremains the default command, so existingetherfold -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
@etherfoldscope (ADR-0017).ethereum-indexeris now@etherfold/core, andethereum-indexer-browser,-js-processor,-fs,-fs-cacheand-utilsare now@etherfold/browser,@etherfold/js-processor,@etherfold/fs,@etherfold/fs-cacheand@etherfold/utils. The two previously unpublished@ethereum-indexer/*packages move to@etherfold/*.The CLI is the one exception to the scope:
ethereum-indexer-clibecomes the flat packageetherfold, because it is the package that installs theetherfoldcommand.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
etherfoldinstead ofei, sonpm i -g etherfoldthenetherfold -p <processor>. Update any script that shells out toei.named-logsnamespaces follow the package names, so any log filter matchingethereum-indexer*needs updating to@etherfold/*. The CLI is the exception: its namespaces follow the command, soeiandei:keepStatebecomeetherfoldandetherfold:keepState.ethereum-indexer-serverandethereum-indexer-db-utilsare deliberately NOT renamed: both are on the retirement path set by ADR-0010, and they have since moved toarchive/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,bnReviverandisBigIntLiteralare removed from@etherfold/core, andbnReviveris removed from@etherfold/browser."123n"was both what123nserializes 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.unconfirmedBlockscarries decodedLogEvents whoseargshold a BigInt peruint256, and the same document carries thecontextdigests.535ccc1stopped that decoder THROWING on values that were never numbers and gavesimple_hasha leadingh; both were containment, and the guess itself is what this removes.Moved onto the tag:
etherfold's snapshot keeper,@etherfold/browser'skeepStateOnIndexedDBandkeepStateOnLocalStorage,@etherfold/fs's file keeper, and@etherfold/core's captured stream fixture.@etherfold/processor-entitieswas 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
nwould 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_FORMATis 2.parseStreamFixturerefuses a format-1 fixture, naming the file.etherfold'sSNAPSHOT_FORMATis 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 everyuint256had 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 andkeepStateOnLocalStorage'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. Callclear(), or clear site data.
keepStateOnIndexedDBneeded the codec only on its REMOTE reads: the local half hands the object toidb-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 base36simple_hashdigest, andcontext.processor,context.configandcontext.source[].hashare all made of those.BigInt('1x9tbh')throws, from insideJSON.parse. In the CLI, whosekeepState.fetchcatches 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 atry/catchsimply threw.- The predicate now lives once, in
@etherfold/coreasisBigIntLiteral(withbnReplacer/bnReviverbeside it), and every live copy uses it: the CLI, both browser adapters (includingkeepStateOnIndexedDB, the in-browser path ADR-0002 calls primary) and the fs adapter. A dead copy in@etherfold/js-processor'shistory.tswas deleted. simple_hashnow prefixes every digest withh, so all hashes change. A guard cannot rescue a digest of all digits ending inn(8918n), because that genuinely IS the convention's shape: such a digest came back from storage as a BigInt, andprocessorHash === context.processorthen compared a string to a BigInt and discarded state that was fine. The prefix makes the shape unreachable instead of unlikely.simple_hashno longer drops falsy values. It filtered with a bareif (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.undefinedis still dropped, matchingJSON.stringify, so a value hashed before and after a round trip still agree.simple_hashalso accepts BigInt values instead of throwing on them, which a processor config holding auint256would previously have ...
- The predicate now lives once, in
@etherfold/utils@0.7.0
Minor Changes
-
aeb7843:
createIndexerStatetakes an entity processor, so a tab can index into the store the application chose.The two halves existed and nothing joined them:
createBrowserStateStorebuilt a browserStateStoreand was referenced by nothing except its own test, while the hook's processor type wasEventProcessorWithInitialState— 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 bareEventProcessorWithInitialStatestill 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 forcreateInitialState, 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. keepStateon the entity path is refused, with a message naming the store: an entity deployment persists through itsStateStore, cursor included (ADR-0027), so a keeper there is a second place to persist rather than a second opinion.keepStatestays optional and unchanged for the free-form path.updateProcessortakes either kind, tagged the same way.options.createIndexernow receives the processor asEventProcessor<ABI, ProcessResultType>— whatnew EthereumIndexer(...)takes, and the one thing both kinds have in common. A caller that annotated that parameter asEventProcessorWithInitialStatehas to widen it.
Reload continuity is the browser-specific risk and it is now tested on a real engine.
pnpm --filter @etherfold/browser test:browserruns 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-patcha reload legitimately starts over (memory-only, ADR-0023), and the store says so incapabilities.durabilitybefore it happens.@etherfold/browserbundles for a browser again, and@etherfold/utilsgained a./indexersubpath to make that true. The barrel re-exports the CLI-side modules, whose top-levelnode:fs/node:path/node:moduleimports madeimport '@etherfold/browser'unresolvable for esbuild and for vite, before tree-shaking could help.storage/state/OnIndexedDB.tsnow importscontextFilenamesfrom@etherfold/utils/indexer(platform-free by construction), and a test bundles the package withplatform: 'browser'on every commit so it cannot come back.@etherfold/utils' existing barrel is unchanged. - The free-form path CREATES its initial state; the entity path READS its store through the handle the processor already exposes (
-
047cd73: Switch the build from
tsuptotscand ship ESM-only output. The CommonJS build (dist/*.cjs) and themainfield have been removed; packages are now consumed via themodule/exportsESM entrypoints only. Module resolution moves toNodeNext(relative imports now carry explicit.jsextensions, JSON imports use import attributes). -
bc5d71a: Update all dependencies to their latest versions and fix the resulting build.
Dependency updates (notable):
viem1.x →^2.52.0(major),abitype→^1.2.4pouchdb/pouchdb-find→^9.0.0,commander→^15.0.0,koa→^3.2.1typescript→^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 stricterencodeEventTopicsreturn type ((Hex | Hex[] | null)[]) and the genericeventNamereturned bydecodeEventLogoverAbiEvent[].@etherfold/browser: alignLastSync/ExistingStreamgeneric vs. baseAbiusage that broke under viem v2's tighterDecodeEventLogReturnType.@etherfold/fs-cache: spread typed event args safely; make the package explicitly ESM (type: module) with.jsimport extensions.- All published packages: add a standard
exportsmap (ESM-only, nomain) so modern bundlers/test runners (Vite/Vitest v4) resolve the package entry correctly.
JS processor authoring keeps full ABI-derived type safety (
event.argstyped from the ABI). -
e0e5832: Renamed to the
@etherfoldscope (ADR-0017).ethereum-indexeris now@etherfold/core, andethereum-indexer-browser,-js-processor,-fs,-fs-cacheand-utilsare now@etherfold/browser,@etherfold/js-processor,@etherfold/fs,@etherfold/fs-cacheand@etherfold/utils. The two previously unpublished@ethereum-indexer/*packages move to@etherfold/*.The CLI is the one exception to the scope:
ethereum-indexer-clibecomes the flat packageetherfold, because it is the package that installs theetherfoldcommand.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
etherfoldinstead ofei, sonpm i -g etherfoldthenetherfold -p <processor>. Update any script that shells out toei.named-logsnamespaces follow the package names, so any log filter matchingethereum-indexer*needs updating to@etherfold/*. The CLI is the exception: its namespaces follow the command, soeiandei:keepStatebecomeetherfoldandetherfold:keepState.ethereum-indexer-serverandethereum-indexer-db-utilsare deliberately NOT renamed: both are on the retirement path set by ADR-0010, and they have since moved toarchive/in the repository, outside the workspace. Their published versions stay installable and are not deprecated here. -
47252ad: Add a shared
resolveProcessorAndSourcehelper (plus the smallerloadProcessorModule,instantiateProcessorandresolveSourcebuilding 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'sinit()and the server'ssetupIndexing()(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 explicitprocessorConfigparameter 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, thecontractsDataPerChain/contractsDataresolution, 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
.jsbut survives in the emitted.d.ts. These packages name types fromabitype,eip-1193and@etherfold/corein their public declarations while listing those asdevDependencies, so a consumer installing them got declaration files importing packages that were never installed.Moved to
dependencies:abitypeandeip-1193in@etherfold/core,eip-1193in@etherfold/browser, and@etherfold/corein@etherfold/utils.Measured against a packed tarball installed under pnpm's isolated linker with
hoist=false,tsc --strict --skipLibCheck falsereported 11 errors (6 forabitype, 5 foreip-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:
abitypewas masked that way by viem and failed only with hoisting off, whileeip-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.tsfiles is a declared dependency. It found the@etherfold/utilscase, 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
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
atthat 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 isassertBlockNumber, called first thing insideassertRetained, so it is written ONCE and every backend whose as-of reads take a block number inherits it (memory, patch, IndexedDB) acrossgetAsOfandlistAsOfalike, rather than three copies drifting.- It is a
TypeError, deliberately outside theBlockUnavailableErrorfamily. 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-numberatis a fact about the CALL: no store configuration makes it answerable, so it is a programmer error and it does not get swallowed by acatch (e) { if (e instanceof BlockUnavailableError) ... }written for the other thing. It comes BEFORE the retention check for the same reason: arevert-onlystore answering "not retained" would send its caller off to widen a window that was never the problem. @etherfold/state-store-sqlitekeeps 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 sameInvalidBlockNumberError(via the seam's sharedisBlockNumber) instead of a bareError, with the same message it had;NoSuchBlockErrorstill answers an address that resolves to no recorded block.
MutationContext.getanswers with a WHOLE row for a key staged in the same block. It returned{...staged.values}, which is only what the handler passed toset, so an id column and a declared field the write did not list wereundefinedfor 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.getnow builds a staged row throughstagedRow, the constructionlistalready 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 readsgains the refusal (asked of every backend, whatever addressing sits above it), andread-your-writes within a blockgains the row shape plus a case pinning thatgetandlistagree about a staged row. - It is a
-
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-storegains 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.rowsare the LIVE rows attakenAt, andSnapshotHeadis 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 oneapplyBlock, 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
unboundeda freshly migrated store would claim, and an as-of read below that block is refused withBlockNotRetainedErrorinstead of answeringundefined-- 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-entitiesgains 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 afinalityDepth, 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 aBootstrapOutcomerather 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/browsergains no API and one piece of documentation that matters:createBrowserStateStorenow says how a browser deployment bootstraps, and that the store must be opened throughopenSnapshotAwareon 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 readinglastToBlockout 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-conformancegains abootstrapping from a snapshotgroup, 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
@derivedFromdoes it: children are their own entity keyed by their parent, and the collection is DERIVED WHEN READ. Nothing is maintained at write time.MutationContextgainslist;StateStoregainslistCurrentandlistAsOf, 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, noorderByand 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'squeryCurrent/queryAsOf, which do take caller-supplied SQL, are the server-side read layer and are unchanged. Seedocs/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, becauserows.length === limitcannot 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...
- Truncation is reported, never inferred. A listing answers
@etherfold/state-store-sqlite@0.1.0
Minor Changes
-
df47021: State can now be read as of a block hash, a height or a timestamp.
getAsOfandqueryAsOftake aBlockAddress(101,{number},{hash}or{timestamp}) where they took a block number. All three axes resolve to a block number through the canonical_blockstable 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 areasonofunknown-hashorno-recorded-block-at-or-before), whileundefinedkeeps its ordinary meaning.resolveBlockNumber(address)is the soft form, answeringundefinedand throwing nothing, andgetBlock(address)returns the recorded row so a consumer can turn a time or a height into the hash to pin. Seedocs/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
applyBlockis 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. normalizeBlockTimestampreadsblockTimestampoff 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
@derivedFromdoes it: children are their own entity keyed by their parent, and the collection is DERIVED WHEN READ. Nothing is maintained at write time.MutationContextgainslist;StateStoregainslistCurrentandlistAsOf, 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, noorderByand 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'squeryCurrent/queryAsOf, which do take caller-supplied SQL, are the server-side read layer and are unchanged. Seedocs/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, becauserows.length === limitcannot 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 BYthe declared id rides the entity's id index with no sort and no table scan. Pinned by the generated statement's shape AND byEXPLAIN 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-entitiesgained a test that models the real ordered bounded collection fromwork/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.
- Truncation is reported, never inferred. A listing answers
-
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-keywordfixed 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 (normalizeEntitiesde-duplicated by exact string) and then meant two different things: SQLite folds identifier case even inside quotes, soCREATE TABLE IF NOT EXISTS "Token"matched the existingtokenand was silently SKIPPED, leaving ONE table withtoken's columns andgetCurrent('Token', ...)answering withtoken'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 andduplicate column nameatmigrate()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 declaringtokenbesidetoken_openwas two ordinary entities everywhere else andSQLITE_ERROR: there is already an index named token_openhere. 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 atmigrate(). SQLite refusessqlite_-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, socaféandcafe+ 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-conformancecarries every new rule, so a future backend inherits the obligation instead of rediscovering it. Thea declaration means the same thing on every backendgroup gains the case cases (entity, id column, field, and across the id/field boundary), the non-ASCII case, andDECLARATION_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 andentity-identifier-sql-keyword's...
@etherfold/state-store-patch@0.1.0
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.tsasserts that equality against@etherfold/state-store-sqliteon the same input, and the store passes@etherfold/state-store-conformanceunder 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. SorevertToworks and is the reason this backend exists, while every as-of read throwsBlockNotRetainedErrorat 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,
revertTothrowsRevertBeyondPatchHistoryError(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-stateKeepStatepath above, and the row-level IndexedDB backend beside. See ADR-0023.prune()drops the reverse patches at or belowtip - finalityDepthand is a call the host schedules (ADR-0022), never a side effect of a write, which is the deliberate difference from@etherfold/js-processor'sHistory. -
5854d60: The storage seam gains a sync-cursor port, and
applyBlockcan write the cursor with the block (ADR-0027).StateStoregainsreadCursor(key)/writeCursor(key, value)/clearCursor(key)over an opaque string, andapplyBlock(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/coretype, 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-storestill declares no dependencies at all.Per backend:
@etherfold/state-store-sqlite: a new fixed_cursor (key, value)table, created bymigrate()alongside_blocks, and the cursor statement rides in the samebatch([...])as the block.CURSOR_TABLE,readCursorStatement,writeCursorStatementandclearCursorStatementare exported like the rest of the SQL.@etherfold/state-store-indexeddb: a newcursorsobject store, written inside the block's own transaction. The package's schema version moved from 1 to 2; the upgrade is additive andcontains-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. Itsdurability: '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-conformancegains athe sync cursorgroup: 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
revertToand not touched byprune: how far the caller got is not entity state.
Patch Changes
- Updated dependencies [ff393f7]
- Updated dependencies [4e75014]
- Updated dependencies [ce8f7d2]
- Updated dependencies [b61de79]
- Updated dependencies [2a4e6ed]
- Updated dependencies [879c4fe]
- Updated dependencies [01ab642]
- Updated dependencies [18c6876]
- Updated dependencies [ab45129]
- Updated dependencies [ebf9690]
- Updated dependencies [5854d60]
- @etherfold/state-store@0.1.0
@etherfold/state-store-indexeddb@0.1.0
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-sqliteruns on it unchanged, and@etherfold/browsergains 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 aMutationContext, 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, fromwork/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-conformanceunder all three retention claims, in node underfake-indexeddbon every commit and in Chromium, Firefox and WebKit viapnpm --filter @etherfold/state-store-indexeddb test:browser(the same suite, not a browser-flavoured copy). Evidence indocs/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
BlockNotRetainedErrorand never the tip value, andprunewalks theupperindex — where a LIVE version cannot appear at all, becausenullis 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. revertTois two index range scans (drop what the fork opened, reopen what it closed) rather than a per-block undo journal, andgetAsOfis 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.
- It passes
-
5854d60: The storage seam gains a sync-cursor port, and
applyBlockcan write the cursor with the block (ADR-0027).StateStoregainsreadCursor(key)/writeCursor(key, value)/clearCursor(key)over an opaque string, andapplyBlock(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/coretype, 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-storestill declares no dependencies at all.Per backend:
@etherfold/state-store-sqlite: a new fixed_cursor (key, value)table, created bymigrate()alongside_blocks, and the cursor statement rides in the samebatch([...])as the block.CURSOR_TABLE,readCursorStatement,writeCursorStatementandclearCursorStatementare exported like the rest of the SQL.@etherfold/state-store-indexeddb: a newcursorsobject store, written inside the block's own transaction. The package's schema version moved from 1 to 2; the upgrade is additive andcontains-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. Itsdurability: '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-conformancegains athe sync cursorgroup: 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
revertToand not touched byprune: how far the caller got is not entity state.
Patch Changes
- Updated dependencies [ff393f7]
- Updated dependencies [4e75014]
- Updated dependencies [ce8f7d2]
- Updated dependencies [b61de79]
- Updated dependencies [2a4e6ed]
- Updated dependencies [879c4fe]
- Updated dependencies [01ab642]
- Updated dependencies [18c6876]
- Updated dependencies [ab45129]
- Updated dependencies [ebf9690]
- Updated dependencies [5854d60]
- @etherfold/state-store@0.1.0
@etherfold/state-store-conformance@0.1.0
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
atthat 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 isassertBlockNumber, called first thing insideassertRetained, so it is written ONCE and every backend whose as-of reads take a block number inherits it (memory, patch, IndexedDB) acrossgetAsOfandlistAsOfalike, rather than three copies drifting.- It is a
TypeError, deliberately outside theBlockUnavailableErrorfamily. 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-numberatis a fact about the CALL: no store configuration makes it answerable, so it is a programmer error and it does not get swallowed by acatch (e) { if (e instanceof BlockUnavailableError) ... }written for the other thing. It comes BEFORE the retention check for the same reason: arevert-onlystore answering "not retained" would send its caller off to widen a window that was never the problem. @etherfold/state-store-sqlitekeeps 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 sameInvalidBlockNumberError(via the seam's sharedisBlockNumber) instead of a bareError, with the same message it had;NoSuchBlockErrorstill answers an address that resolves to no recorded block.
MutationContext.getanswers with a WHOLE row for a key staged in the same block. It returned{...staged.values}, which is only what the handler passed toset, so an id column and a declared field the write did not list wereundefinedfor 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.getnow builds a staged row throughstagedRow, the constructionlistalready 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 readsgains the refusal (asked of every backend, whatever addressing sits above it), andread-your-writes within a blockgains the row shape plus a case pinning thatgetandlistagree about a staged row. - It is a
-
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-storegains 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.rowsare the LIVE rows attakenAt, andSnapshotHeadis 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 oneapplyBlock, 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
unboundeda freshly migrated store would claim, and an as-of read below that block is refused withBlockNotRetainedErrorinstead of answeringundefined-- 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-entitiesgains 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 afinalityDepth, 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 aBootstrapOutcomerather 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/browsergains no API and one piece of documentation that matters:createBrowserStateStorenow says how a browser deployment bootstraps, and that the store must be opened throughopenSnapshotAwareon 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 readinglastToBlockout 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-conformancegains abootstrapping from a snapshotgroup, 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
@derivedFromdoes it: children are their own entity keyed by their parent, and the collection is DERIVED WHEN READ. Nothing is maintained at write time.MutationContextgainslist;StateStoregainslistCurrentandlistAsOf, 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, noorderByand 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'squeryCurrent/queryAsOf, which do take caller-supplied SQL, are the server-side read layer and are unchanged. Seedocs/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, becauserows.length === limitcannot 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...
- Truncation is reported, never inferred. A listing answers
@etherfold/server@0.1.0
Minor Changes
-
086de7b: Adds the platform-agnostic indexer-server and its Node host, and a
servecommand to the CLI.@etherfold/serveris 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/statusroute reporting database reachability, whether the schema is applied and at which version, and the last error this process saw.POST /admin/setupapplies the schema. A test asserts the package names no runtime, so the property is checked rather than trusted.@etherfold/platform-nodejsis the Node host: a libSQL-backedRemoteSQL, environment from the process, served over HTTP. It applies the schema at startup by default (one process owning one file), whichautoSetup: falsedisables.The CLI gains
etherfold serve, which runs that host, so a project can start an indexer-server without wiring anything.etherfold indexremains the default command, so existingetherfold -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, notGET /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 asload()does in the single-process shape. AGETthat writes is a trap whatever its justification — proxies, browser prefetch, link scanners and retrying clients all assume aGETis safe, and HTTP says it is — so the method now matches what it does.The token guard is registered on BOTH
/ingestand/ingest/*: Hono matches/ingestexactly 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/coregainsStreamBuilder: 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 anEventProcessor, and is authoritative about where the next range must start. It makes no chain calls at all, which is why it is notEthereumIndexer: that class opensload()witheth_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/servergainsGETandPOST /ingest, behind anINGEST_TOKENbearer token. The stream-builder is injected exactly like the database (getIngestionalongsidegetDB/getEnv), so which processor runs against which source stays a deployment's choice; a server with none answers501rather 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
fromBlockis not the server'sexpectedFromBlockis refused with409carrying 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.409is the only resumable refusal: a foreign{source, config}, a malformed range, or a payload that is not the range it claims are400, because no block number makes them right and a sender must not retry them forever.generateStreamToAppendnow throws a typedUnexpectedFromBlockErrorcarryingexpectedFromBlock, instead of anErrorwhose 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
/statusnow reportsreorgs: {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 aterrorlevel 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'sargshold a BigInt for everyuint256an ABI declares andJSON.stringifythrows on those, while the older"123n"suffix convention would revive a contract-emitted string ending innas 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
- 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/processor-sqlite@0.1.0
Minor Changes
-
5854d60:
EntityEventProcessor: run an entity processor against ANYStateStore.The runtime the storage seam was built for and the one thing that was missing from it.
new EntityEventProcessor(store, processor)is anEventProcessorthe core drives, with the store INJECTED, so the same processor definition (entity declarations pluson<EventName>handlers over aMutationContext) 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, mirroringfromJSProcessor.process()hands back anEntityStateView: the seam's four reads (getCurrent/getAsOf/listCurrent/listAsOf) plus the capability report.queryCurrent/queryAsOfare 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/deserializeLastSyncnow live here, alongsideSYNC_CURSOR_KEY,parseStoredCursorandsyncedThrough;@etherfold/processor-sqlitere-exports all four from itssync.ts, and its_synctable is gone, so the SQL that reached it goes with it:SYNC_TABLE,SYNC_ROW_ID,SYNC_SCHEMA_DDL,readLastSync,writeLastSyncStatementanddeleteLastSyncStatementare removed from@etherfold/processor-sqlite's surface. The storage is@etherfold/state-store-sqlite's neutral_cursor (key, value)table, reached throughStateStore.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 andapplyBlockrefused it, so the indexer wedged until a human intervened.applyEventStreamtakes an optionalcursor({key, lastSync}) and applies each block together with the cursor that describes THAT block, because oneprocesscall carries many blocks and each is its own transaction. A stream with no blocks still records the range it scanned.VersionedStateEventProcessoris unchanged in behaviour and is now a thin SQLite flavour ofEntityEventProcessor: it builds aVersionedStateStorefrom aRemoteSQL, 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-storeis the seam: entity declarations,MutationContext(now includingupdateas sugar over get-then-spread-then-set), theStateStoreinterface a backend implements (migrate/applyBlock/getCurrent/getAsOf/revertTo), the capabilities it declares, andMemoryStateStore, 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-entitiesis the ABI-typed authoring surface (EntityProcessor, theon<EventName>handler map) plus the revert-then-apply engine (applyEventStream), written once againstStateStorerather 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
migrateand before any read: a retention kind (revert-only, a window of N BLOCK NUMBERS, orunbounded) and whether it answers as-of reads.@etherfold/state-store-sqlitereportsunboundedbecause 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-sqliteimplementsStateStorenominally, which it already did structurally: onlycapabilitieswas 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;ColumnTypeis a deprecated alias ofFieldType. 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-sqliteconsumes the authoring types rather than defining them.SQLProcessoris kept as a deprecated alias ofEntityProcessor, 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.tsruns 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
versionis now REQUIRED, and the indexer reports when the declared version no longer matches the code.Breaking for processor authors, in both authoring surfaces.
versionbecomes a required field onJSProcessor(@etherfold/js-processor) and onSQLProcessor(@etherfold/processor-sqlite), and a processor without a non-empty one now throws at construction, naming the processor by its handlers. Add aversionto each processor object, ideally generated (asexamples/event-processor-nftsdoes, from a hash of its own built file) so it cannot be forgotten.Breaking for
EventProcessorimplementors.getCodeFingerprint(): string | undefinedis 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. Returningundefinedis 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, andconfigHash || 'not-configured'is gone too: the config is now hashed the same way whether or notconfigure()was called, so an unconfigured processor and one configured withundefinedno 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 asLastSync.context.processorFingerprint. On load, when the version hash is UNCHANGED but the fingerprint is not, the core reports at error level throughnamed-logsand through a newindexer.onProcessorDriftcallback, and keeps going. SetstrictProcessorDrift: truein 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)andassertProcessorVersion(processor, implementation)are exported from@etherfold/corefor anyone implementing their ownEventProcessor.ProcessorContext.versionis now required, since every processor has one.
- The fingerprint is deliberately NOT part of
-
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 onversionsDeleted, 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
VACUUMmeasured 1.1 seconds at 62,553 versions while a block on the same real stream carries a median of 7 mutations, so folding it intoapplyBlockwould 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 isprune({maxVersions: n})on your own schedule (watchcomplete); a background policy isprune()on a timer. Pruning a store with nothing to enforce (unbounded, orrevert-onlywith no declaredfinalityDepth) 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-onlystateview, 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...
@etherfold/processor-entities@0.1.0
Minor Changes
-
5854d60:
EntityEventProcessor: run an entity processor against ANYStateStore.The runtime the storage seam was built for and the one thing that was missing from it.
new EntityEventProcessor(store, processor)is anEventProcessorthe core drives, with the store INJECTED, so the same processor definition (entity declarations pluson<EventName>handlers over aMutationContext) 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, mirroringfromJSProcessor.process()hands back anEntityStateView: the seam's four reads (getCurrent/getAsOf/listCurrent/listAsOf) plus the capability report.queryCurrent/queryAsOfare 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/deserializeLastSyncnow live here, alongsideSYNC_CURSOR_KEY,parseStoredCursorandsyncedThrough;@etherfold/processor-sqlitere-exports all four from itssync.ts, and its_synctable is gone, so the SQL that reached it goes with it:SYNC_TABLE,SYNC_ROW_ID,SYNC_SCHEMA_DDL,readLastSync,writeLastSyncStatementanddeleteLastSyncStatementare removed from@etherfold/processor-sqlite's surface. The storage is@etherfold/state-store-sqlite's neutral_cursor (key, value)table, reached throughStateStore.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 andapplyBlockrefused it, so the indexer wedged until a human intervened.applyEventStreamtakes an optionalcursor({key, lastSync}) and applies each block together with the cursor that describes THAT block, because oneprocesscall carries many blocks and each is its own transaction. A stream with no blocks still records the range it scanned.VersionedStateEventProcessoris unchanged in behaviour and is now a thin SQLite flavour ofEntityEventProcessor: it builds aVersionedStateStorefrom aRemoteSQL, 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-storegains 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.rowsare the LIVE rows attakenAt, andSnapshotHeadis 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 oneapplyBlock, 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
unboundeda freshly migrated store would claim, and an as-of read below that block is refused withBlockNotRetainedErrorinstead of answeringundefined-- 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-entitiesgains 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 afinalityDepth, 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 aBootstrapOutcomerather 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/browsergains no API and one piece of documentation that matters:createBrowserStateStorenow says how a browser deployment bootstraps, and that the store must be opened throughopenSnapshotAwareon 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 readinglastToBlockout 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-conformancegains abootstrapping from a snapshotgroup, 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-storeis the seam: entity declarations,MutationContext(now includingupdateas sugar over get-then-spread-then-set), theStateStoreinterface a backend implements (migrate/applyBlock/getCurrent/getAsOf/revertTo), the capabilities it declares, andMemoryStateStore, 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-entitiesis the ABI-typed authoring surface (EntityProcessor, theon<EventName>handler map) plus the revert-then-apply engine (applyEventStream), written once againstStateStorerather 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
migrateand before any read: a retention kind (revert-only, a window of N BLOCK NUMBERS, orunbounded) and whether it answers as-of reads.@etherfold/state-store-sqlitereportsunboundedbecause 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-sqliteimplementsStateStorenominally, which it already did structurally: onlycapabilitieswas 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;ColumnTypeis a deprecated alias ofFieldType. 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-sqliteconsumes the authoring types rather than defining them.SQLProcessoris kept as a deprecated alias ofEntityProcessor, 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.tsruns 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...