UNPKG

@arkiv-network/sdk

Version:

TypeScript client library for Arkiv's blockchains interactions

714 lines (664 loc) • 26.7 kB
import { type ColumnsOf, describeColumns, type IndexableTypeTag, type IndexableValue, type IndexedColumnName, lookupColumn, type Schema, type StrValue, str, type TagOf, type TypeTag, toValue, validateAttributeName, } from "../schema" // Internal seam: the typeId table is the protocol's, not part of the package's public surface. import { isIndexable, TYPE_IDS } from "../schema/types" import { InvalidPredicateError, UnsupportedOperatorError } from "./errors" /** * The comparison operators the query language defines. Every indexable type accepts all of them; * `<` `<=` `>` `>=` compare in index order — numerically for `i32`, `u64`, `u256` and `dec`, * `false < true` for `bool`, and by unsigned bytes for `str`, `addr` and `key`. */ export type ComparisonOperator = "=" | "<" | "<=" | ">" | ">=" /** * A value a predicate can compare against: a value of any indexable type, or a bare boolean, * number, bigint or string. `bytes` is excluded — it is never indexed, so never filterable. */ export type PredicateValue = IndexableValue | boolean | number | bigint | string /** * `between`'s check that both bounds have one type — `unknown` when they do, and otherwise an * object whose one property is named after the problem. No value has that property, so the * compiler reports the sentence as the property it is missing. */ type SameTypeCheck<L, H> = [TagOf<L>, TagOf<H>] extends [TagOf<H>, TagOf<L>] ? unknown : { [K in `BETWEEN bounds must have one type, got ${TagOf<L>} and ${TagOf<H>}`]: never } /** * The attribute a predicate names, and the type it asserts — `never` when it asserts none, as * `EXISTS` does. Carried by an {@link Expression} at the type level only, so a query against a * schema can be checked at compile time: every name must be an indexed column, and every type the * column's. */ export type AttributeRef = { readonly name: string; readonly type: IndexableTypeTag } declare const refs: unique symbol /** The type-level record of the attributes an expression names. Never present at runtime. */ export type Refs<R extends AttributeRef> = { readonly [refs]?: R } /** A comparison between an attribute and a typed literal. */ export interface ComparisonNode<R extends AttributeRef = AttributeRef> extends Refs<R> { readonly kind: "comparison" readonly name: string readonly operator: ComparisonOperator readonly value: IndexableValue } /** An inclusive range test, `name BETWEEN low AND high`. Both bounds have one type. */ export interface BetweenNode<R extends AttributeRef = AttributeRef> extends Refs<R> { readonly kind: "between" readonly name: string readonly low: IndexableValue readonly high: IndexableValue } /** A `STARTSWITH` prefix test, the language's only pattern operator. */ export interface StartsWithNode<R extends AttributeRef = AttributeRef> extends Refs<R> { readonly kind: "startsWith" readonly name: string readonly value: StrValue } /** An `EXISTS(name)` test — true when the attribute is set with any type. */ export interface ExistsNode<R extends AttributeRef = AttributeRef> extends Refs<R> { readonly kind: "exists" readonly name: string } /** A `TYPEOF(name) = tag` test. */ export interface TypeOfNode<R extends AttributeRef = AttributeRef> extends Refs<R> { readonly kind: "typeOf" readonly name: string readonly tag: IndexableTypeTag } /** A conjunction. */ export interface AndNode<R extends AttributeRef = AttributeRef> extends Refs<R> { readonly kind: "and" readonly expressions: readonly Expression[] } /** A disjunction. */ export interface OrNode<R extends AttributeRef = AttributeRef> extends Refs<R> { readonly kind: "or" readonly expressions: readonly Expression[] } /** * A query expression: an immutable tree built by the combinators below, whose `toString()` is the * exact query string sent over the wire. * * @typeParam R - The attributes the expression names, as {@link AttributeRef}s — inferred by the * combinators, and checked against the schema by `select(...).from(schema).where(...)`. * * @example * const q = and(gte("level", i32(10)), or(eq("status", str("open")), exists("reviewedBy"))) * String(q) // level >= i32(10) AND (status = str('open') OR EXISTS(reviewedBy)) */ export type Expression<R extends AttributeRef = AttributeRef> = | ComparisonNode<R> | BetweenNode<R> | StartsWithNode<R> | ExistsNode<R> | TypeOfNode<R> | AndNode<R> | OrNode<R> /** The attributes an expression names — the `R` of an {@link Expression}. */ export type RefsOf<E> = E extends Refs<infer R> ? Exclude<R, undefined> : never /** The reference a comparison against `V` makes to `N`. */ type ValueRef<N extends string, V> = { readonly name: N readonly type: TagOf<V> & IndexableTypeTag } /** * The system attributes a query may filter on, and the type each one carries. * * Every other system attribute (`$createdAt`, `$updatedAt`, `$creationFlags`) is returned in * query results — selected and read back as `createdAt`, `updatedAt` and `creationFlags` — but is * not indexed, so it cannot appear in a predicate. */ const QUERYABLE_SYSTEM_ATTRIBUTES = { $key: "key", $owner: "addr", $creator: "addr", $expiresAt: "u64", } as const satisfies Record<string, TypeTag> type SystemAttributeName = keyof typeof QUERYABLE_SYSTEM_ATTRIBUTES /** The references a filter on a system attribute makes — each with its fixed type. */ export type SystemAttributeRef = { [N in SystemAttributeName]: { readonly name: N readonly type: (typeof QUERYABLE_SYSTEM_ATTRIBUTES)[N] } }[SystemAttributeName] /** * The references a filter through schema `S` may make: each indexed column with its declared * type, and the queryable system attributes with theirs. An {@link Expression} naming anything else * is a compile error in `select(...).from(S).where(...)`. */ export type SchemaRef<S extends Schema> = | { [K in IndexedColumnName<S>]: { readonly name: K readonly type: ColumnsOf<S>[K]["type"] & IndexableTypeTag } }[IndexedColumnName<S>] | SystemAttributeRef /** System attributes a query can ask for but not filter on — they carry no index. */ const RESULT_ONLY_SYSTEM_ATTRIBUTES = ["$createdAt", "$updatedAt", "$creationFlags"] /** * Checks an attribute reference, returning its fixed type when it names a system attribute. * * User attribute names go through the same grammar the write path enforces, so a name that could * never have been written is rejected here rather than matching nothing. * * @param use - What the reference is for, `"filter"` or `"sort"`, which the error messages name. */ export function resolveReference( name: string, use: "filter" | "sort" = "filter", ): TypeTag | undefined { if (typeof name !== "string" || !name.startsWith("$")) { validateAttributeName(name) return undefined } const system = QUERYABLE_SYSTEM_ATTRIBUTES[name as SystemAttributeName] if (system !== undefined) return system if (RESULT_ONLY_SYSTEM_ATTRIBUTES.includes(name)) { // Selected, and read back, as the entity field of the same name without the `$`. const field = name.slice(1) const reason = use === "filter" ? "carries no index, so it cannot be filtered on" : "cannot be sorted on" throw new InvalidPredicateError( `"${name}" is returned in query results but ${reason}. Select it as \`${field}\` and ${use} ` + "the results in JavaScript instead.", name, ) } throw new InvalidPredicateError( `"${name}" is not a system attribute. The queryable ones are ` + `${Object.keys(QUERYABLE_SYSTEM_ATTRIBUTES).join(", ")}. ` + '"$" is reserved for these, so a user attribute cannot be named this.', name, ) } /** * Whether a string names a type that can be indexed — every type but `bytes`. * * Takes a `string` rather than an `IndexableTypeTag` on purpose — the callers' static types * already say as much, and this is the runtime check for the code that has no types. */ export function isIndexableTag(tag: string): tag is IndexableTypeTag { // `Object.hasOwn` rather than `in`, which would also accept every Object.prototype key. return Object.hasOwn(TYPE_IDS, tag) && isIndexable(tag as TypeTag) } /** The indexable type tags, listed for error messages: "bool, i32, u64, …". */ export const INDEXABLE_TAGS = (Object.keys(TYPE_IDS) as TypeTag[]).filter(isIndexable).join(", ") /** Freezes a node and gives it a (non-enumerable, so still `toEqual`-able) rendering. */ function node<T extends Expression>(value: T): T { Object.defineProperty(value, "toString", { value: () => render(value), enumerable: false, }) return Object.freeze(value) } /** * Resolves a value a predicate compares `name` against, and checks it can be: not `bytes`, which is * never indexed, and of the fixed type when `name` is a system attribute. */ function predicateValue( name: string, system: TypeTag | undefined, input: PredicateValue, ): IndexableValue { const value = toValue(input, name) if (value.type === "bytes") { throw new InvalidPredicateError( `"${name}" is compared against a bytes value, and bytes are stored but never indexed, so ` + "they cannot be filtered on. Index a hash of them, as a hex str, instead.", name, ) } if (system !== undefined && value.type !== system) { throw new InvalidPredicateError( `"${name}" is a ${system}, but the value is a ${value.type}. ` + `System attributes have a fixed type — write ${system}(...) around the value.`, name, ) } return value } function comparison<R extends AttributeRef>( name: string, operator: ComparisonOperator, input: PredicateValue, ): ComparisonNode<R> { const value = predicateValue(name, resolveReference(name), input) return node({ kind: "comparison", name, operator, value }) } /** * Attribute equals value — set with this exact type, and holding this value. * * Bare booleans, numbers, bigints and strings take the same default types they take when written * (`number` is an `i32`, `bigint` a `u256`, `string` a `str`); anything else names its type. * * @throws {InvalidAttributeNameError} If the name is outside the attribute name grammar. * @throws {InvalidPredicateError} If the name starts with `$` but is not a queryable system * attribute, a system attribute is compared against the wrong type, or the value is `bytes`. * * @example * eq("status", str("open")) // status = str('open') * eq("flagged", true) // flagged = true * eq("$owner", addr(account)) // $owner = addr(0xd8dA…) */ export function eq<N extends string, V extends PredicateValue>( name: N, value: V, ): ComparisonNode<ValueRef<N, V>> { return comparison(name, "=", value) } /** * Attribute is greater than value, in index order — see {@link ComparisonOperator}. * * @throws {InvalidAttributeNameError} If the name is outside the attribute name grammar. * @throws {InvalidPredicateError} If the name starts with `$` but is not a queryable system * attribute, a system attribute is compared against the wrong type, or the value is `bytes`. * * @example * gt("level", i32(10)) // level > i32(10) * gt("$expiresAt", u64(1_200_000n)) // $expiresAt > u64(1200000) */ export function gt<N extends string, V extends PredicateValue>( name: N, value: V, ): ComparisonNode<ValueRef<N, V>> { return comparison(name, ">", value) } /** * Attribute is greater than or equal to value, in index order — see {@link ComparisonOperator}. * * @throws {InvalidAttributeNameError} If the name is outside the attribute name grammar. * @throws {InvalidPredicateError} If the name starts with `$` but is not a queryable system * attribute, a system attribute is compared against the wrong type, or the value is `bytes`. * * @example * gte("score", dec("3.5")) // score >= dec(3.5) */ export function gte<N extends string, V extends PredicateValue>( name: N, value: V, ): ComparisonNode<ValueRef<N, V>> { return comparison(name, ">=", value) } /** * Attribute is less than value, in index order — see {@link ComparisonOperator}. * * @throws {InvalidAttributeNameError} If the name is outside the attribute name grammar. * @throws {InvalidPredicateError} If the name starts with `$` but is not a queryable system * attribute, a system attribute is compared against the wrong type, or the value is `bytes`. * * @example * lt("level", i32(10)) // level < i32(10) */ export function lt<N extends string, V extends PredicateValue>( name: N, value: V, ): ComparisonNode<ValueRef<N, V>> { return comparison(name, "<", value) } /** * Attribute is less than or equal to value, in index order — see {@link ComparisonOperator}. * * @throws {InvalidAttributeNameError} If the name is outside the attribute name grammar. * @throws {InvalidPredicateError} If the name starts with `$` but is not a queryable system * attribute, a system attribute is compared against the wrong type, or the value is `bytes`. * * @example * lte("score", dec("5")) // score <= dec(5) */ export function lte<N extends string, V extends PredicateValue>( name: N, value: V, ): ComparisonNode<ValueRef<N, V>> { return comparison(name, "<=", value) } /** * Attribute lies between two values, **inclusive** at both ends: it matches exactly what * `and(gte(name, low), lte(name, high))` matches, in one predicate. Both bounds must have one type, * and a `low` above `high` matches nothing. * * @throws {InvalidAttributeNameError} If the name is outside the attribute name grammar. * @throws {InvalidPredicateError} If the bounds have different types, the name starts with `$` but * is not a queryable system attribute, a system attribute is compared against the wrong type, or a * bound is `bytes`. * * @example * between("score", dec("3.5"), dec("5")) // score BETWEEN dec(3.5) AND dec(5) * between("$expiresAt", u64(100n), u64(200n)) */ export function between<N extends string, L extends PredicateValue, H extends PredicateValue>( name: N, low: L, high: H & SameTypeCheck<L, H>, ): BetweenNode<ValueRef<N, L>> { const system = resolveReference(name) const lowValue = predicateValue(name, system, low) const highValue = predicateValue(name, system, high) if (lowValue.type !== highValue.type) { throw new InvalidPredicateError( `BETWEEN on "${name}" has a ${lowValue.type} and a ${highValue.type} bound. Both bounds ` + "must have one type, the type the attribute is compared as.", name, ) } return node({ kind: "between", name, low: lowValue, high: highValue }) } /** * Attribute is a `str` beginning with this prefix. * * The match is on **raw UTF-8 bytes** with no normalization — it is the index encoding, so * `startsWith("name", "é")` matches only the same byte sequence, not another normal form of it. * * @param prefix - The prefix, as a bare string or a `str` value. * @throws {InvalidAttributeNameError} If the name is outside the attribute name grammar. * @throws {InvalidPredicateError} If the name starts with `$` but is not a queryable system * attribute. * @throws {UnsupportedOperatorError} If given a value that is not a `str`, or a system attribute * (none of which is a `str`). * * @example * startsWith("desc", "ab") // desc STARTSWITH str('ab') */ export function startsWith<N extends string>( name: N, prefix: string | StrValue, ): StartsWithNode<{ readonly name: N; readonly type: "str" }> { const system = resolveReference(name) const value = typeof prefix === "string" ? str(prefix) : toValue(prefix, name) if (value.type !== "str") { throw new UnsupportedOperatorError( name, "STARTSWITH", value.type, "Only str carries a prefix index.", ) } if (system !== undefined) { throw new UnsupportedOperatorError( name, "STARTSWITH", system, "No queryable system attribute is a str.", ) } return node({ kind: "startsWith", name, value }) } /** * The attribute is set, with any type. * * @throws {InvalidAttributeNameError} If the name is outside the attribute name grammar. * @throws {InvalidPredicateError} If given a system attribute — every entity has all of them, so * the test would be a constant — or a `$` name that is not one. * * @example * exists("reviewedBy") // EXISTS(reviewedBy) */ export function exists<N extends string>( name: N, ): ExistsNode<{ readonly name: N; readonly type: never }> { if (resolveReference(name) !== undefined) { throw new InvalidPredicateError( `"${name}" is a system attribute, so it is set on every entity and EXISTS(${name}) is ` + "always true.", name, ) } return node({ kind: "exists", name }) } /** * The attribute is set with exactly this type, whatever its value. * * Useful when the same name is written with different types across entities — a comparison already * asserts the type, so this is for asking about the type alone. * * @param tag - The type tag: `bool`, `i32`, `u64`, `u256`, `dec`, `str`, `addr` or `key` — any * type but `bytes`, which is never indexed. * @throws {InvalidAttributeNameError} If the name is outside the attribute name grammar. * @throws {InvalidPredicateError} If the tag is not an indexable type, the name is a system * attribute (whose type is fixed), or the name starts with `$` but is not a system attribute. * * @example * hasType("age", "i32") // TYPEOF(age) = i32 */ export function hasType<N extends string, T extends IndexableTypeTag>( name: N, tag: T, ): TypeOfNode<{ readonly name: N; readonly type: T }> { const system = resolveReference(name) if (system !== undefined) { throw new InvalidPredicateError( `"${name}" is always a ${system} — a system attribute has a fixed type, so ` + `TYPEOF(${name}) is a constant.`, name, ) } if (!isIndexableTag(tag)) { throw new InvalidPredicateError( `"${tag}" is not an indexable attribute type. Use one of ${INDEXABLE_TAGS}.`, name, ) } return node({ kind: "typeOf", name, tag }) } /** * All of these must hold. * * A single expression is returned unwrapped, so building a conjunction from a filtered list does * not litter the query with redundant nesting. * * @param expressions - The expressions, as separate arguments or one array. * @throws {InvalidPredicateError} If given nothing to combine. * * @example * and(gte("level", i32(10)), eq("status", "open")) * and(filters) // an array works too */ export function and<const E extends readonly Expression[]>( expressions: E, ): Expression<RefsOf<E[number]>> export function and<const E extends readonly Expression[]>( ...expressions: E ): Expression<RefsOf<E[number]>> export function and(...expressions: (Expression | readonly Expression[])[]): Expression { return combine("and", expressions.flat()) } /** * At least one of these must hold. * * A single expression is returned unwrapped. * * @param expressions - The expressions, as separate arguments or one array. * @throws {InvalidPredicateError} If given nothing to combine. * * @example * or(eq("status", "open"), exists("reviewedBy")) */ export function or<const E extends readonly Expression[]>( expressions: E, ): Expression<RefsOf<E[number]>> export function or<const E extends readonly Expression[]>( ...expressions: E ): Expression<RefsOf<E[number]>> export function or(...expressions: (Expression | readonly Expression[])[]): Expression { return combine("or", expressions.flat()) } function combine(kind: "and" | "or", expressions: Expression[]): Expression { if (expressions.length === 0) { throw new InvalidPredicateError( `${kind}() needs at least one expression. An empty ${kind === "and" ? "conjunction" : "disjunction"} ` + `has no query-language spelling — leave the ${kind}() out. A query still needs at least one ` + "filter, so to match broadly narrow by owner or creator, or read through a named schema, " + "whose tag counts as one.", ) } // A one-element conjunction *is* its element; keeping the wrapper would only add parentheses. const only = expressions[0] if (expressions.length === 1 && only !== undefined) return only return node( kind === "and" ? { kind: "and", expressions: Object.freeze(expressions) } : { kind: "or", expressions: Object.freeze(expressions) }, ) } /** * Checks every attribute an expression names against a schema — the runtime half of the check * {@link SchemaRef} makes at compile time, for the callers the type system does not reach. * * @throws {InvalidPredicateError} If a predicate names something that is not a column of the * schema, a column that is not indexed, or compares a column against a value of another type. */ export function checkAgainstSchema(expression: Expression, schema: Schema): void { switch (expression.kind) { case "and": case "or": for (const sub of expression.expressions) checkAgainstSchema(sub, schema) return } const { name } = expression // System attributes were checked against their fixed types when the predicate was built. if (name.startsWith("$")) return const column = sortableColumn(schema, name, "filter") if (!column.isIndexed) { throw new InvalidPredicateError( `"${name}" is not indexed, and only indexed attributes can be filtered on. Declare it ` + `${column.type}().indexed() — entities written before that are indexed by their next ` + "patch of it — or select it and filter the results in JavaScript.", name, ) } const columnType = column.type const asserted = expression.kind === "comparison" || expression.kind === "startsWith" ? expression.value.type : expression.kind === "between" ? expression.low.type : expression.kind === "typeOf" ? expression.tag : undefined if (asserted !== undefined && asserted !== columnType) { throw new InvalidPredicateError( `"${name}" is a ${columnType} column, but the predicate asserts a ${asserted}, so it could ` + `never match. Write ${columnType}(...) around the value.`, name, ) } } /** * The column a filter or sort through a schema names, which must be of any type but bytes. A sort * reads it whether or not it is indexed; a filter also needs it indexed, which the caller checks. * * @param use - What the column is for, `"filter"` or `"sort"`, which the error messages name. * @throws {InvalidPredicateError} If the name is not a column of the schema, or names a bytes one. */ export function sortableColumn( schema: Schema, name: string, use: "filter" | "sort", ): { readonly type: IndexableTypeTag; readonly isIndexed: boolean } { const column = lookupColumn(schema, name) if (column === undefined) { const consequence = use === "filter" ? "a predicate on it can only match entities written some other way" : "it cannot be sorted on" throw new InvalidPredicateError( `"${name}" is not a column of the schema, so ${consequence}. Its columns are ` + `${describeColumns(schema)}.`, name, ) } if (column.type === "bytes") { // A hash of the bytes can be filtered on for equality, but sorting by one orders nothing useful. throw new InvalidPredicateError( use === "filter" ? `"${name}" is a bytes column, and bytes are stored but never indexed, so it cannot be ` + "filtered on. Index a hash of it, as a hex str, instead, or select it and filter the " + "results in JavaScript." : `"${name}" is a bytes column, and bytes cannot be sorted on. Select it and sort the ` + "results in JavaScript.", name, ) } return { type: column.type, isIndexed: column.isIndexed } } /** * Binding strength, tightest last — `AND` binds tighter than `OR`. A leaf never needs parentheses, * so it sits above them both. */ const PRECEDENCE = { or: 1, and: 2, leaf: 3 } as const function precedenceOf(expression: Expression): number { switch (expression.kind) { case "or": return PRECEDENCE.or case "and": return PRECEDENCE.and default: return PRECEDENCE.leaf } } /** Renders a subexpression, parenthesising it only where the precedence would otherwise change it. */ function nested(expression: Expression, minimum: number): string { const rendered = render(expression) return precedenceOf(expression) < minimum ? `(${rendered})` : rendered } /** * Renders an expression as the query string the node parses. Equivalent to `String(expression)`. * * Literals are always written tagged, even where the language would infer them: an untagged number * defaults to `i32`, but spelling it `i32(10)` keeps the exact-type assertion visible in the query * that actually went over the wire. */ export function render(expression: Expression): string { switch (expression.kind) { case "comparison": return `${expression.name} ${expression.operator} ${renderLiteral(expression.value)}` case "between": // A leaf, never parenthesised: the AND inside BETWEEN belongs to it, so // `a BETWEEN 1 AND 5 AND b = 2` already reads as `(a BETWEEN 1 AND 5) AND b = 2`. return `${expression.name} BETWEEN ${renderLiteral(expression.low)} AND ${renderLiteral(expression.high)}` case "startsWith": return `${expression.name} STARTSWITH ${renderLiteral(expression.value)}` case "exists": return `EXISTS(${expression.name})` case "typeOf": return `TYPEOF(${expression.name}) = ${expression.tag}` case "and": return expression.expressions.map((sub) => nested(sub, PRECEDENCE.and)).join(" AND ") case "or": return expression.expressions.map((sub) => nested(sub, PRECEDENCE.or)).join(" OR ") } } /** Renders a typed value as its query literal. `bool` is the one type written bare. */ function renderLiteral(value: IndexableValue): string { switch (value.type) { case "bool": return value.value ? "true" : "false" case "i32": return `i32(${value.value})` case "u64": return `u64(${value.value})` case "u256": return `u256(${value.value})` case "dec": return `dec(${value.value})` case "key": return `key(${value.value})` case "addr": return `addr(${value.value})` case "str": // Single-quoted, with '' as the escape for a quote — the language has no backslash escapes. return `str('${value.value.replaceAll("'", "''")}')` } }