@arkiv-network/sdk
Version:
TypeScript client library for Arkiv's blockchains interactions
491 lines (490 loc) • 24.8 kB
TypeScript
import { F as ResultOf, J as Entity, Mt as ArkivClient, R as SelectArg, W as UnknownSelection, X as OrderCheck, Z as OrderTerm, dt as SchemaRef, st as Expression, ut as RefsOf, zt as RpcSelect } from "./changeOwnership-CV-auKHv.js";
import { O as Schema, ht as TypeTag, x as ColumnsOf } from "./index-DaMIgZgJ.js";
import { Hex } from "viem";
//#region src/query/builderBase.d.ts
/** The expressions in a `where` call — passed one by one, or as one array. */
type WhereExpressions<E extends readonly (Expression | readonly Expression[])[]> = E[number] extends infer X ? (X extends readonly Expression[] ? X[number] : X) : never;
/**
* Human-readable messages for compile-time errors when a filter does not fit the schema.
*/
type FilterProblem<R, S extends Schema> = R extends {
readonly name: infer N extends string;
readonly type: infer T extends string;
} ? R extends SchemaRef<S> ? never : N extends SchemaRef<S>["name"] ? `"${N}" has type ${Extract<SchemaRef<S>, {
name: N;
}>["type"]}, but is compared against a value of type ${T}` : N extends keyof ColumnsOf<S> ? ColumnsOf<S>[N]["type"] extends "bytes" ? `"${N}" is a bytes column, and bytes are never indexed, so it cannot be filtered on. Index a hash of it, as a hex str, instead` : `"${N}" is not indexed, so it cannot be filtered on. Declare it ${ColumnsOf<S>[N]["type"]}().indexed() to filter on it` : `"${N}" is not a column of this schema` : never;
/**
* `where`'s check that every filter fits the schema — `unknown` when it does, or when there is no
* schema, and otherwise an object whose one property is named after the problem. No expression has
* that property, so the compiler reports the sentence as the property it is missing.
*/
type FilterCheck<E extends readonly (Expression | readonly Expression[])[], TSchema> = TSchema extends Schema ? [FilterProblem<RefsOf<WhereExpressions<E>>, TSchema>] extends [infer P extends string] ? [P] extends [never] ? unknown : { [K in P]: never } : unknown : unknown;
/**
* What every query builder shares: the client, the filters — `where`, `ownedBy`, `createdBy` and a
* named schema's tag — and the block to read at. `client.select()` and `client.count()` build on it,
* so the same filters always send the same query string, whichever of them runs it.
*
* Not constructed directly: create a builder with `client.select(...)` or `client.count()`.
*
* @typeParam TSchema - The schema named with `from()`, if any, which `where` is checked against.
*/
declare abstract class QueryBuilderBase<TSchema extends Schema | undefined = undefined> {
protected readonly _client: ArkivClient;
protected _schema: Schema | undefined;
protected readonly _filters: Expression[];
protected _ownedBy: Expression | undefined;
protected _createdBy: Expression | undefined;
constructor(client: ArkivClient);
/**
* This builder's state in a new builder of the same class, re-typed by the caller. Every field is
* carried over, so one a subclass adds is not dropped by `from()`; the filter list, which is
* mutated in place, is copied rather than shared.
*
* A copy, not `this` re-typed: a builder typed for one schema must never be the same object as
* one typed for another, or `base.from(Posts)` and `base.from(Users)` would share their state.
*/
protected copy<B extends QueryBuilderBase<Schema | undefined>>(): B;
/**
* Adds filters. Everything passed here, and across repeated calls, must hold.
*
* After `from()`, every attribute a filter names must be an indexed column of the schema,
* compared against a value of its type — anything else is a compile error.
*
* @param expressions - The expressions, as separate arguments or one array.
*
* @example
* builder.where(eq("category", "docs"))
* builder.where(gte("level", i32(10)), lt("level", i32(20)))
* builder.where(or(eq("status", "open"), exists("reviewedBy")))
*/
where<const E extends readonly (Expression | readonly Expression[])[]>(...expressions: E & FilterCheck<E, TSchema>): this;
/**
* Restricts the query to entities this account owns — shorthand for
* `where(eq("$owner", addr(owner)))`. Calling it again replaces the filter.
*
* @example
* builder.ownedBy("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")
*/
ownedBy(owner: Hex): this;
/**
* Restricts the query to entities this account created — shorthand for
* `where(eq("$creator", addr(creator)))`. Calling it again replaces the filter.
*
* The creator never changes; the owner does, so these differ after a transfer.
*/
createdBy(creator: Hex): this;
/**
* The query string this builder will send — the same text the node parses: its filters,
* `ownedBy` and `createdBy`, and a named schema's tag, ANDed.
*
* @throws {InvalidPredicateError} If no filter has been added, or — after `from()` — a filter
* does not fit the schema. A named schema's own tag filter counts as a filter.
*/
toString(): string;
}
//#endregion
//#region src/query/countBuilder.d.ts
/**
* Builds and runs a count: how many entities a query matches, without fetching any of them.
*
* It filters exactly as `client.select()` does — {@link from}, `where`, `ownedBy` and `createdBy`
* are the same methods, from the same base — but has nothing to select and no pages, so
* {@link fetch} resolves to a `bigint`. Create one with `client.count()`.
*
* To show the total next to a page of rows, use `select(...).withCount()` instead, which counts in
* the same request.
*
* @typeParam TSchema - The schema named with {@link from}, if any.
*
* @example
* const total = await client.count().from(Posts).ownedBy(account.address).fetch()
*/
declare class CountQueryBuilder<TSchema extends Schema | undefined = undefined> extends QueryBuilderBase<TSchema> {
/**
* Counts through a schema: `where` is checked against its indexed columns, and a named schema
* counts only entities tagged with its name — so it needs no other filter.
*
* Returns a new builder, carrying whatever this one already holds; this one is left as it was.
*
* @throws {SchemaDefinitionError} If `schema` was not built with `defineSchema`.
*
* @example
* client.count().from(Posts) // every post
*/
from<S extends Schema>(schema: S): CountQueryBuilder<S>;
/**
* Runs the count.
*
* Counts every entity the filters match at the block, whether or not it fits the schema — what
* `select().from(schema)` leaves out of its pages is still counted.
*
* @throws {InvalidPredicateError} If no filter has been added, or a filter does not fit the
* schema.
* @throws {QueryError} If the node rejects the query.
* @throws If the node returns no count — it predates the count projection.
*/
fetch(): Promise<bigint>;
}
//#endregion
//#region src/query/queryResult.d.ts
/**
* One page of query results.
*
* A page is one request: to read further, run the query again with an `offset()` past this page.
*
* @typeParam TEntity - The shape of each row, inferred from the selection by `client.select()`.
* @typeParam TCount - `bigint` when the query was built with `withCount()`, and `undefined` when it
* was not.
*
* @example
* const page = await client
* .select({ $key: true })
* .where(eq("category", "docs"))
* .orderBy(asc("$key"))
* .limit(20)
* .offset(40)
* .fetch()
*/
declare class QueryResult<TEntity = Record<string, unknown>, TCount extends bigint | undefined = bigint | undefined> {
/** The rows on this page — flat, as the selection lays them out. */
readonly entities: readonly TEntity[];
/**
* The block this page was read at. Every request reads the latest block, so two pages of one
* query can come from different blocks.
*/
readonly blockNumber: bigint;
/**
* How many matching entities this page left out because they did not fit the schema — always
* `0` without a schema, or with `.from(schema, { onMismatch: "throw" })`.
*
* The node counts them toward the limit and the offset, so `entities.length + skipped` is how
* many matches this page covered: add it to the offset to reach the next page. With an explicit
* `limit()`, a page that covered fewer than the limit was the last. An empty page is not the
* end on its own: every match on it may have been skipped.
*/
readonly skipped: number;
/**
* How many entities the query matches in all, regardless of the limit and offset — a `bigint`
* when the query was built with `withCount()`, and `undefined` otherwise. It includes entities
* left out for not fitting the schema, which {@link skipped} reports per page.
*/
readonly count: TCount;
constructor(entities: readonly TEntity[], blockNumber: bigint, skipped: number, count: TCount);
}
//#endregion
//#region src/query/queryBuilder.d.ts
/**
* `from(schema)`'s check that every selected key is an entity field or a column — `unknown` when
* it holds, and an object naming the offenders, which no schema satisfies, when it does not.
*/
type SelectionCheck<TSelection, TSchema extends Schema> = [UnknownSelection<TSelection, TSchema>] extends [never] ? unknown : {
"selected names that are not columns of this schema": UnknownSelection<TSelection, TSchema>;
};
/**
* How a query through a schema treats a matching entity that does not fit the schema — one not
* tagged with a named schema's name, or missing a selected required column or holding one of
* another type. Columns the selection leaves out are not checked.
*/
type FromOptions = {
/**
* `"skip"` (the default) leaves the entity out of the page — so a page can hold fewer rows than
* its limit, and a page whose every entity was skipped is empty while later pages still have rows.
* Each page reports how many it left out as {@link QueryResult.skipped}. Arkiv is public, so
* anyone can write an entity that matches a query but not the schema; skipping keeps one such
* entity from failing every read of the others. `"throw"` fails the fetch with a
* {@link SchemaMismatchError} instead.
*/
onMismatch?: "throw" | "skip" | undefined;
};
/**
* Builds and runs a query.
*
* The selection is fixed when the builder is created and the filter is added by chaining, so the
* result type is known before a single predicate is written. Create one with `client.select(...)`
* rather than constructing it directly — that is what infers the result shape from the selection.
*
* The query returns flat rows: the selected entity fields and attributes side by side. Naming a
* schema with {@link from} changes what the selection means for nothing but the selected
* attributes — they come back typed and checked as the schema declares — and has `where` checked
* against it, so that filtering on a column the schema does not index, or comparing one against the
* wrong type, is a compile error. A named schema also restricts the query to entities tagged with
* its name, and leaves that tag out of the rows' `$attributes` and `$attributeSchema`.
*
* Every filter added with {@link where}, {@link ownedBy} and {@link createdBy} must hold; use
* {@link or} for alternatives inside one call. {@link orderBy} sorts what they match.
*
* @typeParam TSelection - The selection, inferred by `client.select()`.
* @typeParam TSchema - The schema named with {@link from}, if any.
*
* @example
* import { createPublicClient } from "@arkiv-network/sdk"
* import { dec } from "@arkiv-network/sdk/schema"
* import { rabdi } from "@arkiv-network/sdk/chains"
* import { and, desc, eq, gt } from "@arkiv-network/sdk/query"
* import { http } from "viem"
*
* const client = createPublicClient({ chain: rabdi({ accessKey: "..." }), transport: http() })
*
* const page = await client
* .select({ $key: true, author: true, title: true })
* .from(Posts)
* .where(and(eq("category", "docs"), gt("score", dec("4.0"))))
* .orderBy(desc("score"))
* .limit(20)
* .fetch()
*
* for (const row of page.entities) console.log(row.$key, row.title.value)
*/
declare class SelectQueryBuilder<TSelection = "*", TSchema extends Schema | undefined = undefined, TCount extends bigint | undefined = undefined> extends QueryBuilderBase<TSchema> {
private readonly _selection;
private _projection;
private _onMismatch;
private _limit;
private _offset;
private _withCount;
private _orderBy;
/**
* @throws If the selection is not `"*"` or an object, asks for nothing, or names an attribute
* outside the name grammar.
*/
constructor(client: ArkivClient, selection?: SelectArg);
/**
* Runs the query through a schema. The selection keeps its meaning and the rows their shape;
* each selected attribute becomes a column — typed as the schema declares, and checked against
* it — and `where` is checked against the schema's indexed columns.
*
* A named schema also filters: only entities tagged with its name match, so a query through it
* sees only entities written through it, and needs no other filter. The tag is how the query finds
* them rather than one of their attributes, so rows leave it out of `$attributes` and
* `$attributeSchema`. An unnamed schema types the query without filtering it — `where` alone
* decides which entities match.
*
* A matching entity that does not fit — a selected required column missing or of another type,
* or, for a named schema, a tag that does not match — is left out of the page, or fails the fetch
* with a {@link SchemaMismatchError} with `{ onMismatch: "throw" }`. Columns the selection leaves
* out are not checked.
*
* Returns a new builder, carrying whatever this one already holds; this one is left as it was.
*
* @param schema - The schema, built with `defineSchema`.
* @param options - How to treat entities that do not fit. {@link FromOptions}
* @throws {SchemaDefinitionError} If `schema` was not built with `defineSchema`.
* @throws If the selection names something that is neither an entity field nor a column.
*
* @example
* client.select({ $key: true, title: true }).from(Posts) // rows typed { $key; title }
* client.select().from(Posts) // every field and every column
* client.select().from(Posts, { onMismatch: "throw" }) // fail on entities that do not fit
*/
from<S extends Schema>(schema: S & SelectionCheck<TSelection, S>, options?: FromOptions): SelectQueryBuilder<TSelection, S, TCount>;
/**
* Sorts the results by one attribute, with an {@link asc} or {@link desc} key. All the matches
* are sorted, not each page on its own, and ties come in the node's own order. Calling it again
* replaces the order. To page with {@link offset}, sort by a key that does not change, such as
* `asc("$key")`.
*
* Any attribute but a bytes one can be sorted on, indexed or not. After `from()`, the key takes
* its type from the column, and naming anything but a non-bytes column or a queryable system
* attribute is a compile error. Without a schema, a key on a user attribute names its type. Call
* it after `from()`: a key given before it is checked against the schema only when the query is
* sent.
*
* @param key - The sort, an `asc(...)` or a `desc(...)`.
* @throws {InvalidPredicateError} If not given exactly one key, or the key is not one `asc(...)`
* or `desc(...)` would build. A key that does not fit the schema, or has no type without one,
* throws when the query is sent.
*
* @example
* builder.orderBy(desc("score")) // through a schema
* builder.orderBy(desc("score", "u256")) // without one
* builder.orderBy(asc("$expiresAt"))
*/
orderBy<const K extends OrderTerm>(key: K & OrderCheck<K, TSchema>): this;
/**
* The query string this builder will send: the filters, as {@link QueryBuilderBase.toString}
* renders them, followed by the {@link orderBy} clause, if any.
*
* @throws {InvalidPredicateError} If no filter has been added, a filter or sort key does not fit
* the schema, or — without a schema — a sort key on a user attribute has no type.
*/
toString(): string;
/**
* Sets the page size, up to the node maximum of 100. Without it the node picks the page size.
*
* @example
* builder.limit(100)
*/
limit(limit: number): this;
/**
* Skips the first `offset` matches. With {@link limit}, this takes one page: page `n` of size
* `size` is `.limit(size).offset(n * size)`.
*
* Every request reads the latest block, so an entity created or deleted between two requests
* shifts the matches after it, and a row can repeat or be missed. Sort by a key that does not
* change, such as `asc("$key")`, so that pages line up at least while nothing is written; without
* {@link orderBy}, the order is the node's own. Through a schema, the offset counts the entities a
* page skips as well as its rows.
*
* To read every match exactly once while others write, page by key instead: sort by
* `asc("$key")` and filter on `gt("$key", key(lastKey))`, with `lastKey` the last `$key` of the
* previous page. Through a schema, use `{ onMismatch: "throw" }` for that: a page of skipped
* entities returns no rows, so no key to continue from.
*
* @example
* builder.orderBy(asc("$key")).limit(20).offset(40) // the third page of 20
*/
offset(offset: number): this;
/**
* Also counts every entity the query matches, regardless of the limit and offset. The page then
* carries the total as {@link QueryResult.count}, typed `bigint`, so a paginated list can show it
* without a second query. To count without fetching rows, use `client.count()` instead.
*
* The node counts what the filters match, at the block the page was read at. Through a schema,
* that includes entities the page leaves out for not fitting it. Later pages read later blocks,
* so the count can be out of date by the time they arrive.
*
* @throws If, when fetched, the node returns no count — it predates the count projection.
*
* Returns a new builder, carrying whatever this one already holds; this one is left as it was.
*
* @example
* const page = await builder.withCount().limit(20).fetch()
* page.count // every match, not just these 20
*/
withCount(): SelectQueryBuilder<TSelection, TSchema, bigint>;
/**
* Runs the query and returns one page: up to {@link limit} matches, past the first
* {@link offset}.
*
* @throws {InvalidPredicateError} If no filter has been added, or a filter or sort key does not
* fit the schema.
* @throws If the {@link limit} is not an integer from 1 to 100, or the {@link offset} is not a
* non-negative integer.
* @throws {SchemaMismatchError} If, through a schema, a matching entity does not fit the selected
* columns or a named schema's tag, and `from()` was given `{ onMismatch: "throw" }`.
* @throws {QueryError} If the node rejects the query.
*
* @example
* const page = await client.select({ $key: true }).where(eq("category", "docs")).fetch()
* page.entities
*/
fetch(): Promise<QueryResult<ResultOf<TSelection, TSchema>, TCount>>;
}
//#endregion
//#region src/query/engine.d.ts
declare const MAX_LIMIT = 100;
/** One `arkiv_query` call. */
type QueryRequest = {
/** The query expression, already rendered. */
query: string;
/** What to return. */
select: RpcSelect;
/** Page size, up to {@link MAX_LIMIT}. Defaults to the node's own page size. */
limit?: number | undefined;
/** How many matches to skip before the page. Defaults to none. */
offset?: number | undefined;
/** Whether to also count every entity the query matches. */
count?: boolean | undefined;
};
/** One page of results. */
type QueryResponse = {
entities: Entity[];
/** The block the page was read at. */
blockNumber: bigint;
/** How many entities the query matches, if the request asked to count them. */
count: bigint | undefined;
};
/**
* Runs one query and decodes the page it returns.
*
* @throws If `limit` is not an integer from 1 to {@link MAX_LIMIT}, or `offset` is not a
* non-negative integer.
* @throws {QueryError} If the node rejects the query.
*/
declare function runQuery(client: ArkivClient, request: QueryRequest): Promise<QueryResponse>;
//#endregion
//#region src/query/errors.d.ts
/**
* A predicate asked for an operator the query language does not define for that type.
*
* The operator × type matrix is part of the language, not a policy: a prefix test on a type other
* than `str` is a **type error** at the node (a {@link QueryError} of kind `"type"`), not
* an empty result. Catching it here turns a round trip and an RPC error code into a stack trace
* pointing at the call site.
*
* | | `=` `<` `<=` `>` `>=` `BETWEEN` | `STARTSWITH` |
* | --- | --- | --- |
* | `str` | yes | yes |
* | every other indexable type | yes | no |
*/
declare class UnsupportedOperatorError extends Error {
/** The attribute the predicate names. */
readonly attributeName: string;
/** The operator, as it would have been written in the query. */
readonly operator: string;
/** The type of the value it was applied to. */
readonly tag: TypeTag;
constructor(attributeName: string, operator: string, tag: TypeTag, hint?: string);
}
/**
* A predicate or query could not be expressed in the query language for a reason other than its
* operator. The main causes:
*
* - a `$` name that is not a queryable system attribute — one returned in results but never
* indexed (`$createdAt`, `$updatedAt`, `$creationFlags`), or none at all;
* - a system attribute compared against the wrong type, or tested with `exists()` / `hasType()`,
* which on a system attribute would be constants;
* - a comparison against a `bytes` value, which is never indexed;
* - a `BETWEEN` whose bounds have different types;
* - a `hasType()` tag that is not an indexable type;
* - a combinator with nothing to combine;
* - a query with no filter at all — a named schema's tag counts as one;
* - through `.from(schema)`, a filter that does not fit the schema: a name that is not a column, a
* column that is not indexed, or a value of another type than the column's;
* - a sort key that cannot be sent: an `orderBy()` with no key or more than one, a key on a bytes
* column or on something that is not a column of the schema, one with no type and no schema to
* take it from, or one whose type is not the attribute's.
*/
declare class InvalidPredicateError extends Error {
/** The attribute the predicate names, where there is one. */
readonly attributeName: string | undefined;
constructor(message: string, attributeName?: string);
}
/**
* What a node rejected a query for. Part of the frozen RPC surface, so it is safe to branch on.
*/
type QueryErrorKind = /** The query string does not parse. `data` locates it: `{ position, expected, got }`. */
"parse"
/** A predicate is well-formed but ill-typed — `STARTSWITH` on a type other than `str`, `BETWEEN` bounds of different types, an unknown tag. */ | "type"
/** A literal failed validation — an `i32` out of range, a bad EIP-55 checksum, more than 18 decimal places. */ | "literal"
/** A node budget was exceeded — query length, predicate count, nesting depth, bitmap operations. */ | "limits";
/**
* The node rejected a query.
*
* These codes are part of the frozen `arkiv_query` surface, so {@link kind} and {@link data} are
* stable enough to branch on — which the underlying JSON-RPC error is not, because the codes
* overlap Ethereum's own EIP-1474 numbering and get wrapped accordingly on the way up.
*
* @example
* try {
* await client.query("category = 'docs'")
* } catch (error) {
* if (error instanceof QueryError && error.kind === "parse") {
* console.log(error.data) // { position, expected, got }
* }
* }
*/
declare class QueryError extends Error {
/** Which failure this is. */
readonly kind: QueryErrorKind;
/** The JSON-RPC error code. */
readonly code: number;
/** The machine-readable detail the node sent, if any — e.g. `{ position, expected, got }`. */
readonly data: unknown;
/** The query string that was rejected. */
readonly query: string;
constructor(kind: QueryErrorKind, code: number, query: string, data: unknown, detail?: string);
}
//#endregion
export { MAX_LIMIT as a, runQuery as c, QueryResult as d, CountQueryBuilder as f, UnsupportedOperatorError as i, FromOptions as l, QueryError as n, QueryRequest as o, QueryBuilderBase as p, QueryErrorKind as r, QueryResponse as s, InvalidPredicateError as t, SelectQueryBuilder as u };
//# sourceMappingURL=index-CBvzb16i.d.ts.map