AArkiv SQL

SQL over live Arkiv data, in your browser.

Arkiv SQL Indexer mirrors Arkiv entities into Parquet snapshots. Your browser loads them into DuckDB WASM for joins, grouping and window functions, follows new blocks from the public RPC, and drops each row once its expiry block passes.

Open source (MIT), built 9 to 18 October 2026 for the Open Source and Security tracks. Runs on Tiramisu with Arkiv SDK 0.8.1.

SELECT creator, count(*) AS count
FROM entities
GROUP BY creator
ORDER BY count DESC, creator
LIMIT 10
The Top 10 creators chart in the showcase. arkiv_query filters and pages entities; it has no GROUP BY, JOIN or ORDER BY on attributes.

Who this protects

A dApp or analyst that filters Arkiv data by a user written attribute, such as project or type.

Attributes are written by whoever creates the entity. Anyone can create an entity with project = arkiv-sql-indexer-demo, so a dashboard that trusts that attribute counts forged rows as its own. User attributes alone are not an identity boundary.

The usual answer is a hosted indexer or a subgraph, which asks every user to trust whoever runs it, and which typically keeps rows after the data was meant to expire.

README.md, SPECS.md

What changes from day one

  • Rows are scoped by $creator. Set INDEX_EXPRESSION to $creator = addr(…) and only that wallet's entities are indexed, including tickets later handed to another owner. Startup refuses to reuse a store built for a different expression.README.md, packages/core/src/query.ts, apps/indexer/src/store.ts
  • Any row can be checked against the chain. Verify re-reads key, updated_at, expires_at and owner from the public RPC and labels the row ok, stale, extended, expired or deleted. It is a consistency check against an independent RPC, not a cryptographic proof.apps/web/lib/verify.ts, apps/web/components/dash/ResultsGrid.tsx
  • SQL runs in your tab. There is no server SQL endpoint. No API key reaches the browser, and by default the indexer never sends one either.apps/indexer/src/main.ts, apps/indexer/test/keyless.test.ts
  • Expired rows leave every layer. Snapshots, the indexer store and the browser views all drop entities past their expiry block.apps/indexer/src/snapshot.ts, apps/indexer/src/store.ts, packages/core/src/views.ts

Build on it without asking

The data contract is two SQL views, entities and attributes, documented column by column in arkiv/schema.md. Snapshots are plain Parquet files listed in /data/manifest.json, readable by any Parquet tool. The code is MIT licensed, and the whole stack deploys with Docker Compose.

arkiv/schema.md, apps/web/lib/disk-data.ts, LICENSE, docker-compose.yml

How it works

A self hosted indexer prepares the files. Everything you query runs in your browser, which also talks to Arkiv directly.

Architecture of Arkiv SQL IndexerThe Arkiv RPC on Tiramisu feeds a Bun indexer, which writes a DuckDB checkpoint, Parquet snapshots and a JSON delta. A Next.js proxy serves those files to the browser, which runs DuckDB WASM. The browser also talks to the Arkiv RPC directly, without an API key, for the live head, log tail, payloads and verify. An optional Bun demo writer sends SDK writes to the RPC.Demo writerBun and SDK, optionalArkiv RPC on Tiramisupublic endpoint, chain 7738577Docker ComposeBun indexerlog replay, attribute fill, sweepDuckDB checkpointindex.duckdbParquetversioned, keep 2JSON deltaper sync batchNext.js proxy/data with Range requestsYour browser: DuckDB WASMentities and attributes views, SQL, live taildirect, no API keylive headeth_getLogspayloadverify
  1. Replay the chain. The indexer reads public eth_getLogs up to 3 blocks behind head and writes every live entity from log data: owner, creator, blocks and expiry.
  2. Fill attributes. Keyed lookups of at most 50 keys, newest first, each pinned to one block, on the anonymous RPC budget.
  3. Stay live. Each sync batch applies creations, patches, extensions, transfers and deletions straight from logs, then publishes a JSON delta.
  4. Publish snapshots. Every 30 blocks a new versioned pair of Parquet files is written. Older versions are pruned, two are kept.
  5. Load in the browser. The Next.js proxy streams the files with Range support. DuckDB WASM merges snapshot, delta and the live tail.
  6. Follow the chain from the tab. The browser polls the public RPC for new logs about every 4 seconds. Payload and verify go to the RPC directly, never through the indexer.
