Querying data
This guide targets the Tiramisu testnet. Check Networks for current testnets and faucets.
This page shows you how to read entities with the public client of the TypeScript SDK. It covers fetching one entity by key, building a select() query with typed predicates, reading the typed results, paginating, sorting, and reading a past block. Reads cost no gas and need no wallet.
Set up a public client
Section titled “Set up a public client”A public client reads from the chain and never signs anything, so it needs no private key and is safe to ship to a browser:
import { createPublicClient } from "@arkiv-network/sdk"import { tiramisu } from "@arkiv-network/sdk/chains"import { http } from "viem"
const publicClient = createPublicClient({ chain: tiramisu, transport: http(),})http() with no URL uses the public RPC endpoint configured as the default in the tiramisu chain. That endpoint is heavily rate limited and meant for testing. To raise the rate limit for a production build, get a free access key from the Arkiv Hub and append it to the end of the RPC URL. Keep that key on a server, because anyone who opens your website can read it from the network requests.
Use of the public RPC is subject to the Arkiv Terms of Use and Privacy Policy.
Read one entity by key
Section titled “Read one entity by key”When you already hold an entity key, getEntity() returns the entity with every field populated:
const entity = await publicClient.getEntity(entityKey)// {// key: "0x9f3c…", // Hex, 32 bytes// owner: "0x1234…", // checksummed address// creator: "0x1234…", // checksummed address// createdAt: 1_180_400n, // bigint block height// updatedAt: 1_180_400n, // bigint block height// expiresAt: 1_200_000n, // bigint block height// creationFlags: { readonly: false, permissionlessExtension: false, raw: 0 },// contentType: "application/json",// payload: Uint8Array [ 123, 34, … ], // raw bytes// attributes: {// status: { type: "str", value: "open" }, // typed name-value pairs// priority: { type: "i32", value: 3 },// },// attributeSchema: { status: "str", priority: "i32" },// }
entity.owner // checksummed addressentity.expiresAt // bigint block heightentity.attributes.status.value // typed attribute valueentity.toJson() // payload parsed as JSONThe method throws NoEntityFoundError when no live entity has that key: it never existed, was deleted, or expired.
Build a query
Section titled “Build a query”Every query has the same shape: name the fields you want with select(), add filters, then run it with fetch():
import { i32, str } from "@arkiv-network/sdk/attr"import { eq, gte } from "@arkiv-network/sdk/query"
const page = await publicClient .select({ key: true, payload: true, attributes: true }) .where(eq("project", str("my_project")), eq("type", str("note"))) .where(gte("priority", i32(3))) .limit(50) .fetch()
for (const note of page.entities) { console.log(note.key, note.attributes.priority.value, note.toJson())}Choose the fields to return
Section titled “Choose the fields to return”Pass select() an object with the fields you want set to true. Every field is opt-in, including key, and the network sends only what you name:
| Field | Type | What it holds |
|---|---|---|
key | Hex | The 32-byte entity key |
owner | Hex | The wallet that owns the entity now. Changes on transfer |
creator | Hex | The wallet that created the entity. Never changes |
createdAt | bigint | The block the entity was created in |
updatedAt | bigint | The block of the last patch |
expiresAt | bigint | The block the entity expires at |
creationFlags | ResolvedCreationFlags | readonly, permissionlessExtension, and the raw byte |
contentType | string | The MIME type of the payload |
payload | Uint8Array | The raw payload bytes |
attributeSchema | AttributeSchema | Attribute names mapped to their type tags, without values |
attributes | Attributes | Attribute names mapped to a typed value |
Call select() with no argument, or with "*", to fetch all eleven fields. That is convenient while you explore and wasteful in production, because the payload alone can be 128 KiB per entity.
attributes also takes a map of names, which fetches only those attributes and skips the rest:
const page = await publicClient .select({ key: true, attributes: { status: true, priority: true } }) .where(eq("project", str("my_project"))) .fetch()attributeSchema returns names and types without values, which is enough to learn the shape of unfamiliar data before you transfer it.
Read the typed result
Section titled “Read the typed result”The result type follows your selection. A field you did not select is a compile error rather than undefined, and the payload helpers appear only when you selected payload:
const page = await publicClient .select({ owner: true, payload: true }) .where(eq("project", str("my_project"))) .fetch()
const [first] = page.entitiesfirst.owner // Hexfirst.toJson() // available because payload was selectedfirst.creator // compile error: not selectedFilter with predicates
Section titled “Filter with predicates”Import the predicates from @arkiv-network/sdk/query. Each one takes an attribute name and a value, and renders to one clause of the query language that the node parses:
| Predicate | Renders as | Works on |
|---|---|---|
eq(name, value) | name = value | every type |
gt(name, value) | name > value | i32, u64, u256, dec |
gte(name, value) | name >= value | i32, u64, u256, dec |
lt(name, value) | name < value | i32, u64, u256, dec |
lte(name, value) | name <= value | i32, u64, u256, dec |
startsWith(name, prefix) | name STARTSWITH str('prefix') | str |
not(expression) | NOT (…) | any expression |
and(…expressions) | a AND b | any expressions |
or(…expressions) | a OR b | any expressions |
Every predicate asserts a type
Section titled “Every predicate asserts a type”A value in a predicate carries a type tag, and the tag is part of the match. eq("level", i32(10)) matches an entity whose level is an i32 holding 10. It does not match a level stored as u64(10) or as str("10"). A type mismatch is not an error: the query runs and returns nothing.
Build the value with the same constructor you used to store it. A dec predicate finds dec attributes and nothing else:
import { dec, u64 } from "@arkiv-network/sdk/attr"
gt("created_at_ms", u64(Date.now() - 86_400_000)) // matches u64 attributesgte("rating", dec("4.5")) // matches dec attributesThe SDK also accepts a bare JavaScript value in a predicate, but a constructor is highly recommended to avoid a type mismatch with the stored attribute. Choose an attribute type on the Mutating data page lists every type, what it accepts, and when to use it.
Predicates the network does not support yet
Section titled “Predicates the network does not support yet”Until then, write a not-equal test by wrapping eq() in not():
import { str } from "@arkiv-network/sdk/attr"import { eq, not } from "@arkiv-network/sdk/query"
not(eq("status", str("archived"))) // NOT status = str('archived')not() is the full complement over the live entities. The predicate above matches entities whose status is any other string, entities whose status has another type, and entities that never set status at all. Add an eq() on a field every entity in your project carries to keep the result inside your data.
Combine conditions
Section titled “Combine conditions”Conditions combine with AND by default. Chained where() calls, several arguments to one where(), and an array passed to where() all mean the same thing:
builder.where(eq("type", str("note"))).where(gt("priority", i32(3)))builder.where(eq("type", str("note")), gt("priority", i32(3)))builder.where([eq("type", str("note")), gt("priority", i32(3))])For alternatives, nest or() inside a where(). Both and() and or() also accept an array, so you can build a filter list at runtime:
import { i32, str } from "@arkiv-network/sdk/attr"import { and, eq, gt, or } from "@arkiv-network/sdk/query"
const filters = [eq("project", str("my_project")), eq("type", str("note"))]if (minPriority !== undefined) { filters.push(gt("priority", i32(minPriority)))}
const page = await publicClient .select({ key: true }) .where(and(filters)) .where(or(eq("status", str("open")), eq("status", str("review")))) .fetch()Call toString() on a builder or an expression to see the query string the node receives.
Filter by owner, creator, key and expiry
Section titled “Filter by owner, creator, key and expiry”Four system attributes carry an index and accept predicates. Each has a fixed type:
| Attribute | Type | Meaning |
|---|---|---|
$owner | addr | The wallet that owns the entity now |
$creator | addr | The wallet that created it. Immutable |
$key | key | The entity key |
$expiresAt | u64 | The block the entity expires at |
ownedBy() and createdBy() are shorthands for an eq() on $owner and $creator:
const mine = await publicClient .select({ key: true, payload: true }) .where(eq("project", str("my_project"))) .ownedBy("0x1234567890123456789012345678901234567890") .fetch()Ownership changes on transfer, and the creator never does. Filter by creator when you need proof that data came from a wallet you trust, for example entities your backend publishes for a frontend to read. Anyone can copy your project attribute onto their own entities. Nobody can forge $creator.
$expiresAt is a u64 block height, so a range predicate on it finds entities that expire soon. One block is 2 seconds, so a day is 43,200 blocks:
import { str, u64 } from "@arkiv-network/sdk/attr"import { eq, lt } from "@arkiv-network/sdk/query"
const head = await publicClient.getBlockNumber()
const expiringToday = await publicClient .select({ key: true, expiresAt: true }) .where(eq("project", str("my_project"))) .where(lt("$expiresAt", u64(head + 43_200n))) .ownedBy(myAddress) .fetch()The other system fields, $createdAt, $updatedAt, $creationFlags, $contentType and $payload, carry no index and cannot be filtered on. Select the field and filter in JavaScript instead.
Read the results
Section titled “Read the results”fetch() resolves to a QueryResult with three fields: entities, blockNumber, and cursor. The entities on a page carry the fields you selected, decoded into JavaScript values:
for (const entity of page.entities) { const status = entity.attributes.status if (status?.type === "str") console.log(status.value.toUpperCase())
const level = entity.attributes.level if (level?.type === "i32") console.log(level.value + 1)}Each attribute is an object with a type tag and a value. Narrowing on the tag gives you a typed value:
| Type tag | value is |
|---|---|
bool | boolean |
i32 | number |
u64, u256 | bigint |
dec | string, such as "3.5" |
str | string |
addr | A checksummed 0x address |
key, bytes32 | Lowercase 0x hex |
Block heights (createdAt, updatedAt, expiresAt) are bigint. The payload is a Uint8Array: toJson() parses it as JSON and toText() decodes it as UTF-8. toJson() returns any, so validate the shape before you trust it. Validate entity data with schemas shows one way.
Paginate
Section titled “Paginate”The node returns at most 200 entities per page and sends a cursor when more remain. Set the page size with limit(), then walk the pages with hasNextPage() and next():
let page = await publicClient .select({ key: true }) .where(eq("project", str("my_project"))) .limit(200) .fetch()
const keys = page.entities.map((e) => e.key)while (page.hasNextPage()) { page = await page.next() keys.push(...page.entities.map((e) => e.key))}Without limit(), Tiramisu currently returns 100 per page. The builder is also an async iterable that walks every page for you, one entity at a time:
const notes = publicClient .select({ key: true, attributes: true }) .where(eq("project", str("my_project")))
for await (const note of notes) { console.log(note.key, note.attributes)}Every page reads the same block
Section titled “Every page reads the same block”The first page records the block it was read at in blockNumber, and every later page of the same walk reads that same block. Writes that land while you paginate never shift the pages under you. The trade-off is that a cursor expires once the node no longer retains that block. When that happens, start the query again from the first page.
Resume a walk later
Section titled “Resume a walk later”page.cursor is a string you can store, and cursor() on the builder starts from it. Rebuild the query exactly as before, because a cursor belongs to the query and selection it came from:
const resumed = await publicClient .select({ key: true }) .where(eq("project", str("my_project"))) .limit(200) .cursor(savedCursor) .fetch()Sort results
Section titled “Sort results”The node returns matching entities newest first and supports no other order. To sort by an attribute, fetch the entities and sort them in JavaScript:
const page = await publicClient .select({ key: true, attributes: true }) .where(eq("project", str("my_project")), eq("type", str("note"))) .fetch()
const priorityOf = (note: (typeof page.entities)[number]) => { const priority = note.attributes.priority return priority?.type === "i32" ? priority.value : 0}
const byPriority = [...page.entities] .sort((a, b) => priorityOf(b) - priorityOf(a))Read a past block
Section titled “Read a past block”atBlock() runs the query against the state as of a block height instead of the head. The result reports the block it read in blockNumber, and pagination stays on that block:
const snapshot = await publicClient .select({ key: true, attributes: true }) .where(eq("project", str("my_project"))) .atBlock(1_200_000n) .fetch()The node keeps a limited history, so a block that is too old is rejected.
Count entities
Section titled “Count entities”getEntityCount() returns the number of live entities on the whole chain as a plain number. It takes no query and applies no filter:
const total = await publicClient.getEntityCount()To count the entities that match a filter, walk the pages with select({ key: true }) and add up the page lengths. Selecting only the key keeps each page small.
Run a raw query
Section titled “Run a raw query”query() runs one page without the builder. It takes an expression built with the predicates above, or a raw query string:
import { i32, str } from "@arkiv-network/sdk/attr"import { and, eq, gte } from "@arkiv-network/sdk/query"
const page = await publicClient.query( and(eq("project", str("my_project")), gte("level", i32(10))), { select: { key: true, attributes: true }, limit: 100 },)
const raw = await publicClient.query( "project = str('my_project') AND level >= i32(10)", { select: { key: true }, limit: 100 },)The options are select, limit, cursor and atBlock, with the same meanings as on the builder. The result has entities, cursor and blockNumber, and each entity is typed as the full Entity with every field optional.
The string form sends the query language directly. The query syntax reference on the JSON-RPC page documents it.
Handle query errors
Section titled “Handle query errors”A query the node rejects throws QueryError. Its kind tells you what to do, and code, data and query carry the detail. No query method retries:
kind | Code | Meaning | What to do |
|---|---|---|---|
parse | -32001 | The query string does not parse | Fix the query |
type | -32002 | A predicate uses an operator the node does not support for that type | Fix the query |
literal | -32003 | A literal is out of range for its type | Fix the value |
limits | -32004 | The query exceeded a node budget: length, predicate count, or nesting depth | Split the query |
cursor | -32005 | The cursor expired or belongs to another query | Restart from page one |
block | -32006 | atBlock is outside the retained range | Use a more recent block |
import { QueryError } from "@arkiv-network/sdk/query"
try { page = await page.next()} catch (error) { if (error instanceof QueryError && error.kind === "cursor") { page = await notes.fetch() // the snapshot is gone: restart the walk } else { throw error }}