@arkiv-network/sdk
Version:
TypeScript client library for Arkiv's blockchains interactions
901 lines (805 loc) • 36.3 kB
text/typescript
import { describe, expect, it, vi } from "bun:test"
import type { Hex } from "viem"
import type { ArkivClient } from "../clients/baseClient"
import {
type AddrValue,
type ArkivValue,
type Attributes,
addr,
type BytesValue,
bytes,
dec,
defineSchema,
InvalidAttributeNameError,
i32,
SchemaMismatchError,
type StrValue,
str,
} from "../schema"
import type { RpcEntity } from "../types/rpcSchema"
import { MAX_LIMIT } from "./engine"
import { InvalidPredicateError, QueryError } from "./errors"
import { eq, exists, gte, or } from "./expression"
import { asc, desc } from "./order"
import { SelectQueryBuilder } from "./queryBuilder"
import type { DynamicSelection, SelectArg, Selection } from "./selection"
const ACCOUNT = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" as const
const KEY = `0x${"ab".repeat(32)}` as const
type Page = { data: RpcEntity[]; blockNumber: string; count?: string }
/** A client whose `arkiv_query` serves the given pages in order. */
function makeClient(...pages: Page[]) {
const served = pages.length > 0 ? pages : [{ data: [], blockNumber: "0x1" }]
let index = 0
const request = vi.fn(async () => served[Math.min(index++, served.length - 1)])
return { client: { request } as unknown as ArkivClient, request }
}
/** The `[queryString, options]` of the nth `arkiv_query` call. */
function sent(request: ReturnType<typeof makeClient>["request"], call = 0) {
const params = request.mock.calls[call][0] as unknown as {
method: string
params: [string, Record<string, unknown>]
}
return { method: params.method, query: params.params[0], options: params.params[1] }
}
describe("the query it sends", () => {
it("ANDs everything across repeated where() calls", async () => {
const { client, request } = makeClient()
await new SelectQueryBuilder(client, { $key: true })
.where(gte("level", i32(10)))
.where(eq("category", "docs"), or(eq("status", "open"), eq("status", "review")))
.fetch()
expect(sent(request).method).toBe("arkiv_query")
expect(sent(request).query).toBe(
"level >= i32(10) AND category = str('docs') AND " +
"(status = str('open') OR status = str('review'))",
)
})
it("takes an array of expressions", async () => {
const { client, request } = makeClient()
await new SelectQueryBuilder(client, { $key: true }).where([eq("a", 1), eq("b", 2)]).fetch()
expect(sent(request).query).toBe("a = i32(1) AND b = i32(2)")
})
it("turns ownedBy and createdBy into typed system predicates", async () => {
const { client, request } = makeClient()
await new SelectQueryBuilder(client, { $key: true })
.where(eq("category", "docs"))
.ownedBy(ACCOUNT)
.createdBy(ACCOUNT)
.fetch()
expect(sent(request).query).toBe(
`category = str('docs') AND $owner = addr(${ACCOUNT}) AND $creator = addr(${ACCOUNT})`,
)
})
it("replaces rather than ANDs a second ownedBy — two owners would match nothing", async () => {
const other = "0x1111111111111111111111111111111111111111"
const { client, request } = makeClient()
await new SelectQueryBuilder(client, { $key: true }).ownedBy(ACCOUNT).ownedBy(other).fetch()
expect(sent(request).query).toBe(`$owner = addr(${other})`)
})
it("is readable without running anything", () => {
const { client } = makeClient()
const builder = new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1))
expect(builder.toString()).toBe("a = i32(1)")
expect(`${builder}`).toBe("a = i32(1)")
})
it("refuses a query with no filter, which the language cannot spell", async () => {
const { client, request } = makeClient()
const builder = new SelectQueryBuilder(client, { $key: true })
expect(() => builder.toString()).toThrow(InvalidPredicateError)
await expect(builder.fetch()).rejects.toThrow(/at least one filter/)
expect(request).not.toHaveBeenCalled()
})
})
describe("the options it sends", () => {
it("sends the resolved selection, and nothing else by default", async () => {
const { client, request } = makeClient()
await new SelectQueryBuilder(client, { $key: true, $attributes: true })
.where(eq("a", 1))
.fetch()
const { options } = sent(request)
expect(options).toEqual({ select: expect.objectContaining({ key: true, attributes: true }) })
// No limit or offset unless asked for — the node picks its own page size.
expect(Object.keys(options).sort()).toEqual(["select"])
})
it("sends limit and offset as the RPC spells them", async () => {
const { client, request } = makeClient()
await new SelectQueryBuilder(client, { $key: true })
.where(eq("a", 1))
.limit(100)
.offset(200)
.fetch()
const { options } = sent(request)
expect(options).toMatchObject({
limit: "0x64",
offset: "0xc8",
})
})
it("sends an offset of 0", async () => {
const { client, request } = makeClient()
await new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1)).offset(0).fetch()
expect(sent(request).options).toMatchObject({ offset: "0x0" })
})
it("rejects an offset the node would reject", async () => {
const { client, request } = makeClient()
const query = () => new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1))
await expect(query().offset(-1).fetch()).rejects.toThrow(/non-negative integer/)
await expect(query().offset(1.5).fetch()).rejects.toThrow(/non-negative integer/)
await expect(query().offset(Number.NaN).fetch()).rejects.toThrow(/non-negative integer/)
expect(request).not.toHaveBeenCalled()
})
it("sends each fetch the builder as it is then", async () => {
const { client, request } = makeClient()
const builder = new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1)).limit(2)
await builder.fetch()
await builder.offset(2).fetch()
expect(sent(request, 0).options).not.toHaveProperty("offset")
expect(sent(request, 1).options).toMatchObject({ limit: "0x2", offset: "0x2" })
})
it("rejects a page size the node would reject", async () => {
const { client, request } = makeClient()
const query = () => new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1))
await expect(
query()
.limit(MAX_LIMIT + 1)
.fetch(),
).rejects.toThrow(/exceeds the node maximum/)
await expect(query().limit(0).fetch()).rejects.toThrow(/positive integer/)
await expect(query().limit(1.5).fetch()).rejects.toThrow(/positive integer/)
expect(query().limit(MAX_LIMIT)).toBeDefined()
expect(request).not.toHaveBeenCalled()
})
})
describe("withCount", () => {
it("carries the total, typed bigint", async () => {
const { client, request } = makeClient({
data: [{ key: KEY }],
blockNumber: "0x1",
count: "0x2",
})
const page = await new SelectQueryBuilder(client, { $key: true })
.where(eq("a", 1))
.withCount()
.limit(1)
.offset(1)
.fetch()
const total: bigint = page.count
expect(total).toBe(2n)
expect(sent(request).options.select).toMatchObject({ key: true, count: true })
})
it("fails rather than typing a missing count as bigint", async () => {
const { client } = makeClient({ data: [], blockNumber: "0x1" })
await expect(
new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1)).withCount().fetch(),
).rejects.toThrow(/returned no count/)
})
it("leaves count undefined, in the type too, unless asked for", async () => {
const { client, request } = makeClient({ data: [], blockNumber: "0x1" })
const page = await new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1)).fetch()
const nothing: undefined = page.count
expect(nothing).toBeUndefined()
expect(sent(request).options.select).not.toHaveProperty("count")
})
it("survives from(), and leaves the builder it was called on uncounted", async () => {
const Posts = defineSchema("posts", { category: str().indexed() })
const { client, request } = makeClient({ data: [], blockNumber: "0x1", count: "0x0" })
const base = new SelectQueryBuilder(client, { $key: true }).where(eq("category", "news"))
const counted = base.withCount().from(Posts)
const page = await counted.fetch()
const total: bigint = page.count
expect(total).toBe(0n)
await base.fetch()
expect(sent(request, 1).options.select).not.toHaveProperty("count")
})
})
describe("decoding", () => {
it("builds entities from exactly the fields the row carries", async () => {
const { client } = makeClient({
blockNumber: "0x8e1ff",
data: [
{
key: KEY,
owner: ACCOUNT,
expiresAt: "0x92e21",
creationFlags: { readonly: false, permissionlessExtension: true, raw: 2 },
attributeSchema: [
{ name: "version", type: "i32", indexed: true },
{ name: "body", type: "bytes", indexed: false },
],
attributes: [
{ name: "projectId", type: "str", value: "alice-123" },
{ name: "version", type: "i32", value: 4 },
{ name: "balance", type: "u256", value: "0xf4240" },
{ name: "score", type: "dec", value: "3.5" },
{ name: "body", type: "bytes", value: "0x7b7d" },
],
},
],
})
const page = await new SelectQueryBuilder(client, {
$key: true,
$expiresAt: true,
$creationFlags: true,
$creator: true,
$createdAt: true,
$attributes: true,
$attributeSchema: true,
})
.where(eq("a", 1))
.fetch()
const entity = page.entities[0]
expect(entity.$key).toBe(KEY)
expect(entity.$expiresAt).toBe(0x92e21n)
expect(entity.$creationFlags).toEqual({
readonly: false,
permissionlessExtension: true,
raw: 2,
})
expect(entity.$attributes).toEqual({
projectId: { type: "str", value: "alice-123" },
version: { type: "i32", value: 4 },
balance: { type: "u256", value: 1_000_000n },
score: { type: "dec", value: "3.5" },
body: { type: "bytes", value: "0x7b7d" },
})
expect(entity.$attributeSchema).toEqual({
version: { type: "i32", indexed: true },
body: { type: "bytes", indexed: false },
})
// Unselected stays undefined rather than becoming an empty value.
expect(entity.$creator).toBeUndefined()
expect(entity.$createdAt).toBeUndefined()
expect(page.blockNumber).toBe(0x8e1ffn)
})
it("leaves indexed undefined when the node does not say, rather than guessing", async () => {
const { client } = makeClient({
blockNumber: "0x1",
data: [{ key: KEY, attributeSchema: [{ name: "version", type: "i32" }] }],
})
const page = await new SelectQueryBuilder(client, { $attributeSchema: true })
.where(eq("a", 1))
.fetch()
expect(page.entities[0].$attributeSchema).toEqual({
version: { type: "i32", indexed: undefined },
})
})
})
describe("orderBy", () => {
const Posts = defineSchema("posts", {
category: str().indexed(),
score: dec().indexed(),
title: str(),
body: bytes(),
})
const select = <const S extends Selection>(client: ArkivClient, selection: S) =>
new SelectQueryBuilder<S>(client, selection)
it("sorts after the whole filter, a top-level OR included", async () => {
const { client, request } = makeClient()
await select(client, { $key: true })
.where(or(eq("status", "open"), eq("status", "review")))
.orderBy(desc("score", "u256"))
.fetch()
expect(sent(request).query).toBe(
"status = str('open') OR status = str('review') ORDER BY score:u256 DESC",
)
})
it("takes the type from the schema's column", async () => {
const { client, request } = makeClient()
await select(client, { $key: true }).from(Posts).orderBy(desc("score")).fetch()
expect(sent(request).query).toBe("arkiv.schema = str('posts') ORDER BY score:dec DESC")
})
it("replaces the order when called again", async () => {
const { client, request } = makeClient()
await select(client, { $key: true })
.from(Posts)
.orderBy(desc("score"))
.orderBy(asc("category"))
.fetch()
expect(sent(request).query).toBe("arkiv.schema = str('posts') ORDER BY category:str ASC")
})
it("takes exactly one key, at compile time and at the call", () => {
const { client } = makeClient()
const builder = select(client, { $key: true }).from(Posts).orderBy(desc("category"))
// @ts-expect-error - orderBy takes one key
expect(() => builder.orderBy()).toThrow(/takes one key/)
// @ts-expect-error - the node sorts by one attribute only
expect(() => builder.orderBy(asc("category"), desc("score"))).toThrow(/takes one key/)
// @ts-expect-error - a key is an asc(...) or desc(...), not an array of them
expect(() => builder.orderBy([asc("category")])).toThrow(/is an asc\(\.\.\.\) or a desc/)
// @ts-expect-error - nor anything else
expect(() => builder.orderBy("score")).toThrow(/is an asc\(\.\.\.\) or a desc/)
// A failed call leaves the order it would have replaced.
expect(builder.toString()).toEndWith("ORDER BY category:str DESC")
})
it("holds a key built by hand to what asc() and desc() check, at the call", () => {
const { client } = makeClient()
const builder = select(client, { $key: true }).where(eq("category", "docs"))
const key = (fields: object) => ({ kind: "order", name: "score", type: "str", ...fields })
const orderBy = (fields: object) => () => builder.orderBy(key(fields) as never)
expect(orderBy({ direction: "desc -- " })).toThrow(/"asc" or "desc"/)
expect(orderBy({ name: "a--b", direction: "asc" })).toThrow(InvalidAttributeNameError)
expect(orderBy({ name: "a b", direction: "asc" })).toThrow(InvalidAttributeNameError)
expect(orderBy({ type: "bytes", direction: "asc" })).toThrow(/cannot be sorted as "bytes"/)
expect(orderBy({ type: "u64) OR (", direction: "asc" })).toThrow(/cannot be sorted as/)
expect(orderBy({ name: "$createdAt", type: "u64", direction: "asc" })).toThrow(
/cannot be sorted on/,
)
expect(orderBy({ name: "$whatever", type: "u64", direction: "asc" })).toThrow(/not a system/)
expect(orderBy({ name: "$expiresAt", type: "u256", direction: "asc" })).toThrow(/always a u64/)
// A hand-built $key with no type gets the fixed one, as asc() would give it.
builder.orderBy(key({ name: "$key", type: undefined, direction: "asc" }) as never)
expect(builder.toString()).toEndWith("ORDER BY $key:key ASC")
})
it("types each name of a union by itself, so a column and a system attribute mix", () => {
const { client } = makeClient()
const column = "$expiresAt" as "score" | "$expiresAt"
const builder = select(client, { $key: true }).from(Posts).orderBy(desc(column))
expect(builder.toString()).toEndWith("ORDER BY $expiresAt:u64 DESC")
})
it("needs a type for a name typed as plain string without a schema, at compile time", () => {
const { client } = makeClient()
const name: string = "score"
const builder = select(client, { $key: true }).where(eq("category", "docs"))
// @ts-expect-error - a plain string could name any attribute, so it needs its type
expect(() => builder.orderBy(desc(name)).toString()).toThrow(/needs its type/)
expect(builder.orderBy(desc(name, "dec")).toString()).toEndWith("ORDER BY score:dec DESC")
})
it("sends the order with an offset", async () => {
const { client, request } = makeClient()
await select(client, { $key: true }).from(Posts).orderBy(asc("$key")).offset(20).fetch()
expect(sent(request).query).toEndWith("ORDER BY $key:key ASC")
expect(sent(request).options).toMatchObject({ offset: "0x14" })
})
it("survives from() and withCount()", async () => {
const { client, request } = makeClient({ data: [], blockNumber: "0x1", count: "0x0" })
await select(client, { $key: true })
.orderBy(desc("score", "dec"))
.from(Posts)
.withCount()
.fetch()
expect(sent(request).query).toBe("arkiv.schema = str('posts') ORDER BY score:dec DESC")
})
it("needs a type without a schema, at compile time and before sending", async () => {
const { client, request } = makeClient()
const builder = select(client, { $key: true }).where(eq("category", "docs"))
// @ts-expect-error - score needs its type without a schema
await expect(builder.orderBy(desc("score")).fetch()).rejects.toThrow(/needs its type/)
expect(request).not.toHaveBeenCalled()
})
it("sorts on a column that is not indexed", async () => {
const { client, request } = makeClient()
await select(client, { $key: true }).from(Posts).orderBy(asc("title")).fetch()
expect(sent(request).query).toBe("arkiv.schema = str('posts') ORDER BY title:str ASC")
})
it("only sorts on the schema's non-bytes columns, at compile time and before sending", async () => {
const { client, request } = makeClient()
const query = () => select(client, { $key: true }).from(Posts)
// @ts-expect-error - title is a str, not a u256
await expect(query().orderBy(asc("title", "u256")).fetch()).rejects.toThrow(/str column/)
// @ts-expect-error - body is bytes, which cannot be sorted on
await expect(query().orderBy(asc("body")).fetch()).rejects.toThrow(/bytes cannot be sorted on/)
// @ts-expect-error - nope is not a column
await expect(query().orderBy(asc("nope")).fetch()).rejects.toThrow(/not a column/)
// @ts-expect-error - score is a dec, not a u256
await expect(query().orderBy(asc("score", "u256")).fetch()).rejects.toThrow(/dec column/)
expect(request).not.toHaveBeenCalled()
})
it("only sorts on the system attributes that can be sorted on, at compile time", () => {
const { client } = makeClient()
const builder = select(client, { $key: true }).from(Posts)
// @ts-expect-error - $createdAt cannot be sorted on
expect(() => builder.orderBy(desc("$createdAt"))).toThrow(
/"\$createdAt" is returned in query results but cannot be sorted on/,
)
// @ts-expect-error - $expiresAt is a u64
expect(() => builder.orderBy(desc("$expiresAt", "u256"))).toThrow(/always a u64/)
})
it("accepts the sortable system attributes without a type or a schema", async () => {
const { client, request } = makeClient()
await select(client, { $key: true }).ownedBy(ACCOUNT).orderBy(asc("$expiresAt")).fetch()
expect(sent(request).query).toEndWith("ORDER BY $expiresAt:u64 ASC")
await select(client, { $key: true }).ownedBy(ACCOUNT).orderBy(desc("$creator")).fetch()
expect(sent(request, 1).query).toEndWith("ORDER BY $creator:addr DESC")
})
})
describe("node rejections", () => {
/** viem wraps the arkiv codes in its own EIP-1474 classes, so the code sits down the chain. */
function rpcFailure(code: number, data?: unknown) {
const inner = Object.assign(new Error("RPC Request failed."), { code, data })
return Object.assign(new Error("Something went wrong."), {
shortMessage: "Something went wrong.",
cause: inner,
})
}
it("names the failure and keeps the node's detail", async () => {
const { client } = makeClient()
const data = { position: 14, expected: "typed literal", got: "3.5" }
;(client.request as ReturnType<typeof vi.fn>).mockRejectedValue(rpcFailure(-32001, data))
const promise = new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1)).fetch()
await expect(promise).rejects.toThrow(QueryError)
const error = (await promise.catch((e) => e)) as QueryError
expect(error.kind).toBe("parse")
expect(error.code).toBe(-32001)
expect(error.data).toEqual(data)
expect(error.query).toBe("a = i32(1)")
expect(error.message).toContain("Query: a = i32(1)")
})
it("words the message with the node's detail, not viem's label for the code", async () => {
const { client } = makeClient()
const inner = Object.assign(new Error("RPC Request failed."), {
code: -32001,
details: "unexpected character",
data: { message: "unexpected character", position: 25 },
})
const outer = Object.assign(new Error("Requested resource not found."), {
shortMessage: "Requested resource not found.",
details: "unexpected character",
code: -32001,
cause: inner,
})
;(client.request as ReturnType<typeof vi.fn>).mockRejectedValue(outer)
const error = (await new SelectQueryBuilder(client, { $key: true })
.where(eq("a", 1))
.fetch()
.catch((e) => e)) as QueryError
expect(error.message).toStartWith("Query rejected (parse, -32001): unexpected character.")
expect(error.message).not.toContain("not found")
})
it("skips an empty detail for the next link's, or the node's data", async () => {
const { client } = makeClient()
const inner = Object.assign(new Error("RPC Request failed."), {
code: -32001,
data: { message: "unexpected character", position: 25 },
})
const outer = Object.assign(new Error(""), { details: "", code: -32001, cause: inner })
;(client.request as ReturnType<typeof vi.fn>).mockRejectedValue(outer)
const error = (await new SelectQueryBuilder(client, { $key: true })
.where(eq("a", 1))
.fetch()
.catch((e) => e)) as QueryError
expect(error.message).toStartWith("Query rejected (parse, -32001): unexpected character.")
})
it("falls back to the message in the node's data", async () => {
const { client } = makeClient()
const data = { message: "unknown tag", position: 3 }
;(client.request as ReturnType<typeof vi.fn>).mockRejectedValue(rpcFailure(-32002, data))
const error = (await new SelectQueryBuilder(client, { $key: true })
.where(eq("a", 1))
.fetch()
.catch((e) => e)) as QueryError
expect(error.message).toStartWith("Query rejected (type, -32002): unknown tag.")
})
it("maps each code in the frozen range", async () => {
const kinds = {
[-32001]: "parse",
[-32002]: "type",
[-32003]: "literal",
[-32004]: "limits",
}
for (const [code, kind] of Object.entries(kinds)) {
const { client } = makeClient()
;(client.request as ReturnType<typeof vi.fn>).mockRejectedValue(rpcFailure(Number(code)))
const error = await new SelectQueryBuilder(client, { $key: true })
.where(eq("a", 1))
.fetch()
.catch((e) => e)
expect((error as QueryError).kind).toBe(kind as QueryError["kind"])
}
})
it("leaves anything else alone", async () => {
const { client } = makeClient()
const transportFailure = new Error("fetch failed")
;(client.request as ReturnType<typeof vi.fn>).mockRejectedValue(transportFailure)
const error = await new SelectQueryBuilder(client, { $key: true })
.where(eq("a", 1))
.fetch()
.catch((e) => e)
expect(error).toBe(transportFailure)
})
})
describe("the projected type", () => {
// Compile-time only: these assertions fail the build, not the run.
it("narrows the entity to exactly the selected fields", async () => {
const { client } = makeClient({
blockNumber: "0x1",
data: [{ key: KEY, owner: ACCOUNT }],
})
const page = await new SelectQueryBuilder<{ $key: true; $owner: true }>(client, {
$key: true,
$owner: true,
})
.where(eq("a", 1))
.fetch()
const entity = page.entities[0]
expectType<Hex>(entity.$key)
expectType<Hex>(entity.$owner)
// @ts-expect-error - creator was not selected
entity.$creator
})
it("reads every attribute as a map with $attributes", async () => {
const { client } = makeClient({
blockNumber: "0x1",
data: [{ key: KEY, attributes: [{ name: "version", type: "i32", value: 4 }] }],
})
const page = await new SelectQueryBuilder<{ $key: true; $attributes: true }>(client, {
$key: true,
$attributes: true,
})
.where(eq("a", 1))
.fetch()
expectType<Attributes>(page.entities[0].$attributes)
expect(page.entities[0].$attributes).toEqual({ version: { type: "i32", value: 4 } })
})
})
describe("through a schema", () => {
/** Builds the way `client.select` does, so the selection is inferred as a literal. */
const select = <const S extends Selection>(client: ArkivClient, selection: S) =>
new SelectQueryBuilder<S>(client, selection)
const Posts = defineSchema({
author: addr().indexed(),
category: str().indexed(),
score: dec().indexed(),
title: str(),
body: bytes(),
thumbnail: bytes().optional(),
})
const row: RpcEntity = {
key: KEY,
owner: ACCOUNT,
attributes: [
{ name: "author", type: "addr", value: ACCOUNT },
{ name: "title", type: "str", value: "Hello" },
{ name: "body", type: "bytes", value: "0x2348" },
],
}
it("asks for the selected columns by name, alongside the entity fields", async () => {
const { client, request } = makeClient()
await select(client, { $key: true, title: true, body: true })
.from(Posts)
.where(eq("category", "docs"))
.fetch()
expect(sent(request).options.select).toMatchObject({
key: true,
owner: false,
attributes: { title: true, body: true },
})
})
it("returns flat rows with each column typed by the schema", async () => {
const { client } = makeClient({ blockNumber: "0x1", data: [row] })
const page = await select(client, {
$key: true,
author: true,
title: true,
body: true,
thumbnail: true,
})
.from(Posts)
.where(eq("category", "docs"))
.fetch()
const [first] = page.entities
expect(first).toEqual({
$key: KEY,
author: addr(ACCOUNT),
title: str("Hello"),
body: bytes("0x2348"),
thumbnail: undefined,
})
expectType<Hex>(first.$key)
expectType<AddrValue>(first.author)
expectType<StrValue>(first.title)
expectType<BytesValue>(first.body)
expectType<BytesValue | undefined>(first.thumbnail)
// @ts-expect-error - score was not selected
first.score
})
it("decodes every entity of a page as a row", async () => {
const { client } = makeClient({ blockNumber: "0x1", data: [row, row] })
const { entities } = await select(client, { title: true })
.from(Posts)
.where(eq("category", "docs"))
.fetch()
expect(entities.map((entry) => entry.title.value)).toEqual(["Hello", "Hello"])
})
it("fails the fetch on an entity that does not fit the schema, with onMismatch: throw", async () => {
const { client } = makeClient({
blockNumber: "0x1",
data: [{ key: KEY, attributes: [{ name: "author", type: "addr", value: ACCOUNT }] }],
})
await expect(
select(client, { $key: true, title: true })
.from(Posts, { onMismatch: "throw" })
.where(eq("category", "docs"))
.fetch(),
).rejects.toThrow(SchemaMismatchError)
})
it("checks the filters against the schema before sending anything", async () => {
const { client, request } = makeClient()
const builder = select(client, { $key: true }).from(Posts)
// @ts-expect-error - title is not indexed
await expect(builder.where(eq("title", "Hello")).fetch()).rejects.toThrow(/not indexed/)
const bytesFilter = select(client, { $key: true }).from(Posts)
// @ts-expect-error - body is bytes, which is never indexed
await expect(bytesFilter.where(exists("body")).fetch()).rejects.toThrow(/never indexed/)
expect(request).not.toHaveBeenCalled()
})
it("rejects a selection naming something the schema does not have, at from()", () => {
const { client } = makeClient()
expect(() =>
select(client, { $key: true, nope: true })
// @ts-expect-error - nope is not a column
.from(Posts),
).toThrow(/"nope", which is not a column/)
})
it("checks a selection built at runtime when from() is called", () => {
const { client } = makeClient()
const selection: SelectArg = JSON.parse('{"$key": true, "nope": true}')
expect(() => new SelectQueryBuilder(client, selection).from(Posts)).toThrow(
/"nope", which is not a column/,
)
})
it("sends the selection as it was when select() was called", async () => {
const { client, request } = makeClient()
const selection: Record<string, boolean> = { $key: true, title: true }
const builder = new SelectQueryBuilder(client, selection as Selection).from(Posts)
selection.author = true
await builder.where(eq("category", "docs")).fetch()
expect(sent(request).options.select).toMatchObject({ attributes: { title: true } })
})
it("carries the filters, owner, limit and offset across from()", async () => {
const { client, request } = makeClient()
await new SelectQueryBuilder(client, { $key: true })
.where(eq("category", "docs"))
.ownedBy(ACCOUNT)
.limit(5)
.offset(10)
.from(Posts)
.fetch()
const { query, options } = sent(request)
expect(query).toBe(`category = str('docs') AND $owner = addr(${ACCOUNT})`)
expect(options).toMatchObject({ limit: "0x5", offset: "0xa" })
})
it("reads the same selection without a schema, untyped and unchecked", async () => {
const misfit = { key: KEY, attributes: [{ name: "title", type: "i32", value: 7 }] }
const { client, request } = makeClient({ blockNumber: "0x1", data: [row, misfit] })
const page = await select(client, { $key: true, title: true, body: true })
.where(eq("category", "docs"))
.fetch()
expect(sent(request).options.select).toMatchObject({
key: true,
attributes: { title: true, body: true },
})
// The same flat rows as through a schema; without one, nothing is checked, so the misfit
// comes back as it is and a missing attribute as undefined.
expect(page.entities).toEqual([
{ $key: KEY, title: str("Hello"), body: bytes("0x2348") },
{ $key: KEY, title: i32(7), body: undefined },
])
const title: ArkivValue | undefined = page.entities[0]?.title
expect(title).toEqual(str("Hello"))
})
it("skips a mismatch by default, and throws on it with onMismatch: throw", async () => {
// Anyone can write an entity a query matches but the schema does not fit, so one such entity
// must not fail every read of the others unless the caller asks for that.
const misfit = { key: KEY, attributes: [{ name: "author", type: "addr", value: ACCOUNT }] }
const pages = () => makeClient({ blockNumber: "0x1", data: [row, misfit, row] }).client
const page = await select(pages(), { $key: true, title: true })
.from(Posts)
.where(eq("category", "docs"))
.fetch()
expect(page.entities.map((entry) => entry.title.value)).toEqual(["Hello", "Hello"])
expect(page.skipped).toBe(1)
await expect(
select(pages(), { $key: true, title: true })
.from(Posts, { onMismatch: "throw" })
.where(eq("category", "docs"))
.fetch(),
).rejects.toThrow(SchemaMismatchError)
})
it("still throws errors that are not mismatches when skipping", async () => {
const { client } = makeClient({
blockNumber: "0x1",
data: [{ key: KEY, attributes: [{ name: "title", type: "nope", value: 1 }] }],
})
await expect(
select(client, { title: true })
.from(Posts, { onMismatch: "skip" })
.where(eq("category", "docs"))
.fetch(),
).rejects.toThrow(/Unknown attribute type/)
})
it("counts skips, so a page of misfits is not mistaken for the end", async () => {
const misfit = { key: KEY, attributes: [] }
const { client } = makeClient({ blockNumber: "0x1", data: [misfit, row, misfit] })
const page = await select(client, { title: true })
.from(Posts, { onMismatch: "skip" })
.where(eq("category", "docs"))
.limit(3)
.fetch()
expect(page.entities.map((entry) => entry.title.value)).toEqual(["Hello"])
// The node counted all three toward the limit, so the next page starts at offset 3.
expect(page.entities.length + page.skipped).toBe(3)
})
it("reports nothing skipped when not skipping, or without a schema", async () => {
const throughSchema = await select(makeClient({ blockNumber: "0x1", data: [row] }).client, {
title: true,
})
.from(Posts)
.where(eq("category", "docs"))
.fetch()
expect(throughSchema.skipped).toBe(0)
const plain = await new SelectQueryBuilder(makeClient().client, { $key: true })
.where(eq("category", "docs"))
.fetch()
expect(plain.skipped).toBe(0)
})
it("leaves the builder it was called on untouched, so two schemas never share one", async () => {
const Users = defineSchema("users", { category: str().indexed(), name: str() })
const { client, request } = makeClient()
const base = new SelectQueryBuilder(client, "*").limit(5)
const posts = base.from(Posts).where(eq("category", "docs"))
const users = base.from(Users)
expect(posts).not.toBe(base)
expect(users).not.toBe(posts)
await users.where(eq("category", "people")).fetch()
await posts.fetch()
// Each carried what `base` held when it was made, and nothing the other added since.
expect(sent(request, 0).query).toBe("arkiv.schema = str('users') AND category = str('people')")
expect(sent(request, 0).options.limit).toBe("0x5")
expect(sent(request, 1).query).toBe("category = str('docs')")
expect(() => base.toString()).toThrow(/at least one filter/)
})
it("types a dynamic selection's rows with every field optional", async () => {
const { client } = makeClient({ blockNumber: "0x1", data: [row] })
const selection: SelectArg = { $key: true }
const page = await new SelectQueryBuilder<DynamicSelection>(client, selection)
.from(Posts)
.where(eq("category", "docs"))
.fetch()
const [first] = page.entities
expectType<StrValue | undefined>(first.title)
expect(first).toEqual({ $key: KEY })
})
it("rejects an onMismatch it does not know", () => {
const { client } = makeClient()
expect(() =>
select(client, { title: true }).from(Posts, { onMismatch: "ignore" as never }),
).toThrow(/"throw" or "skip"/)
})
it("rejects something that is not a schema", () => {
const { client } = makeClient()
expect(() =>
new SelectQueryBuilder(client, "*").from({ columns: {} } as unknown as typeof Posts),
).toThrow(/defineSchema/)
})
})
describe("through a named schema", () => {
const Posts = defineSchema("posts", { category: str().indexed(), title: str() })
const select = <const S extends Selection>(client: ArkivClient, selection: S) =>
new SelectQueryBuilder<S>(client, selection)
it("filters on the schema's tag, ahead of everything else", async () => {
const { client, request } = makeClient()
await select(client, { $key: true }).from(Posts).where(eq("category", "docs")).fetch()
expect(sent(request).query).toBe("arkiv.schema = str('posts') AND category = str('docs')")
})
it("needs no other filter, so it can list every entity of the schema", async () => {
const { client, request } = makeClient()
await select(client, { $key: true }).from(Posts).fetch()
expect(sent(request).query).toBe("arkiv.schema = str('posts')")
})
it("checks the caller's filters, not the tag it adds itself", async () => {
const { client } = makeClient()
const builder = select(client, { $key: true }).from(Posts)
// @ts-expect-error - arkiv.schema is not a column
builder.where(eq("arkiv.schema", "comments"))
await expect(builder.fetch()).rejects.toThrow(/"arkiv.schema" is not a column/)
})
it("leaves an unnamed schema's query to its filters alone", async () => {
const { client, request } = makeClient()
await select(client, { $key: true })
.from(defineSchema({ category: str().indexed() }))
.where(eq("category", "docs"))
.fetch()
expect(sent(request).query).toBe("category = str('docs')")
})
})
/** Asserts the static type of an expression without touching it at runtime. */
function expectType<T>(_value: T): void {}