Skip to content

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.

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.

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 address
entity.expiresAt // bigint block height
entity.attributes.status.value // typed attribute value
entity.toJson() // payload parsed as JSON

The method throws NoEntityFoundError when no live entity has that key: it never existed, was deleted, or expired.

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())
}

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:

FieldTypeWhat it holds
keyHexThe 32-byte entity key
ownerHexThe wallet that owns the entity now. Changes on transfer
creatorHexThe wallet that created the entity. Never changes
createdAtbigintThe block the entity was created in
updatedAtbigintThe block of the last patch
expiresAtbigintThe block the entity expires at
creationFlagsResolvedCreationFlagsreadonly, permissionlessExtension, and the raw byte
contentTypestringThe MIME type of the payload
payloadUint8ArrayThe raw payload bytes
attributeSchemaAttributeSchemaAttribute names mapped to their type tags, without values
attributesAttributesAttribute 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.

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.entities
first.owner // Hex
first.toJson() // available because payload was selected
first.creator // compile error: not selected

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:

PredicateRenders asWorks on
eq(name, value)name = valueevery type
gt(name, value)name > valuei32, u64, u256, dec
gte(name, value)name >= valuei32, u64, u256, dec
lt(name, value)name < valuei32, u64, u256, dec
lte(name, value)name <= valuei32, u64, u256, dec
startsWith(name, prefix)name STARTSWITH str('prefix')str
not(expression)NOT (…)any expression
and(…expressions)a AND bany expressions
or(…expressions)a OR bany expressions

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 attributes
gte("rating", dec("4.5")) // matches dec attributes

The 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.

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.

Four system attributes carry an index and accept predicates. Each has a fixed type:

AttributeTypeMeaning
$owneraddrThe wallet that owns the entity now
$creatoraddrThe wallet that created it. Immutable
$keykeyThe entity key
$expiresAtu64The 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.

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 tagvalue is
boolboolean
i32number
u64, u256bigint
decstring, such as "3.5"
strstring
addrA checksummed 0x address
key, bytes32Lowercase 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.

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)
}

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.

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()

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))

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.

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.

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.

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:

kindCodeMeaningWhat to do
parse-32001The query string does not parseFix the query
type-32002A predicate uses an operator the node does not support for that typeFix the query
literal-32003A literal is out of range for its typeFix the value
limits-32004The query exceeded a node budget: length, predicate count, or nesting depthSplit the query
cursor-32005The cursor expired or belongs to another queryRestart from page one
block-32006atBlock is outside the retained rangeUse 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
}
}