apps/indexer/src/replay.ts, apps/indexer/src/fill.ts, apps/indexer/src/sync.ts, apps/indexer/src/config.ts, apps/web/lib/proxy.ts, apps/web/lib/live-tail.ts

Why Arkiv

The project depends on properties that only hold when the data lives on Arkiv.

Postgres

Whoever runs the database can edit any row, and readers have no independent source to check it against. Expiry becomes a job you hope keeps running.

IPFS

Content is addressed by hash and cannot change in place. There is no owner, no attribute query and no expiry beyond whoever stops pinning.

A subgraph

It needs a contract that emits events and a hosted indexer that users have to trust. Nothing in it removes rows when the source data should be gone.

Your own API

Users trust your server twice: for the data, and for the claim of who wrote it.

What we rely on

  • $creator and $owner are system fields. The chain sets them, so the creator scope cannot be forged with an attribute.
  • Native expiry. Every entity has an expiresAt block. The index enforces it instead of inventing its own retention.
  • Typed, queryable attributes at a pinned block. Lookups combine $creator, $key and atBlock with cursor pagination.
  • A public RPC anyone can read. Your browser checks rows and follows logs without the indexer in the path.

What stays off Arkiv, on purpose

  • Parquet snapshots and the DuckDB checkpoint. Derived, disposable copies. They can be rebuilt from public logs, and a store is tied to one chain ID and genesis hash.
  • SQL execution. arkiv_query returns at most 200 results per page with boolean filters only, so joins, grouping and window functions run in DuckDB.
  • Payloads stay on chain. They are fetched on demand and never written to Parquet.
SPECS.md, arkiv/schema.md, apps/indexer/src/store.ts

How we use Arkiv

Each feature below is in the code. For each: what it does, why it was chosen, and what you see.

Typed attributes, payload on demand

What the code does
Tickets carry priority as u64, assignee as addr; comments point at their ticket with a key attribute. Decoding keeps exact integers as text and a numeric num column.
Why
Typed values sort and aggregate correctly in SQL. Payloads are not needed for analytics, so they never enter Parquet.
What you see
A Payload button on each result row reads the payload from the RPC when you click it, shown up to 128 KiB.
apps/demo-writer/src/writer.ts, packages/core/src/decode.ts, apps/web/components/RowActions.tsx

Compound queries scoped by $creator

What the code does
Key lookups render ($creator = addr(…)) AND ($key = key(…) OR …) with the SDK query builder, at most 50 keys per call.
Why
50 key terms fit the query length limit, 100 did not. The creator term keeps forged look alike entities out.
What you see
The status bar shows the indexed expression. With a creator expression, rows from other creators never reach your tab.
packages/core/src/query.ts, apps/web/components/dash/StatusBar.tsx

Pinned reads with atBlock

What the code does
Backfill and fills pass atBlock with cursor pagination, and a fill page answered at a different block is rejected.
Why
Every page of one lookup describes the same block, so a write landing mid scan cannot tear a result.
What you see
Consistent rows, and a status bar that tells you how far the index is behind the chain head.
apps/indexer/src/backfill.ts, apps/indexer/src/fill.ts

Expiry per entity type

What the code does
The demo writer requests ExpirationTime.fromSeconds of 600 for heartbeats, 86400 for comments and 604800 for tickets.
Why
Short lived data shows that expiry really removes rows; long lived tickets carry the joins.
What you see
The Expiry view in the showcase, and heartbeats leaving SQL ten minutes after creation.
apps/demo-writer/src/plan.ts

Leases renewed by activity

What the code does
Each later comment calls extendEntity on its ticket for another 7 days. Tickets are created with permissionlessExtension so renewal still works after a transfer.
Why
A ticket with live discussion stays; an abandoned one expires on its own.
What you see
A ticket's expires_at moves forward. Verify reports extended when the chain holds a newer update with a later expiry than your row.
apps/demo-writer/src/writer.ts, apps/demo-writer/src/plan.ts, apps/web/lib/verify.ts

One batch for a ticket and its first comment

What the code does
predictEntityKeys yields the ticket key, then executeBatch creates the ticket and a comment that already references it.
Why
No ticket ever exists without its opening comment, and the reference is right from the first block.
What you see
The featured playground query joins open tickets to their comment counts.
apps/demo-writer/src/writer.ts, apps/web/lib/views.ts

Ownership moves, creator stays

