UNPKG

@arkiv-network/sdk

Version:

TypeScript client library for Arkiv's blockchains interactions

213 lines (198 loc) • 8.66 kB
import { type AbiAttribute, encodeAbiAttribute, encodeTombstone } from "./codec" import type { AnyColumn } from "./column" import { ConflictingMutationError, EntityTooLargeError, SchemaDefinitionError, SchemaMismatchError, } from "./errors" import { describeColumns, isSchema, lookupColumn, SCHEMA_ATTRIBUTE, type Schema } from "./schema" import { type ArkivValue, INDEXED_FLAG, TYPE_IDS, type TypeTag } from "./types" import { toValue, type ValueInput } from "./values" /** * The most data the `bytes` columns of one create or patch may hold together, in bytes. Each * `bytes` value is also capped on its own, at `MAX_BYTES_LENGTH`. * * Provisional: the protocol has not settled a per-entity cap yet, so this keeps the budget the * single payload had — and an operation much larger than it would not fit in a transaction the * node accepts anyway. */ export const MAX_ENTITY_BYTES = 128 * 1024 /** * An entity's attributes as a whole — what the `$attributes` selection returns — keyed by name, * every value typed. * * The vocabulary is the same in both directions, so a value read from here drops straight back into * a write — as long as its name is a column of the schema the write goes through. */ export type Attributes = Readonly<Record<string, ArkivValue>> /** * The names, types and indexing of an entity's attributes, with no values — what the * `$attributeSchema` selection returns. `indexed` is `undefined` when the node did not say. * * It is for finding out what entities hold before fetching their values. Values can be large — a * `bytes` attribute holds up to 128 KiB — and an RPC endpoint may cap how big a response it sends, * so a page of 100 entities with every attribute can be more than it will return. The schema is a * few bytes per attribute: select it for a page, then fetch the attributes you need by name, or the * large ones a few entities at a time. * * It also tells apart two entities that use the same name with different types, and shows which * attributes are indexed, and so can be filtered on. */ export type AttributeSchema = Readonly< Record<string, { type: TypeTag; indexed: boolean | undefined }> > /** * Encodes a create's values into the ABI attribute array it carries: every column the * values name, plus the default of every defaulted column they leave out, each flagged indexed or * not as its column says — and, for a named schema, the indexed tag that marks the entity as its. * * @throws {SchemaDefinitionError} If `schema` is not a schema built with `defineSchema`. * @throws {TypeError} If `values` is not an object. * @throws {SchemaMismatchError} If a value names a column the schema lacks, a required column is * left out, or a value's type is not its column's. * @throws {MissingValueError} If a value is `null`. * @throws {UntypedValueError} If a value is neither a tagged value nor a bare form. * @throws {InvalidValueError} If a value does not fit its type. * @throws {EntityTooLargeError} If the `bytes` values add up to more than {@link MAX_ENTITY_BYTES}. */ export function encodeCreate( schema: Schema, values: Readonly<Record<string, unknown>>, ): AbiAttribute[] { assertSchema(schema) for (const name of keysOf(values, "create")) columnFor(schema, name, "create") const attributes: AbiAttribute[] = [] if (schema.tag !== undefined) { attributes.push(encodeAbiAttribute(SCHEMA_ATTRIBUTE, schema.tag, true)) } for (const [name, column] of Object.entries(schema.columns)) { const input = Object.hasOwn(values, name) ? values[name] : undefined if (input === undefined) { if (column.defaultValue !== undefined) { attributes.push(encodeAbiAttribute(name, column.defaultValue, column.isIndexed)) } else if (!column.isOptional) { throw new SchemaMismatchError( name, `the create leaves out a required ${column.type} column. Give it a value, or declare ` + `it ${column.type}().optional() or with a .default(...)`, ) } continue } attributes.push(encodeColumn(name, column, input)) } assertBytesFit(attributes) return attributes.sort(byName) } /** * Encodes a patch into the ABI attribute array it carries: a write for each column in `set`, with * the indexed flag its column declares, and a tombstone for each name in `unset`. A column `set` * to `undefined` is left out, as it is from a create. * * Writing a column with a different indexed flag than the entity holds flips its indexing, so a * schema that marks a column `.indexed()` indexes it from the next patch that writes it. * * @throws {SchemaDefinitionError} If `schema` is not a schema built with `defineSchema`. * @throws {SchemaMismatchError} If a name is not a column, a value's type is not its column's, or * `unset` names a required column. * @throws {ConflictingMutationError} If a name appears in both `set` and `unset`. * @throws {MissingValueError} If a value in `set` is `null`. * @throws {UntypedValueError} If a value in `set` is neither a tagged value nor a bare form. * @throws {InvalidValueError} If a value does not fit its type. * @throws {EntityTooLargeError} If the `bytes` values in `set` add up to more than * {@link MAX_ENTITY_BYTES}. * @throws {TypeError} If `set` is not an object, or `unset` is not an array. */ export function encodePatch( schema: Schema, { set = {}, unset = [], }: { set?: Readonly<Record<string, unknown>> | undefined unset?: readonly string[] | undefined }, ): AbiAttribute[] { assertSchema(schema) const mutations: AbiAttribute[] = [] for (const name of keysOf(set, "patch")) { const column = columnFor(schema, name, "patch") const input = set[name] if (input !== undefined) mutations.push(encodeColumn(name, column, input)) } if (!Array.isArray(unset)) { throw new TypeError(`"unset" must be an array of column names`) } for (const name of new Set(unset)) { const column = columnFor(schema, name, "patch") if (!column.isOptional) { throw new SchemaMismatchError( name, "a required column, so unsetting it would leave the entity without a value its schema " + `requires. Declare it ${column.type}().optional() to allow that`, ) } if (Object.hasOwn(set, name) && set[name] !== undefined) { throw new ConflictingMutationError(name) } mutations.push(encodeTombstone(name)) } assertBytesFit(mutations) return mutations.sort(byName) } function assertSchema(schema: Schema): void { if (!isSchema(schema)) { throw new SchemaDefinitionError( "expected a schema built with defineSchema(...) — writes are typed by the schema they name", ) } } function keysOf( values: Readonly<Record<string, unknown>>, operation: "create" | "patch", ): string[] { if (typeof values !== "object" || values === null) { throw new TypeError(`The ${operation} values must be an object keyed by column name`) } return Object.keys(values) } function columnFor(schema: Schema, name: string, operation: "create" | "patch"): AnyColumn { const column = lookupColumn(schema, name) if (column === undefined) { throw new SchemaMismatchError( name, `the ${operation} names a column this schema does not have. Its columns are ` + describeColumns(schema), ) } return column } /** One column's value, resolved, checked against the column's type, and encoded. */ function encodeColumn(name: string, column: AnyColumn, input: unknown): AbiAttribute { const value = toValue(input as ValueInput, name) if (value.type !== column.type) { throw new SchemaMismatchError( name, `a ${column.type} column, but the value is a ${value.type}. Write ${column.type}(...) ` + "around the value", ) } return encodeAbiAttribute(name, value, column.isIndexed) } /** Throws if the `bytes` values among `attributes` add up to more than {@link MAX_ENTITY_BYTES}. */ function assertBytesFit(attributes: readonly AbiAttribute[]): void { let total = 0 for (const { typeId, value } of attributes) { if ((typeId & ~INDEXED_FLAG) === TYPE_IDS.bytes) total += (value.length - 2) / 2 } if (total > MAX_ENTITY_BYTES) throw new EntityTooLargeError(total, MAX_ENTITY_BYTES) } /** * The engine requires attributes **strictly ascending by their `bytes32` name**: that ordering is * what the canonical serialization hashes into the state root, and it doubles as the uniqueness * check. */ function byName(a: AbiAttribute, b: AbiAttribute): number { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0 }