---
name: arkiv-query-patterns
description: Query Arkiv — the queryable database on Ethereum — like the database it is. Use when reading, filtering, paginating, counting, searching, or sorting Arkiv entities with @arkiv-network/sdk; whenever you see buildQuery, raw arkiv_query strings, predicates (eq, gt, lt, and, or), cursor pagination, NoCursorOrLimitError, $owner/$creator/$key synthetic attributes, "how do I query Arkiv", full-text search on Arkiv, geo lookups, scheduled-job polling, or PublicClient reads. Decides the index design at write time (numeric attributes for ranges, marker entities for counts/uniqueness, token slots for text search, geohash cells for location) and encodes the read-path footguns before you hit them — no server-side orderBy (sort in JS), .limit() required for pagination, payload/attributes empty by default, fetch() mutates the builder, getEntity throws, query-string injection.
license: MIT
metadata:
  arkiv.skill-version: "1.1.0"
  arkiv.sdk-package: "@arkiv-network/sdk"
  arkiv.sdk-range: ">=0.6.5 <0.7.0"
  arkiv.sdk-verified: "0.6.8"
  arkiv.network-fingerprint: "braga:60138453102"
  arkiv.surface: "createPublicClient, http, braga, buildQuery, where, ownedBy, createdBy, withAttributes, withPayload, withMetadata, limit, validAtBlock, fetch, hasNextPage, next, query, getEntity, getEntityCount, eq, neq, gt, gte, lt, lte, and, or, not, Predicate, Entity, toJson, NoEntityFoundError, NoCursorOrLimitError, NoMoreResultsError"
  arkiv.last-verified-at: "2026-06-11"
  arkiv.evidence: "8 patterns from 124 hackathon submissions across 5 events"
---

# Arkiv Query Patterns

Use this skill when you read data out of Arkiv, a queryable database on Ethereum. Arkiv has no joins, no aggregates, no server-side sorting, and no content search — but attribute queries are fast, public, and free to read. Winning teams treat attributes as the index: they decide at WRITE time what they will query, then every read is one flat attribute query. This skill gives you the verified 0.6.8 query surface, the eight read patterns that actually shipped in 124 hackathon submissions, and the six footguns that waste hours when discovered mid-build.

## Ground rules (read before any query code)

