@arkiv-network/sdk
Version:
TypeScript client library for Arkiv's blockchains interactions
251 lines (228 loc) • 10.6 kB
text/typescript
import { type AnyColumn, isColumn } from "./column"
import { InvalidValueError, SchemaDefinitionError, TooManyAttributesError } from "./errors"
import { validateAttributeName } from "./names"
import { type ArkivValue, isArkivValue, type StrValue, type TypeTag, type ValueOf } from "./types"
import { type BareOf, str } from "./values"
/**
* The most attributes one entity operation may carry, and so the most columns a schema may have —
* one fewer for a named schema, whose tag is one more attribute on every create.
*/
export const MAX_ATTRIBUTES = 32
/**
* The attribute a named schema tags its entities with: an indexed `str` holding the schema's name.
* Creates write it, and `.from(schema)` filters on it, so a query through a schema only ever sees
* entities written through it.
*/
export const SCHEMA_ATTRIBUTE = "arkiv.schema"
/**
* Marks a schema. A registry symbol rather than `instanceof`, so a schema defined through one copy
* of the SDK — its CJS build, say — is still recognised by another, such as the ESM build an app
* loaded.
*/
const SCHEMA_BRAND = Symbol.for("@arkiv-network/sdk/schema")
/** A schema's columns, keyed by attribute name. */
export type Columns = { readonly [name: string]: AnyColumn }
/**
* A schema: the named, typed attributes an entity of one kind carries, and which of them the node
* indexes. Built with {@link defineSchema}.
*
* A schema is an SDK-side convention. The node sees only attributes, each with a type and an
* indexed flag; the schema is what types the SDK's writes and reads, and checks them at runtime.
*
* A named schema also tags every entity it creates with its name, in the indexed
* {@link SCHEMA_ATTRIBUTE} attribute, so that a query through it sees only its own entities.
*
* @typeParam C - The columns, keyed by attribute name.
*/
export class Schema<C extends Columns = Columns> {
/** The name entities are tagged with, or `undefined` for an untagged schema. */
readonly name: string | undefined
/** The columns, keyed by attribute name, in the order they were declared. */
readonly columns: C
/** The tag every create writes and every query filters on, if the schema is named. */
readonly tag: StrValue | undefined
/** Use {@link defineSchema}. */
constructor(name: string | undefined, columns: C) {
this.tag = name === undefined ? undefined : validateName(name)
validateColumns(columns, name !== undefined)
this.name = name
this.columns = Object.freeze({ ...columns })
Object.defineProperty(this, SCHEMA_BRAND, { value: true })
Object.freeze(this)
}
}
/**
* Declares a schema: the attributes an entity carries, their types, and which of them are indexed.
*
* Each column is a type constructor called with no arguments — `str()`, `dec()`, `bytes()` —
* refined with `.indexed()`, `.optional()` or `.default(...)`. Only indexed columns can be
* filtered on; the rest are stored and selectable, and do not pay for an index.
*
* Give the schema a name to tag its entities: every create then also writes an indexed
* `arkiv.schema` attribute holding the name, and `.from(schema)` filters on it — the way a
* collection keeps one model's documents apart from another's. Without a name, a query through the
* schema sees every entity its `where` matches, whatever wrote it — though the columns it selects
* are still checked against the schema, so an entity that does not fit is left out of the page, or
* fails the query with `{ onMismatch: "throw" }`.
*
* @param name - The name entities are tagged with — a `str`, so at most 128 bytes. Optional.
* @param columns - The columns, keyed by attribute name.
* @throws {InvalidAttributeNameError} If a name violates the attribute-name grammar.
* @throws {SchemaDefinitionError} If the name is not a valid `str`, an entry is not a column, or a
* column is named after the schema tag.
* @throws {TooManyAttributesError} If there are more columns than one create can carry — at most
* {@link MAX_ATTRIBUTES}, or one fewer for a named schema, whose tag counts as an attribute.
*
* @example
* import { addr, bytes, dec, defineSchema, str } from "@arkiv-network/sdk/schema"
*
* const Posts = defineSchema("posts", {
* author: addr().indexed(),
* category: str().indexed(),
* score: dec().indexed(),
* title: str(), // stored, not indexed
* body: bytes(), // a blob; bytes can never be indexed
* thumbnail: bytes().optional(), // bytes columns share one MAX_ENTITY_BYTES budget
* mimeType: str().default("text/markdown"), // written on create when left out
* })
*/
export function defineSchema<const C extends Columns>(name: string, columns: C): Schema<C>
export function defineSchema<const C extends Columns>(columns: C): Schema<C>
export function defineSchema<const C extends Columns>(
...args: [name: string, columns: C] | [columns: C]
): Schema<C> {
return args.length === 2 ? new Schema(args[0], args[1]) : new Schema(undefined, args[0])
}
function validateName(name: string): StrValue {
if (typeof name !== "string" || name.length === 0) {
throw new SchemaDefinitionError(
"a schema name must be a non-empty string — leave it out for an untagged schema",
)
}
try {
return str(name)
} catch (error) {
if (!(error instanceof InvalidValueError)) throw error
throw new SchemaDefinitionError(`the name is not a valid str tag. ${error.message}`)
}
}
function validateColumns(columns: Columns, tagged: boolean): void {
if (typeof columns !== "object" || columns === null || Array.isArray(columns)) {
throw new SchemaDefinitionError("defineSchema takes an object of columns keyed by name")
}
const entries = Object.entries(columns)
// The tag is one more attribute on every create, so it counts against the same cap.
const attributes = entries.length + (tagged ? 1 : 0)
if (attributes > MAX_ATTRIBUTES) {
throw new TooManyAttributesError(attributes, MAX_ATTRIBUTES, tagged)
}
for (const [name, column] of entries) {
validateAttributeName(name)
if (name === SCHEMA_ATTRIBUTE) {
throw new SchemaDefinitionError(
`"${name}" is the attribute a named schema tags its entities with, so a column cannot ` +
"use it. Pick another name",
name,
)
}
if (!isColumn(column)) throw notAColumn(name, column)
}
}
/** Whether `input` is a schema built with {@link defineSchema}. */
export function isSchema(input: unknown): input is Schema {
return typeof input === "object" && input !== null && SCHEMA_BRAND in input
}
/**
* The column `name` of `schema`, or `undefined` if it has none. Only the schema's own columns
* count, so an inherited key such as `toString` or `__proto__` is not mistaken for one.
*/
export function lookupColumn(schema: Schema, name: string): AnyColumn | undefined {
return Object.hasOwn(schema.columns, name) ? schema.columns[name] : undefined
}
/** The schema's column names, for an error message that has to say what would have worked. */
export function describeColumns(schema: Schema): string {
return Object.keys(schema.columns).join(", ") || "(none)"
}
function notAColumn(name: string, input: unknown): SchemaDefinitionError {
if (isArkivValue(input)) {
return new SchemaDefinitionError(
`got a ${input.type} value where a column belongs. Did you mean ` +
`${input.type}().default(${defaultArgument(input)})? Called with a value, ${input.type}(...) ` +
`makes a value; called with none, ${input.type}() declares a column`,
name,
)
}
return new SchemaDefinitionError(
"not a column. Declare it with a type constructor called with no arguments, e.g. str() or " +
"dec().indexed()",
name,
)
}
/** A value spelled as the argument its constructor would take, for an error message. */
function defaultArgument(value: ArkivValue): string {
switch (value.type) {
case "bool":
case "i32":
return String(value.value)
case "u64":
case "u256":
return `${value.value}n`
default:
return JSON.stringify(value.value)
}
}
/** The columns of a schema. */
export type ColumnsOf<S extends Schema> = S["columns"]
/**
* What a write accepts for a column of type `T`: a value of that type, or the bare form that
* defaults to it — a `string` for `str`, a `Uint8Array` for `bytes`, a `number` for `i32`, a
* `bigint` for `u256`, a `boolean` for `bool`. Every other type is written tagged.
*/
export type ColumnInput<T extends TypeTag> = ValueOf<T> | BareOf<T>
/** The columns a create must provide: neither optional nor defaulted. */
type RequiredColumnName<C extends Columns> = {
[K in keyof C]: C[K] extends { readonly isOptional: false; readonly defaultValue: undefined }
? K
: never
}[keyof C]
/** The columns an entity may leave unset — which is to say, the ones a patch may unset. */
export type OptionalColumnName<S extends Schema> = {
[K in keyof ColumnsOf<S>]: true extends ColumnsOf<S>[K]["isOptional"] ? K : never
}[keyof ColumnsOf<S>] &
string
/** The indexed columns, which are the only ones `where` can filter on. */
export type IndexedColumnName<S extends Schema> = {
[K in keyof ColumnsOf<S>]: true extends ColumnsOf<S>[K]["isIndexed"] ? K : never
}[keyof ColumnsOf<S>] &
string
/**
* The values a create takes for schema `S`: every required column, plus any optional or
* defaulted one. A defaulted column left out is written with its default.
*
* @example
* const values: CreateEntityValues<typeof Posts> = {
* author: addr("0x…"),
* category: "docs", // bare string -> str
* score: dec("4.1"),
* title: "Cells, not entities",
* body: new Uint8Array(), // bare Uint8Array -> bytes
* }
*/
export type CreateEntityValues<S extends Schema> = Prettify<
{
readonly [K in RequiredColumnName<ColumnsOf<S>>]: ColumnInput<ColumnsOf<S>[K]["type"]>
} & {
readonly [K in Exclude<keyof ColumnsOf<S>, RequiredColumnName<ColumnsOf<S>>>]?:
| ColumnInput<ColumnsOf<S>[K]["type"]>
| undefined
}
>
/**
* What a patch writes for schema `S`: any subset of its columns. Defaults do not apply — a patch
* changes only what it names — and a column given as `undefined` is left alone, as a create
* leaves it out.
*/
export type PatchEntityValues<S extends Schema> = {
readonly [K in keyof ColumnsOf<S>]?: ColumnInput<ColumnsOf<S>[K]["type"]> | undefined
}
type Prettify<T> = { [K in keyof T]: T[K] } & {}