@arkiv-network/sdk
Version:
TypeScript client library for Arkiv's blockchains interactions
663 lines (636 loc) • 24.3 kB
text/typescript
import {
type Address,
checksumAddress,
type Hex,
isAddress,
isHex,
stringToBytes,
toHex,
} from "viem"
import { type AnyColumn, type Column, createColumn, isColumn } from "./column"
import { DECIMAL_UNIT, decimalToUnits, unitsToDecimal } from "./decimal"
import { InvalidValueError, MissingValueError, UntypedValueError } from "./errors"
import {
type AddrValue,
type ArkivValue,
type BoolValue,
type BytesValue,
type DecValue,
type I32Value,
isArkivValue,
type KeyValue,
makeValue,
type StrValue,
type TypeTag,
type U64Value,
type U256Value,
} from "./types"
/** The widest `i32`. */
export const I32_MAX = 2147483647
/** The narrowest `i32`. */
export const I32_MIN = -2147483648
/** The widest `u64`. */
export const U64_MAX = 2n ** 64n - 1n
/** The widest `u256`. */
export const U256_MAX = 2n ** 256n - 1n
/**
* The longest `str`, in UTF-8 bytes
*/
export const MAX_STRING_BYTES = 128
/**
* The longest `bytes` value, in bytes. The cap applies to each `bytes` attribute separately, but
* all the `bytes` values of one create or patch also share the `MAX_ENTITY_BYTES` budget.
*/
export const MAX_BYTES_LENGTH = 128 * 1024
/**
* Finds the first lone UTF-16 surrogate — a string the node would reject, since it has no UTF-8
* encoding. `TextEncoder` would quietly replace it with U+FFFD instead.
*/
function findLoneSurrogate(value: string): number | undefined {
for (let index = 0; index < value.length; index++) {
const code = value.charCodeAt(index)
if (code < 0xd800 || code > 0xdfff) continue
const next = value.charCodeAt(index + 1)
if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) {
index++
continue
}
return index
}
return undefined
}
/** A `bool` column, indexed for equality once `.indexed()`. */
export function bool(): Column<"bool", false, false, false>
/**
* A boolean.
*
* This is the default type for a bare boolean, so `{ flagged: true }` and
* `{ flagged: bool(true) }` are the same value.
*
* @example
* bool(true) // { type: "bool", value: true }
*/
export function bool(value: boolean): BoolValue
export function bool(...args: [] | [boolean]): BoolValue | Column<"bool", false, false, false> {
if (args.length === 0) return createColumn("bool", toTypedValue)
const [value] = args
if (typeof value !== "boolean") {
throw new InvalidValueError("bool", value, "not a boolean")
}
return makeValue("bool", value)
}
/** An `i32` column, indexed for equality and range queries once `.indexed()`. */
export function i32(): Column<"i32", false, false, false>
/**
* A 32-bit signed integer. This is the default type for a bare number, so `{ level: 10 }` and
* `{ level: i32(10) }` are the same value.
*
* @param value - An integer in [-2147483648, 2147483647].
* @throws {InvalidValueError} If the value is not an integer, or does not fit an `i32`.
*
* @example
* i32(10) // { type: "i32", value: 10 }
* i32(-42) // negative values are fine — i32 is the signed integer
*/
export function i32(value: number | bigint): I32Value
export function i32(
...args: [] | [number | bigint]
): I32Value | Column<"i32", false, false, false> {
if (args.length === 0) return createColumn("i32", toTypedValue)
const [value] = args
if (typeof value === "bigint") {
if (value > BigInt(I32_MAX) || value < BigInt(I32_MIN)) {
throw new InvalidValueError("i32", value, outOfI32Range(value >= 0n))
}
return makeValue("i32", Number(value))
}
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new InvalidValueError("i32", value, "not a number")
}
if (!Number.isInteger(value)) {
throw new InvalidValueError(
"i32",
value,
"not an integer",
`Use dec(${JSON.stringify(String(value))}) for a fixed-point decimal.`,
)
}
if (value > I32_MAX || value < I32_MIN) {
throw new InvalidValueError("i32", value, outOfI32Range(value >= 0))
}
return makeValue("i32", value)
}
function outOfI32Range(nonNegative: boolean): string {
const range = `outside the i32 range [${I32_MIN}, ${I32_MAX}]`
return nonNegative
? `${range} — use u64(...) for large counters, timestamps and block heights (Date.now() does ` +
`not fit an i32), or u256(...) beyond 2**64 - 1`
: `${range} — use dec(...) for values that need more range than an i32`
}
/** A `u256` column, indexed for equality and range queries once `.indexed()`. */
export function u256(): Column<"u256", false, false, false>
/**
* A 256-bit unsigned integer. The type for token amounts and anything else that can outgrow 2⁶⁴ —
* reach for {@link u64} first for counters, timestamps and block heights.
*
* @param value - A non-negative integer, as a `bigint`, a safe `number`, or a decimal or `0x` hex
* string.
* @throws {InvalidValueError} If the value is negative, non-integral, or wider than 256 bits.
*
* @example
* u256(1_000_000n)
* u256(Date.now()) // safe integers are accepted
* u256("0xf4240") // hex, as it comes back over JSON-RPC
*/
export function u256(value: bigint | number | string): U256Value
export function u256(
...args: [] | [bigint | number | string]
): U256Value | Column<"u256", false, false, false> {
if (args.length === 0) return createColumn("u256", toTypedValue)
return makeValue("u256", toUnsigned("u256", args[0], U256_MAX))
}
/** A `u64` column, indexed for equality and range queries once `.indexed()`. */
export function u64(): Column<"u64", false, false, false>
/**
* A 64-bit unsigned integer.
*
* This is the width the protocol counts in: block heights, `$expiresAt` and the entity-nonce are
* all `u64`, so it is the type to reach for when a value has to line up with one of them. Use
* {@link u256} for token amounts and anything else that can exceed 2⁶⁴.
*
* @param value - A non-negative integer, as a `bigint`, a safe `number`, or a decimal or `0x` hex
* string.
* @throws {InvalidValueError} If the value is negative, non-integral, or wider than 64 bits.
*
* @example
* u64(1_200_000n) // a block height
* u64(Date.now()) // safe integers are accepted
* u64("0x124f80") // hex, as it comes back over JSON-RPC
*/
export function u64(value: bigint | number | string): U64Value
export function u64(
...args: [] | [bigint | number | string]
): U64Value | Column<"u64", false, false, false> {
if (args.length === 0) return createColumn("u64", toTypedValue)
return makeValue("u64", toUnsigned("u64", args[0], U64_MAX))
}
/**
* Parses and range-checks an unsigned integer for `tag`, whose widest value is `max`.
*
* Shared by {@link u64} and {@link u256} so the two differ in exactly one thing — their width —
* rather than in how they read a string or how they explain a failure.
*/
function toUnsigned(tag: "u64" | "u256", value: bigint | number | string, max: bigint): bigint {
const parsed = toUnsignedBigint(tag, value)
if (parsed < 0n) {
throw new InvalidValueError(
tag,
value,
`negative — ${tag} is unsigned`,
"Use i32(...) for small signed integers, or dec(...) for a signed fixed-point value.",
)
}
if (parsed > max) {
const bits = tag === "u64" ? 64 : 256
throw new InvalidValueError(
tag,
value,
`wider than ${bits} bits`,
tag === "u64" ? "Use u256(...) for values beyond 2**64 - 1." : undefined,
)
}
return parsed
}
function toUnsignedBigint(tag: "u64" | "u256", value: bigint | number | string): bigint {
if (typeof value === "bigint") return value
if (typeof value === "number") {
if (!Number.isInteger(value)) {
throw new InvalidValueError(
tag,
value,
"not an integer",
`Use dec(${JSON.stringify(String(value))}) for a fixed-point decimal.`,
)
}
if (!Number.isSafeInteger(value)) {
throw new InvalidValueError(
tag,
value,
"beyond the safe integer range, so it may already have lost precision",
"Pass it as a bigint literal (e.g. 12345678901234567890n) or a string instead.",
)
}
return BigInt(value)
}
if (typeof value === "string") {
if (!/^(0x[0-9a-fA-F]+|\d+)$/.test(value)) {
throw new InvalidValueError(tag, value, "not a decimal or 0x-hex integer")
}
return BigInt(value)
}
throw new InvalidValueError(tag, value, "not a bigint, number or string")
}
/** A `dec` column, indexed for equality and range queries once `.indexed()`. */
export function dec(): Column<"dec", false, false, false>
/**
* A signed fixed-point decimal with exactly 18 decimal places.
*
* Pass fractional values as **strings**. A JavaScript number cannot represent most decimals
* exactly, so accepting `dec(0.1 + 0.2)` would quietly store `0.30000000000000004`; requiring the
* string keeps what you write and what is stored the same thing. Excess precision is likewise an
* error rather than a rounding.
*
* @param value - A decimal string, or an integral `number` / `bigint`.
* @throws {InvalidValueError} If the value is malformed, has more than 18 fractional digits, or
* falls outside the `int256` range.
*
* @example
* dec("3.5")
* dec("-0.000000000000000001") // the smallest step
* dec(5) // integers may be passed as a number
*/
export function dec(value: string | number | bigint): DecValue
export function dec(
...args: [] | [string | number | bigint]
): DecValue | Column<"dec", false, false, false> {
if (args.length === 0) return createColumn("dec", toTypedValue)
const [value] = args
if (typeof value === "string") {
return makeValue("dec", unitsToDecimal(decimalToUnits(value)))
}
if (typeof value === "bigint") {
return decFromUnits(value * DECIMAL_UNIT)
}
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new InvalidValueError("dec", value, "not a number")
}
if (!Number.isInteger(value)) {
throw new InvalidValueError(
"dec",
value,
"a fractional number cannot be represented exactly in binary floating point",
`Pass it as a string instead: dec(${JSON.stringify(String(value))}).`,
)
}
return decFromUnits(BigInt(value) * DECIMAL_UNIT)
}
/**
* The exact scaled integer behind a `dec` — the value multiplied by 10 ** 18, which is what the
* protocol stores and compares. Use it when you need exact arithmetic; JavaScript has no native
* decimal type, so the SDK carries the canonical string and leaves the maths to you.
*
* @example
* decUnits(dec("3.5")) // 3500000000000000000n
*/
export function decUnits(value: DecValue): bigint {
return decimalToUnits(value.value)
}
/**
* A `dec` built from its scaled integer units — the inverse of {@link decUnits}.
*
* @example
* decFromUnits(3_500_000_000_000_000_000n) // dec("3.5")
*/
export function decFromUnits(units: bigint): DecValue {
return makeValue("dec", unitsToDecimal(units))
}
/** A `str` column, indexed for equality and prefix (`STARTSWITH`) matching once `.indexed()`. */
export function str(): Column<"str", false, false, false>
/**
* A UTF-8 string of at most 128 bytes.
*
* The limit is on **bytes**, not characters: a string of 128 emoji does not fit. Any character
* is allowed, control characters included. To store raw bytes, use {@link bytes}, or hex in a
* `str` when they must be indexed.
*
* @throws {InvalidValueError} If the value is not a string, holds a lone surrogate (so it has no
* UTF-8 encoding), or exceeds 128 UTF-8 bytes.
*
* @example
* str("Bob")
*/
export function str(value: string): StrValue
export function str(...args: [] | [string]): StrValue | Column<"str", false, false, false> {
if (args.length === 0) return createColumn("str", toTypedValue)
const [value] = args
if (typeof value !== "string") {
throw new InvalidValueError("str", value, "not a string")
}
const surrogate = findLoneSurrogate(value)
if (surrogate !== undefined) {
throw new InvalidValueError(
"str",
value,
`contains a lone surrogate at index ${surrogate}, so it is not valid UTF-8`,
)
}
const bytes = stringToBytes(value).length
if (bytes > MAX_STRING_BYTES) {
throw new InvalidValueError(
"str",
value,
`${bytes} UTF-8 bytes exceeds the ${MAX_STRING_BYTES}-byte limit on a str`,
"Store the long form in a bytes column and keep a short, queryable key here.",
)
}
return makeValue("str", value)
}
/** An `addr` column, indexed for equality once `.indexed()`. */
export function addr(): Column<"addr", false, false, false>
/**
* A 20-byte Ethereum address.
*
* All-lowercase and all-uppercase input carries no checksum and is accepted as-is; mixed-case
* input is checked against its EIP-55 checksum, so a mistyped address is caught here rather than
* being written to a wrong account. The stored value is always EIP-55 checksummed.
*
* @throws {InvalidValueError} If the value is not 20 hex bytes, or fails its EIP-55 checksum.
*
* @example
* addr("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")
*/
export function addr(value: string): AddrValue
export function addr(...args: [] | [string]): AddrValue | Column<"addr", false, false, false> {
if (args.length === 0) return createColumn("addr", toTypedValue)
const [value] = args
if (typeof value !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(value)) {
throw new InvalidValueError("addr", value, "not a 20-byte 0x-prefixed hex address")
}
const body = value.slice(2)
const cased = body !== body.toLowerCase() && body !== body.toUpperCase()
if (cased && !isAddress(value, { strict: true })) {
throw new InvalidValueError(
"addr",
value,
"fails its EIP-55 checksum, so a digit is probably mistyped",
`If those hex digits are right, the correctly checksummed form is ` +
`${checksumAddress(value as Address)}.`,
)
}
return makeValue("addr", checksumAddress(value as Address))
}
/** A `key` column — a reference to another entity — indexed for equality once `.indexed()`. */
export function key(): Column<"key", false, false, false>
/**
* A reference to another entity.
*
* References are **weak**: nothing checks that the target exists when you write it, and a
* reference to a deleted or expired entity is allowed to dangle.
*
* @param value - A 32-byte entity key, as `0x` hex or raw bytes.
* @throws {InvalidValueError} If the value is not exactly 32 bytes.
*
* @example
* key(parentEntityKey)
*/
export function key(value: Hex | Uint8Array): KeyValue
export function key(
...args: [] | [Hex | Uint8Array]
): KeyValue | Column<"key", false, false, false> {
if (args.length === 0) return createColumn("key", toTypedValue)
return makeValue("key", asFixedHex("key", args[0], 32))
}
/**
* A `bytes` column — a blob. Bytes are stored but never indexed, so a bytes column has no
* `.indexed()`: it can be selected, not filtered on. A schema may have several, within the
* `MAX_ATTRIBUTES` cap on columns — but the `bytes` values of one create or patch share a single
* `MAX_ENTITY_BYTES` budget.
*/
export function bytes(): Column<"bytes", false, false, false>
/**
* Opaque bytes of any length up to 128 KiB — a document, an image, a serialized object. This is the
* default type for a bare `Uint8Array`, so `{ body: buf }` and `{ body: bytes(buf) }` are the same
* value.
*
* @param value - The bytes, raw or as `0x` hex. Hex is taken as the bytes it spells, never as text.
* @throws {InvalidValueError} If the value is not bytes or hex, or exceeds 128 KiB.
*
* @example
* bytes(new TextEncoder().encode("# Hello"))
* bytes("0xdeadbeef")
*/
export function bytes(value: Hex | Uint8Array): BytesValue
export function bytes(
...args: [] | [Hex | Uint8Array]
): BytesValue | Column<"bytes", false, false, false> {
if (args.length === 0) return createColumn("bytes", toTypedValue)
const [value] = args
const hex = asHex("bytes", value)
const length = (hex.length - 2) / 2
if (length > MAX_BYTES_LENGTH) {
throw new InvalidValueError(
"bytes",
`<${length} bytes>`,
`${length} bytes exceeds the ${MAX_BYTES_LENGTH}-byte limit on a bytes value`,
"Split it across several entities, or store the bulk elsewhere and keep a reference.",
)
}
return makeValue("bytes", hex)
}
/** Normalizes hex-or-bytes input of a known width into lowercase `0x` hex. */
function asFixedHex(tag: "key", value: Hex | Uint8Array, size: number): Hex {
if (value instanceof Uint8Array) {
if (value.length !== size) {
throw new InvalidValueError(tag, `<${value.length} bytes>`, `not exactly ${size} bytes`)
}
return toHex(value)
}
const hex = asHex(tag, value)
if (hex.length !== 2 + size * 2) {
throw new InvalidValueError(
tag,
value,
`${(hex.length - 2) / 2} bytes, not exactly ${size} bytes`,
)
}
return hex
}
/** Normalizes hex-or-bytes input into lowercase `0x` hex of a whole number of bytes. */
function asHex(tag: "key" | "bytes", value: Hex | Uint8Array): Hex {
if (value instanceof Uint8Array) return toHex(value)
if (typeof value !== "string" || !isHex(value, { strict: true })) {
throw new InvalidValueError(tag, value, "not 0x-prefixed hex")
}
if (value.length % 2 !== 0) {
throw new InvalidValueError(tag, value, "an odd number of hex digits, so not whole bytes")
}
return value.toLowerCase() as Hex
}
/**
* A JavaScript value accepted wherever a typed value is expected: either a tagged value, or one of
* the five bare forms that map unambiguously onto a single type.
*
* | bare form | type |
* | ------------ | ------- |
* | `boolean` | `bool` |
* | `number` | `i32` |
* | `bigint` | `u256` |
* | `string` | `str` |
* | `Uint8Array` | `bytes` |
*
* Anything else — a large number, a decimal, an address, an entity key — must name its type, which
* is what keeps `"0x1234..."` a string rather than silently becoming a key.
*
* These defaults apply identically when writing and when querying, so a value written bare and
* queried bare always agrees with itself. The mismatch to watch for is a bare value where its
* column has another type: a bare `bigint` is a `u256`, not a {@link u64}. Through a schema that is
* caught — a write throws `SchemaMismatchError` and a query throws `InvalidPredicateError` — but a
* query without a schema sends the predicate as written, and the engine, for which a type mismatch
* is not an error, simply matches nothing.
*/
export type ValueInput = ArkivValue | boolean | number | bigint | string | Uint8Array
/**
* The type a {@link ValueInput} resolves to — the tag of a tagged value, or the default of a bare
* form. `never` for anything else.
*/
export type TagOf<input> = input extends { readonly type: infer tag extends TypeTag }
? tag
: input extends boolean
? "bool"
: input extends number
? "i32"
: input extends bigint
? "u256"
: input extends string
? "str"
: input extends Uint8Array
? "bytes"
: never
/**
* The bare JavaScript form that defaults to `tag`, where there is one — `boolean` for `bool`,
* `number` for `i32`, `bigint` for `u256`, `string` for `str`, `Uint8Array` for `bytes`. The other
* types — `u64`, `dec`, `addr` and `key` — must be written tagged.
*/
export type BareOf<tag extends TypeTag> = {
bool: boolean
i32: number
u64: never
u256: bigint
dec: never
bytes: Uint8Array
str: string
addr: never
key: never
}[tag]
/**
* Resolves a {@link ValueInput} to a typed value, applying the bare-form defaults above. Tagged
* values pass through untouched.
*
* @param input - The value to resolve.
* @param attributeName - The attribute this value belongs to, used to make errors name it.
*
* @throws {MissingValueError} If the input is `undefined` or `null` — an attribute is either set
* with a value and a type, or not set at all.
* @throws {UntypedValueError} If the input is not a tagged value or a bare boolean, number, bigint,
* string or `Uint8Array`.
* @throws {InvalidValueError} If a bare value does not fit the type it defaults to.
*
* @example
* toValue(10) // i32(10)
* toValue(10n) // u256(10n)
* toValue("hello") // str("hello")
* toValue(dec("3.5")) // unchanged
*/
export function toValue(input: ValueInput, attributeName?: string): ArkivValue {
// `undefined` and `null` are rejected before the type switch. They are not assignable to
// ValueInput, but they still arrive at runtime — from a spread of an optional-shaped object, or
// from data that never went through the type system — and "cannot infer a type for undefined"
// does not tell you the fix, which is always to leave the attribute out.
if (input === undefined || input === null) {
throw new MissingValueError(input, attributeName)
}
switch (typeof input) {
case "boolean":
return bool(input)
case "number":
return i32(input)
case "bigint":
return u256(input)
case "string":
return str(input)
default: {
if (input instanceof Uint8Array) return bytes(input)
if (isArkivValue(input)) return revalidate(input)
// Widened: a column is not a ValueInput, but it is the one mix-up the dual-mode constructors
// invite — `str()` where `str("...")` was meant — so it is named rather than left untyped.
const candidate: unknown = input
if (isColumn(candidate)) throw columnGivenAsValue(candidate)
throw new UntypedValueError(input)
}
}
}
/**
* Resolves `input` to a value of type `tag` — a tagged value of that type, or whatever `tag`'s
* constructor accepts. What a column's `.default(...)` runs its argument through.
*
* @throws {InvalidValueError} If the input does not make a valid value of type `tag`.
*/
function toTypedValue(tag: TypeTag, input: unknown): ArkivValue {
if (isArkivValue(input)) {
const value = revalidate(input)
if (value.type !== tag) {
throw new InvalidValueError(tag, input, `a ${value.type} value, not a ${tag}`)
}
return value
}
if (isColumn(input)) throw columnGivenAsValue(input)
// Through the constructor, so every type accepts exactly what its constructor does.
const construct = CONSTRUCTORS[tag] as (value: unknown) => ArkivValue
return construct(input)
}
function columnGivenAsValue(column: AnyColumn): InvalidValueError {
return new InvalidValueError(
column.type,
`<${column.type} column>`,
"a column, not a value",
`Pass the value itself, e.g. ${column.type}(...) with an argument — ${column.type}() with ` +
"none declares a schema column.",
)
}
/** Each tag's constructor, called with a value. */
const CONSTRUCTORS: { readonly [tag in TypeTag]: (value: never) => ArkivValue } = {
bool,
i32,
u64,
u256,
dec,
bytes,
str,
addr,
key,
}
/**
* Re-runs a tagged value through its own constructor.
*
* {@link isArkivValue} can only check shape — the `validated` brand is a type-level construct with
* no runtime existence, so a hand-written `{ type: "u256", value: "1000" }` (from `JSON.parse`, a
* config file, an `as any` boundary) is indistinguishable from a real `u256(1000n)`. Passing it
* through unchecked was silent corruption rather than an error: the ABI encoder would take the
* *string* "1000" and write its UTF-8 bytes into the word, storing 2.27e76 on chain.
*
* Running the constructor again is what makes the brand's promise — "every value crossing the wire
* has been range-, length- and format-checked" — true at runtime. For a value that really did come
* from a constructor it is a no-op that returns an equal value.
*/
function revalidate(value: ArkivValue): ArkivValue {
switch (value.type) {
case "bool":
return bool(value.value)
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 "bytes":
return bytes(value.value)
case "str":
return str(value.value)
case "addr":
return addr(value.value)
case "key":
return key(value.value)
}
}