- **Network facts (mutable!):** current testnet is **Braga** — chainId `60138453102`, RPC `https://braga.hoodi.arkiv.network/rpc`, faucet `https://braga.hoodi.arkiv.network/faucet/`, explorer `https://explorer.braga.hoodi.arkiv.network`. **Networks rotate. Verify at https://docs.arkiv.network before relying on these. Testnet entities do NOT migrate between networks.**
- **SDK pin:** `@arkiv-network/sdk` `>=0.6.5 <0.7.0` (verified against 0.6.8 source). Never mix with the legacy `golem-base-sdk` package (`createClient`/`GolemBaseClient`/annotations are the OLD package).
- **Node 22 LTS.** Node 24 hangs entity updates forever (arkiv-sdk-js issue #14).
- **NEVER call `.orderBy()` / `asc()` / `desc()`.** The server silently ignores ordering and the API is removed in SDK 0.7.0 (PR #70). Sort in JS after fetching. There are zero exceptions to this rule.
- **Reads are walletless.** Querying needs only a `createPublicClient` — no private key, no GLM. Only writes need a wallet (see `arkiv-write-safety`).

## Setup: the read module (paste once, reuse everywhere)

```ts
// lib/arkiv-read.ts
import { createPublicClient, http } from "@arkiv-network/sdk"
import { braga } from "@arkiv-network/sdk/chains"
import type { Entity } from "@arkiv-network/sdk"

export const publicClient = createPublicClient({ chain: braga, transport: http() })

// Stamp this on EVERY write AND every query — the testnet is one shared public store.
// Namespace design + the creator-filter security twin live in arkiv-data-modeling.
export const PROJECT = "myapp-v1-x7k2"

/** Attributes are a flat { key, value }[] list — read one value off an entity. */
export const attr = (e: Entity, key: string) =>
  e.attributes.find((a) => a.key === key)?.value

/** Numeric attribute as JS number — use for client-side sorting (the server can't sort). */
export const numAttr = (e: Entity, key: string) => Number(attr(e, key) ?? 0)

/**
 * Strings interpolated into queries MUST pass through this — the SDK wraps string
 * values in double quotes WITHOUT escaping (issue #67 #5), so a `"` in user input
 * breaks or rewrites your query. Applies to predicate helpers AND raw strings.
 */
export function sanitizeQueryValue(value: string): string {
  if (/["\\]/.test(value)) throw new Error(`Unsafe characters in query value: ${value}`)
  return value
}

/** Drain every page. Pagination REQUIRES .limit(n) — next() throws NoCursorOrLimitError without it. */
export async function fetchAll(builder: ReturnType<typeof publicClient.buildQuery>) {
  const result = await builder.fetch()
  const all = [...result.entities]
  while (result.hasNextPage()) {
    await result.next() // replaces result.entities with the next page, in place
    all.push(...result.entities)
  }
  return all
}
```

## The query surface (verified against 0.6.8 source)

### QueryBuilder — the default path

```ts
import { eq, gt, gte, lt, lte, neq, not, and, or } from "@arkiv-network/sdk/query"

const result = await publicClient
  .buildQuery()                                   // FRESH builder per query — never reuse
  .where([eq("project", PROJECT), eq("type", "job"), eq("status", "open")]) // array = AND
  .withAttributes(true)                           // default FALSE — without these you get
  .withPayload(true)                              //   keys only and toJson()/toText() THROW
  .limit(100)                                     // required for pagination; page cap is 200
  .fetch()

const jobs = result.entities                      // Entity[]
const first = jobs[0]?.toJson() ?? null           // payload parsed as JSON
```

Chain reference: `.where(Predicate | Predicate[])` · `.ownedBy(0x…)` · `.createdBy(0x…)` · `.withAttributes(true)` · `.withPayload(true)` · `.withMetadata(true)` (needed for `owner`/`expiresAtBlock`/`createdAtBlock` — `undefined` otherwise) · `.limit(n ≤ 200)` · `.cursor(c)` · `.validAtBlock(bigint)` (point-in-time reads) · `.fetch()`.

Predicates: `eq, neq, gt, gte, lt, lte` take `(key, value)`; `not(key)` matches entities WITHOUT that attribute; **`and([...])` and `or([...])` take an ARRAY** (not variadic):

```ts
.where([eq("project", PROJECT), or([eq("status", "live"), eq("status", "draft")])])
```

### Raw query strings — when you need glob or hand-built fan-out

```ts
const { entities, cursor, blockNumber } = await publicClient.query(
  `project = "${PROJECT}" && type = "doc" && title ~ "intro*" && score >= 10`,
  { includeData: { attributes: true, payload: true }, resultsPerPage: 100 },
)
```

- Operators: `=` `!=` `<` `>` `<=` `>=` `~` (glob) `!~` combined with `&&` `||` parentheses; `!key` = attribute absent. String values double-quoted, numbers bare. Glob (`~`) exists ONLY on the raw path — there is no predicate helper for it.
- Synthetic attributes: `$owner`, `$creator`, `$key`, `$expiration`, `$createdAtBlock`.
- **Asymmetry trap:** raw `query()` defaults `payload: true, attributes: false` — the opposite shape from the builder's all-false defaults. Always pass `includeData` explicitly.
- Sanitize EVERY interpolated string with `sanitizeQueryValue` — including values you pass to `eq()` (the builder compiles to this same unescaped string).

### getEntity — point lookup THROWS, never returns null

```ts
import { NoEntityFoundError } from "@arkiv-network/sdk"

try {
  const entity = await publicClient.getEntity(entityKey) // must be 32-byte hex
  return entity.toJson()                                 // getEntity fetches all data for you
} catch (err) {
  if (err instanceof NoEntityFoundError) return null     // missing OR expired — same error
  throw err
}
```

An expired entity and a never-existed entity are indistinguishable here. If absence has business meaning, that is a feature — see `arkiv-expiration-design`.

### Sorting — always in JS

```ts
const newestFirst = [...jobs].sort((a, b) => numAttr(b, "createdAt") - numAttr(a, "createdAt"))
const top10 = newestFirst.slice(0, 10)
```

For this to work, sortable fields must be NUMERIC attributes at write time (next section).

---

## The 8 query patterns

### numeric-attributes-discipline

Anything you will range-query (`gt`/`lt`) or sort MUST be written as a `number` attribute; strings only support `eq`/glob. Server-side numerics are big integers — **no floats** — so scale decimals by a documented factor and store dates as epoch-ms.

```ts
// WRITE-time index design (the write call itself: arkiv-write-safety)
// [{ key: "createdAt",  value: Date.now() },        // epoch-ms → range + JS sort
//  { key: "priceCents", value: 1999 },               // $19.99 ×100 — document the scale
//  { key: "lat",        value: -34603722 },          // degrees ×1_000_000 (microdegrees)
//  { key: "inStock",    value: 1 }]                  // booleans: 1 = true, OMIT for false
//                                                     // (numeric 0 encodes as "" on the wire —
//                                                     //  eq(key, 0) matching is not guaranteed;
//                                                     //  query presence/1, never 0)

const lowStock = await fetchAll(
  publicClient.buildQuery()
    .where([eq("project", PROJECT), eq("type", "product"), lt("stock", 5)])
    .withAttributes(true).withPayload(true).limit(100),
)
const lastWeek = await fetchAll(
  publicClient.buildQuery()
    .where([eq("project", PROJECT), eq("type", "event"),
            gte("createdAt", Date.now() - 7 * 86_400_000), lt("createdAt", Date.now())])
    .withAttributes(true).withPayload(true).limit(200),
)
```

Storing a sortable field as a string silently breaks every future range query — this is the single most common schema regret. Seen in 24 of 124 submissions — Fhedin, Mnemos, MeshOWN, vialibre (live `margin < 15` / `stock < 5` queries on-chain).

### flag-marker-entities-owner-queries

Arkiv has no unique constraints and no server-side aggregates. Get both with marker entities and owner-scoped queries: (a) uniqueness = query-before-write; (b) per-user dedupe (likes/flags/bookmarks) = one marker entity per (actor, target), owned by the actor; (c) counts = drain a keys-only query (cheapest possible pages — no payload).

```ts
import type { Predicate } from "@arkiv-network/sdk/query"

/** "Has this wallet already flagged this job?" — also the one-X-per-wallet uniqueness check. */
export async function hasAlready(action: string, wallet: `0x${string}`, targetKey: string) {
  const r = await publicClient.buildQuery()
    .where([eq("project", PROJECT), eq("type", action),
            eq("targetKey", sanitizeQueryValue(targetKey))])
    .ownedBy(wallet)
    .limit(1)
    .fetch()
  return r.entities.length > 0
}

/** Count by draining pages — .count() is broken and getEntityCount() is chain-wide. */
export async function countMatching(predicates: Predicate[]) {
  let n = 0
  const result = await publicClient.buildQuery().where(predicates).limit(200).fetch()
  n += result.entities.length
  while (result.hasNextPage()) { await result.next(); n += result.entities.length }
  return n
}

// Marker write shape: [{ key: "project", value: PROJECT }, { key: "type", value: "flag" },
//                      { key: "targetKey", value: jobKey }, { key: "flaggedAt", value: Date.now() }]
// Owner = the actor's wallet; a short expiration makes counters self-cleaning.
// Reconciling/creating MANY markers at once? Batch them in ONE mutateEntities({ creates })
// (≤1000 ops/tx — one signature, one nonce), never loop createEntity (see arkiv-write-safety).
```

Seen in 5 of 124 submissions — ArkivJobs, Hostr, Gather3.club, Web3 Job Board.

### walletless-public-reads

The read path needs no key, no GLM, no backend — run `createPublicClient` directly in the browser and query Braga from the frontend. Great for dashboards and "the judge can verify on-chain" demos; pairs with a server-side signer for writes.

```tsx
// React: read hook with zero backend
import { useEffect, useState } from "react"
import { publicClient, PROJECT, fetchAll } from "@/lib/arkiv-read"
import { eq } from "@arkiv-network/sdk/query"
import type { Entity } from "@arkiv-network/sdk"

export function useLiveItems(type: string) {
  const [items, setItems] = useState<Entity[]>([])
  useEffect(() => {
    let alive = true
    fetchAll(
      publicClient.buildQuery()
        .where([eq("project", PROJECT), eq("type", type), eq("status", "live")])
        .withAttributes(true).withPayload(true).limit(100),
    ).then((r) => { if (alive) setItems(r) })
    return () => { alive = false }
  }, [type])
  return items
}
```

For polling/live updates on top of this, use `arkiv-realtime`. Seen in 4 of 124 submissions — norte vivo, ViArkiv, InforMed.

### tokenized-diy-full-text-search

There is no content/substring search — payloads are not queryable. Index text yourself at write time: lowercase, dedupe, and store the first N words as enumerated `token_N` attributes, plus a `titleLower` attribute for exact/prefix matching. Search = OR fan-out across the slots.

```ts
/** Write-time tokenizer → attributes to stamp on the entity. */
export function tokenize(text: string, max = 20) {
  const words = [...new Set(text.toLowerCase().match(/[a-z0-9]{2,}/g) ?? [])]
  return words.slice(0, max).map((w, i) => ({ key: `token_${i}`, value: w }))
}

export async function searchPages(term: string) {
  const w = sanitizeQueryValue(term.toLowerCase())
  const slots = Array.from({ length: 20 }, (_, i) => eq(`token_${i}`, w))
  return fetchAll(
    publicClient.buildQuery()
      .where([eq("project", PROJECT), eq("type", "page"), or(slots)])
      .withAttributes(true).withPayload(true).limit(50),
  )
}
```

Multi-word search: run one query per word and intersect entity keys client-side. Seen in 3 of 124 submissions — Arklib (`token_0..token_19` slots), Hostr (one entity per search word), Ocean (`titleLower`).

### parallel-fan-out-client-side-joins

No joins on-chain — fire independent queries in parallel with `Promise.all` and stitch in the client. One FRESH builder per leg (builders are stateful), and paginate each leg.

```ts
const [profiles, jobs] = await Promise.all([
  fetchAll(publicClient.buildQuery()
    .where([eq("project", PROJECT), eq("type", "profile")])
    .withAttributes(true).withPayload(true).limit(200)),
  fetchAll(publicClient.buildQuery()
    .where([eq("project", PROJECT), eq("type", "job"), eq("status", "open")])
    .withAttributes(true).withPayload(true).limit(200)),
])

// Client-side join on the shared-attribute foreign key (see arkiv-data-modeling)
const profileByKey = new Map(profiles.map((p) => [p.key, p]))
const enriched = jobs.map((j) => ({
  job: j.toJson(),
  poster: profileByKey.get(attr(j, "posterKey") as `0x${string}`)?.toJson() ?? null,
}))
```

Seen in 3 of 124 submissions — Rootgraph (4 parallel `buildQuery()` legs → trust graph), Clink (rsvps × attendance → reputation, no central scorer).

### arkiv-as-scheduled-job-queue

Encode future work as entities with a numeric fire-time attribute; workers poll a range query. The queue survives process restarts because it lives on-chain, and expiration is free garbage collection for stale jobs.

```ts
/** Worker poll: everything due now. */
export async function dueJobs() {
  return fetchAll(
    publicClient.buildQuery()
      .where([eq("project", PROJECT), eq("type", "job"),
              eq("status", "pending"), lte("submitAfter", Date.now())])
      .withAttributes(true).withPayload(true).limit(100),
  )
}
// Loop: dueJobs() → process → mark done. Marking done is a FULL-REPLACE update
// (re-send payload + ALL attributes) — use the read→merge→write helper in arkiv-write-safety.
```

Seen in 2 of 124 submissions — espresso (satellite tx relay: `submitAfter <= now` as a restart-proof scheduler), VEIL (`trigger_at` actions for an off-chain dispatcher).

### geo-anchored-entities

There are no geo operators — equality on a grid cell is the index. Stamp a geohash string attribute (precision 5 ≈ 4.9 km cells) plus exact integer-microdegree coordinates; "nearby" = OR over the center cell + its 8 neighbors, then refine by real distance client-side.

```ts
// Write shape: [{ key: "geocell", value: "69y7p" },                 // geohash-5 string
//               { key: "lat", value: -34603722 }, { key: "lng", value: -58381592 }] // ×1e6
export const toMicro = (deg: number) => Math.round(deg * 1_000_000)

export async function nearby(cell: string, neighbors: string[]) {
  const cells = [cell, ...neighbors] // 9-cell block via the 'ngeohash' npm lib: [cell, ...ngeohash.neighbors(cell)] — don't hand-roll geohash math
  return fetchAll(
    publicClient.buildQuery()
      .where([eq("project", PROJECT), eq("type", "pin"),
              or(cells.map((c) => eq("geocell", c)))])
      .withAttributes(true).withPayload(true).limit(100),
  )
  // then: filter by haversine distance using lat/lng attributes
}
```

Seen in 2 of 124 submissions — Artefact (GPS-pinned AR messages), OpenArkiv (winner, Best DePIN — GPS + timestamps as verifiable context).

### multi-value-tag-queries

Attributes are flat key-values — no arrays. **Default: enumerated slots** `tag_0..tag_7` on the entity (one query, no joins). **Upgrade to junction entities** (a `tagLink` entity per tag pointing at the listing) only when tags are unbounded or "all listings with tag X" is your primary axis at scale.

```ts
// Default — slots, single query:
export async function withTag(tag: string) {
  const t = sanitizeQueryValue(tag.toLowerCase())
  return fetchAll(
    publicClient.buildQuery()
      .where([eq("project", PROJECT), eq("type", "note"),
              or(Array.from({ length: 8 }, (_, i) => eq(`tag_${i}`, t)))])
      .withAttributes(true).withPayload(true).limit(100),
  )
}

// Junction variant — tagLink: [{ key: "tag", value: "defi" }, { key: "listingKey", value: "0x…" }]
// Step 1: query tagLinks by tag → step 2: getEntity per listingKey (N+1 — fine at hackathon scale).
```

Seen in 2 of 124 submissions — arkiv-notes-mvp (tag_N slots), OpenClu (junction skillTag entities, "foreign-key style").

---

## Query recipes (copy the predicate, keep the chrome)

Every recipe implicitly includes `eq("project", PROJECT)`, `.withAttributes(true).withPayload(true)`, and `.limit(n)`.

| Recipe | Predicate / chain |
|---|---|
| Children of a parent | `eq("eventKey", parentKey)` — shared-attribute foreign key |
| My entities | `.ownedBy(myAddress)` |
| Live items only | `eq("status", "live")` — pairs with the status state machine |
| Trusted reads | `.createdBy(APP_WALLET)` — **namespaces are organization, NOT auth**; anyone can stamp your project attribute, only `$creator` is unforgeable |
| Range scan | `[gte("createdAt", fromMs), lt("createdAt", toMs)]` |
| Existence check | `.limit(1)` → `entities.length > 0` (the `hasAlready` helper above) |
| Latest N | fetch all → JS sort desc on numeric `createdAt` → `.slice(0, n)` |
| Exclude a state | `neq("status", "archived")` |
| Attribute absent | `not("deletedAt")` |
| Point-in-time read | `.validAtBlock(blockNumber)` |
| Expiration metadata | add `.withMetadata(true)` → `entity.expiresAtBlock` (bigint) |
| Count my query | `countMatching(predicates)` above — never `.count()` |

## Anti-patterns & gotchas (SDK survey #3 #4 #7 #8 #9 #10)

**#3 — `.orderBy()` does NOTHING. Repeat: NOTHING.** Sorting was never re-implemented server-side after the query layer moved to roaring bitmaps; the SDK silently sends an ordering the server ignores, and PR #70 deletes `.orderBy()`/`asc()`/`desc()` in 0.7.0. Any query code containing them is wrong. Sort in JS after fetching (requires numeric attributes at write time).

**#4 — Pagination requires `.limit(n)`, and `.count()` lies.** `result.next()` throws `NoCursorOrLimitError` if `.limit()` was never set, and `NoMoreResultsError` past the last page. `.count()` returns ONE page's length, not the total (issue #67 #7). `getEntityCount()` is the chain-wide total, not your query's. Page size caps at 200. Use the `fetchAll`/`countMatching` helpers.

**#7 — The builder fetches NOTHING by default.** `withPayload`/`withAttributes`/`withMetadata` all default false → you get bare keys, and `entity.toText()`/`toJson()` THROW on the missing payload. Asymmetry: raw `client.query()` defaults `payload: true, attributes: false`. Always state what you want explicitly — and omit payloads on purpose for cheap existence/count queries.

**#8 — `fetch()` mutates the builder.** It stores the page cursor inside the builder, so a second `.fetch()` on the same builder returns page 2, not a re-run. Build a fresh `client.buildQuery()` for every logical query; never share a builder across requests, renders, or `Promise.all` legs.

**#9 — `getEntity` THROWS `NoEntityFoundError`** — for missing AND expired entities alike; it never returns null. The key must be exactly 32-byte hex. Wrap in try/catch (see the snippet above), and design for "absence is a signal" where expiry is meaningful.

**#10 — Query-string injection.** The engine wraps string values in double quotes WITHOUT escaping (issue #67 #5) — this applies to predicate helpers AND raw strings, so `eq("name", userInput)` is just as injectable as template literals. A `"` in user data breaks the query or escapes your project/namespace filter. Route every user-sourced string through `sanitizeQueryValue` first.

## Compose with

- **arkiv-data-modeling** — design the attributes these queries run on: entityType discriminator, project namespace + creator-filter twin, foreign keys, attributes-vs-payload split.
- **arkiv-write-safety** — every write these patterns assume: full-replace updates, `mutateEntities` batching, nonce-safe write queues, error catalog.
- **arkiv-expiration-design** — expiration (a.k.a. TTL) as a query feature: unexpired = live, absence = signal, `expiresAtBlock` → wall-clock conversion.
- **arkiv-realtime** — turning these reads into live UIs: polling cadence, `subscribeEntityEvents`, presence heartbeats.
- **arkiv-app-architecture** — where reads run (browser vs server) and the signer spectrum for the write side.