What the code does
Assigning a ticket patches assignee, then changeOwnership hands it to an agent wallet.
Why
$owner changes but $creator does not, so an index scoped by creator keeps the ticket.
What you see
The Creator vs current owner table in the showcase Creators view.
apps/demo-writer/src/writer.ts, apps/web/lib/views.ts

Keyless sync from native logs

What the code does
The indexer and the browser decode Arkiv entity events from eth_getLogs; no arkiv_query runs per event. By default no request carries an API key.
Why
About 2,800 keys change per hour on Tiramisu. A keyed query per change would spend 25 to 35 percent of the monthly quota per day.
What you see
The live block, pending attribute fetches and RPC budget state in the status bar.
apps/indexer/src/sync.ts, apps/web/lib/live-tail.ts, apps/indexer/test/keyless.test.ts

Expiry, end to end

Three demo entity types with different lifetimes, and three places where expired rows are removed.

Entity lifetimes and expiry enforcementHeartbeats live 10 minutes, comments 1 day and tickets 7 days. Each new comment extends its ticket by another 7 days. Three layers remove expired rows: the indexer snapshot build, the indexer sweep and snapshot pruning, and the browser SQL views filtering against the live chain head.heartbeat10 mincomment1 dayticket7 dayseach new comment calls extendEntity: 7 more days1 min10 min1 h1 day7 daysThree layers remove expired rowsindexerSnapshot buildcopies only rows with expires_at > headindexerSweep and prunedeletes expired rows, keeps 2 snapshotsbrowserSQL viewsfilter against live_head read from RPC

Heartbeats disappear through expiry without any event. Comments live a day. Tickets live a week, and every new comment renews the lease.

The snapshot build skips rows with expires_at <= head. The indexer sweeps expired rows and keeps two snapshot versions. The browser filters every query against live_head, read directly from the RPC.

An older snapshot file can still hold a row until it is pruned, but SQL hides it at expiry. Someone who copied data while it was live keeps their copy; this is not a promise to erase third party copies.

apps/demo-writer/src/plan.ts, apps/indexer/src/snapshot.ts, apps/indexer/src/store.ts, packages/core/src/views.ts, README.md

Friction we measured

Observed on Tiramisu on 18 October 2026 with SDK 0.8.1. Full reproduction steps are in arkiv/friction.md.

  • Anonymous budget and its 42910000 units, gone in one query

    A broad $expiresAt > u64(0) query with limit 5 used the whole anonymous budget. Later calls returned HTTP 429 with a plain object, not a JSON RPC error envelope. The client handles both shapes and waits for Retry-After.

  • Undocumented query length limit50 key terms pass, 100 fail

    An OR of 50 keys (about 4,100 characters) worked; 100 keys (about 8,200) failed with -32004 query is too long, and the failure still cost units. Lookups are chunked at 50.

  • Flat cost per callArkiv-Cost: 100

    One key, 50 keys, a full 200 row page and a failing call all showed the same cost. Batching keys is the only lever, so every lookup carries up to 50.

  • Monthly quota is a percentageno capacity or remaining units

    Headers expose quota used as a percent, so remaining calls cannot be computed. Together with the measured change rate, this is why the indexer runs without an API key by default.

arkiv/friction.md, apps/web/components/RowActions.tsx, packages/core/src/query.ts

Reproduce it

Five minutes in a browser. No wallet, no API key.

  1. Open the showcase. Every chart is one SQL query run by DuckDB in your tab. Each card shows its timing, and most name the clause arkiv_query cannot express.
  2. Open the Creators view. The Creator vs current owner table lists entities whose owner differs from their creator, such as tickets the demo wallet handed to an agent.
  3. Open the playground and press Run. The featured query joins open tickets with their comment counts using JOIN and GROUP BY.
  4. Press Verify page. Each row is re-read from the public RPC and labelled ok, stale, extended, expired or deleted. Payload loads a row's payload from the chain.
  5. Hover the status bar. It shows the indexed expression, the chain head, the browser live tail block and pending attribute fetches.
  6. Check the chain yourself. The addresses below are on the Tiramisu explorer.
RoleAddress
Demo wallet (creator)0x1f61c3cac1612C020f64739B128C5f198939C8Cd
Agent one0xb0B87EdDB0bc777Eb663c7a11703ba5ED4849A28
Agent two0xF1BC753cF42d0157Ac34E28DBf06108F66aDB3B3
README.md, apps/web/lib/views.ts, apps/web/components/dash/ResultsGrid.tsx, apps/web/components/dash/StatusBar.tsx