@arkiv-network/sdk
Version:
TypeScript client library for Arkiv's blockchains interactions
470 lines (429 loc) • 18.6 kB
text/typescript
import {
type AnyColumn,
type ArkivValue,
type AttributeSchema,
type Attributes,
type ColumnsOf,
describeColumns,
lookupColumn,
SCHEMA_ATTRIBUTE,
type Schema,
SchemaMismatchError,
type StrValue,
type TypeTag,
type ValueOf,
validateAttributeName,
} from "../schema"
import type { Entity, EntityFields } from "../types/entity"
import type { RpcSelect } from "../types/rpcSchema"
/**
* The entity fields a row can carry. Each is `$`-prefixed, like the system attributes `where`
* filters on: an attribute name cannot start with `$`, so no attribute can collide with one.
*/
export const SYSTEM_FIELDS = [
"$key",
"$owner",
"$creator",
"$createdAt",
"$updatedAt",
"$expiresAt",
"$creationFlags",
] as const
/** The name of an entity field a row carries alongside its attributes. */
export type SystemField = (typeof SYSTEM_FIELDS)[number]
/** The {@link Entity} property a system field reads. */
type EntityField<F extends SystemField> = F extends `$${infer N extends keyof EntityFields}`
? N
: never
/**
* The keys a selection gives a fixed meaning: the entity fields, plus `$attributes` and
* `$attributeSchema`, which read an entity's attributes as a whole. Any key without a `$` names an
* attribute.
*/
export type SelectionFields = { [K in SystemField]?: boolean } & {
/**
* Attribute names, types and indexing, without their values — a cheap way to find out what
* entities hold before fetching values that may be large. See {@link AttributeSchema}.
*/
$attributeSchema?: boolean
/** Every attribute, as a map keyed by name. */
$attributes?: boolean
}
/** The selection keys with a fixed meaning, in the order they are written into the RPC `select`. */
const SELECTION_KEYS = [
...SYSTEM_FIELDS,
"$attributeSchema",
"$attributes",
] as const satisfies readonly (keyof SelectionFields)[]
/** A selection key with a fixed meaning. */
type SelectionKey = (typeof SELECTION_KEYS)[number]
/**
* A selection: entity fields, `$attributes` / `$attributeSchema`, and attributes by name, each set
* to `true` to ask for it. The same selection means the same thing with or without a schema —
* naming one with `.from(...)` only types and checks the selected attributes, and the filter. A
* named schema also restricts the query to entities tagged with its name, and leaves the tag out of
* every row.
*/
export type Selection = Readonly<Record<string, boolean | undefined>>
/** Argument accepted by `select()` — a {@link Selection}, or `"*"` for everything. */
export type SelectArg = Selection | "*"
/** Whether a selection value asks for the field: `true` does, `false` and `undefined` do not. */
export type Selected<V> = V extends false | undefined ? false : true
/**
* What a column reads back as: a value of its type, or `undefined` too if the column is optional.
*/
export type ColumnValue<C> = C extends {
readonly type: infer T extends TypeTag
readonly isOptional: infer O
}
? ValueOf<T> | (true extends O ? undefined : never)
: never
/**
* What a selected key reads back as: an entity field as the entity has it, the attributes as a
* whole, or one attribute — typed by its column through a schema, and any value or none without.
*/
type FieldValue<K, TSchema extends Schema | undefined> = K extends SystemField
? NonNullable<Entity[EntityField<K>]>
: K extends "$attributes"
? Attributes
: K extends "$attributeSchema"
? AttributeSchema
: K extends `$${string}`
? never
: TSchema extends Schema
? K extends keyof ColumnsOf<TSchema>
? ColumnValue<ColumnsOf<TSchema>[K]>
: never
: ArkivValue | undefined
/**
* One result of selection `S`, through schema `TSchema` or without one: the selected entity fields
* and attributes side by side, flat. Through a schema each attribute is typed as its column
* declares; without one it is whatever value the entity holds, or `undefined`. Accessing an
* unselected field is a compile error.
*
* @example
* // select({ $key: true, title: true }) → { $key: Hex; title: ArkivValue | undefined }
* // select({ $key: true, title: true }).from(Posts) → { $key: Hex; title: StrValue }
*/
export type Row<S, TSchema extends Schema | undefined = undefined> = {
readonly [K in keyof S as Selected<S[K]> extends true ? K : never]: FieldValue<K, TSchema>
}
/**
* What `select()` / `select("*")` selects without a schema: every entity field and every
* attribute. Not the `$attributeSchema`: its point is learning an entity's shape without fetching
* its values, so returning it next to every value would only add to the response. It is only
* returned when asked for by name.
*/
type AllSelected = { [K in SystemField | "$attributes"]: true }
/**
* The result when everything is selected without a schema — every entity field, and every
* attribute as the `$attributes` map. Also what `getEntity(key)` returns.
*/
export type FullEntity = Row<AllSelected>
/** The row type when everything is selected through a schema — every entity field and column. */
export type FullRow<TSchema extends Schema> = Row<
{ [K in SystemField | keyof ColumnsOf<TSchema>]: true },
TSchema
>
declare const dynamic: unique symbol
/**
* The selection type of a builder whose selection is only known at runtime — `select(sel)` with
* `sel` typed {@link SelectArg} rather than written inline. Nothing about which fields will be
* present can be known statically, so results are typed with every field optional.
*/
export type DynamicSelection = { readonly [dynamic]: true }
/**
* A result whose selection is only known at runtime: any entity field and any attribute may be
* absent, because nothing says statically which were selected.
*/
export type PartialRow<TSchema extends Schema | undefined = undefined> = {
readonly [K in SystemField]?: NonNullable<Entity[EntityField<K>]>
} & {
readonly $attributes?: Attributes
readonly $attributeSchema?: AttributeSchema
} & (TSchema extends Schema
? { readonly [K in keyof ColumnsOf<TSchema>]?: ValueOf<ColumnsOf<TSchema>[K]["type"]> }
: { readonly [name: string]: ArkivValue | undefined })
/**
* Whether which fields `S` selects is only known at runtime: a selection typed {@link SelectArg} or
* {@link Selection}, or one with a field typed `boolean` rather than `true` or `false`.
*/
type IsDynamic<S> = string extends keyof Exclude<S, "*">
? true
: S extends "*"
? false
: true extends {
[K in keyof S]: [S[K]] extends [boolean] ? (boolean extends S[K] ? true : false) : false
}[keyof S]
? true
: false
/**
* The selection type a query carries for the selection `select()` or `query()` was given: `"*"`
* for everything, {@link DynamicSelection} when the fields are only known at runtime, and the
* selection itself otherwise.
*/
export type SelectionOf<S> = [S] extends ["*"]
? "*"
: IsDynamic<S> extends true
? DynamicSelection
: S
/**
* Rejects a selection that asks for nothing at compile time — `{}`, or every field `false`:
* `unknown` when `S` asks for something, or when that is only known at runtime, and an object no
* selection satisfies otherwise.
*/
export type NonEmptySelection<S> = SelectionOf<S> extends "*" | DynamicSelection
? unknown
: [keyof { [K in keyof S as Selected<S[K]> extends true ? K : never]: true }] extends [never]
? { "select() needs at least one field set to true": true }
: unknown
/**
* Rejects a `$` key that is not an entity field, `$attributes` or `$attributeSchema` at compile
* time. An attribute name cannot start with `$`, so such a key can only be a typo: `unknown` when
* there is none, and an object naming the offenders, which no selection satisfies, otherwise.
*/
export type KnownSystemFields<S> = SelectionOf<S> extends "*" | DynamicSelection
? unknown
: [Extract<Exclude<keyof S, SelectionKey>, `$${string}`>] extends [never]
? unknown
: {
"selected names that are not entity fields": Extract<
Exclude<keyof S, SelectionKey>,
`$${string}`
>
}
/** What a query returns per result — a flat row, typed by the schema when there is one. */
export type ResultOf<
TSelection,
TSchema extends Schema | undefined,
> = TSelection extends DynamicSelection
? PartialRow<TSchema>
: TSelection extends "*"
? TSchema extends Schema
? FullRow<TSchema>
: FullEntity
: Row<TSelection, TSchema>
/**
* The keys of a selection that mean nothing through `TSchema` — neither an entity field,
* `$attributes` / `$attributeSchema`, nor a column. None for a star or a selection only known at
* runtime, which `from()` checks when it runs instead.
*/
export type UnknownSelection<TSelection, TSchema extends Schema> = TSelection extends
| "*"
| DynamicSelection
? never
: Exclude<keyof TSelection, SelectionKey | keyof ColumnsOf<TSchema>>
/**
* Converts a selection into the RPC `select` object — the same request {@link projectSelection}
* makes without a schema.
*
* Every field is opt-in — anything not explicitly selected is sent as `false`, including the key.
* Sending the full object rather than omitting the `false`s keeps what the SDK asked for legible in
* a request log, and pins the answer even if a node's defaults change.
*
* `undefined` or `"*"` selects everything.
*
* @throws If the selection asks for nothing, or names an attribute outside the name grammar.
*
* @example
* toRpcSelect() // everything
* toRpcSelect({ $key: true }) // only the key
* toRpcSelect({ $key: true, projectId: true }) // the key and one attribute
*/
export function toRpcSelect(selection?: SelectArg): RpcSelect {
return projectSelection(selection).select
}
/**
* A frozen copy of a selection, so that editing the object passed to `select()` afterwards cannot
* change what the builder sends.
*/
export function snapshotSelection<S extends SelectArg | undefined>(selection: S): S {
if (typeof selection !== "object" || selection === null) return selection
return Object.freeze({ ...selection }) as S
}
/**
* A selection resolved, through a schema or without one: what to ask the node for, and how to lay
* the answer out.
*/
export type Projection = {
readonly select: RpcSelect
/** The entity fields each row carries. */
readonly fields: readonly SystemField[]
/**
* The attributes each row carries as fields of their own, by name — each with its column
* through a schema, which the value is checked against, and `undefined` without one.
*/
readonly columns: readonly (readonly [name: string, column: AnyColumn | undefined])[]
/** Whether each row carries the `$attributes` map. */
readonly attributes: boolean
/** Whether each row carries the `$attributeSchema`. */
readonly attributeSchema: boolean
/** The tag each entity must carry, for a named schema. Checked, never part of the row. */
readonly tag: StrValue | undefined
}
/**
* Resolves a selection, through a schema or without one. A key that does not start with `$` names
* an attribute — through a schema, it must be a column.
*
* `undefined` or `"*"` selects every entity field and, through a schema, every column; without one,
* every attribute as the `$attributes` map. The `$attributeSchema` is only selected by name.
*
* Attributes are fetched by name where possible, so the ones a row does not carry stay on the node.
* A named schema's tag is fetched too, so each entity can be checked against it.
*
* @throws If the selection asks for nothing, names a `$` key that is not an entity field, names an
* attribute outside the name grammar, or — through a schema — names an attribute that is not a
* column.
*/
export function projectSelection(selection?: SelectArg, schema?: Schema): Projection {
const fields: SystemField[] = []
const columns: [string, AnyColumn | undefined][] = []
let attributes = false
let attributeSchema = false
if (selection === undefined || selection === "*") {
fields.push(...SYSTEM_FIELDS)
if (schema === undefined) {
attributes = true
} else {
columns.push(...Object.entries(schema.columns))
}
} else {
if (typeof selection !== "object" || selection === null || Array.isArray(selection)) {
throw new TypeError('select() takes "*" or an object of fields set to true')
}
for (const [name, wanted] of Object.entries(selection)) {
if (typeof wanted !== "boolean" && wanted !== undefined) {
throw new Error(
name === "$attributes"
? 'select() takes true or false for "$attributes", which reads every attribute. To ' +
"read some of them, select each by name: select({ title: true, body: true })."
: `select() takes true or false for "${name}", got ${typeof wanted}.`,
)
}
const fixed = (SELECTION_KEYS as readonly string[]).includes(name)
if (!fixed && name.startsWith("$")) {
throw new Error(
`select() names "${name}", which is not an entity field. The entity fields are ` +
`${SYSTEM_FIELDS.join(", ")}, and $attributes and $attributeSchema read the ` +
"attributes as a whole.",
)
}
const column = fixed || schema === undefined ? undefined : lookupColumn(schema, name)
if (!fixed) {
if (schema !== undefined && column === undefined) {
throw new Error(
`select() names "${name}", which is not a column of the schema. Its columns are ` +
`${describeColumns(schema)}.`,
)
}
validateAttributeName(name)
}
if (!wanted) continue
if (name === "$attributes") attributes = true
else if (name === "$attributeSchema") attributeSchema = true
else if (fixed) fields.push(name as SystemField)
else columns.push([name, column])
}
// Rejects both `{}` and a selection whose fields are all explicitly `false` — the type system
// permits `select({ $key: false })`, and the node would answer it with a page of empty objects.
if (fields.length === 0 && columns.length === 0 && !attributes && !attributeSchema) {
throw nothingSelected()
}
}
const select: RpcSelect = {}
for (const field of SYSTEM_FIELDS) select[entityField(field)] = fields.includes(field)
select.attributeSchema = attributeSchema
if (attributes) {
select.attributes = true
} else {
const names = new Set(columns.map(([name]) => name))
if (schema?.tag !== undefined) names.add(SCHEMA_ATTRIBUTE)
select.attributes =
names.size > 0 ? Object.fromEntries([...names].map((n) => [n, true])) : false
}
return { select, fields, columns, attributes, attributeSchema, tag: schema?.tag }
}
/**
* Lays an entity out as a row of `projection` — checking it against the schema, when there is one.
*
* @throws {SchemaMismatchError} If a named schema's entity is not tagged with its name, or the
* entity lacks a selected required column or holds one with a type other than the schema's — it
* was not written through this schema, or was patched since.
*/
export function toRow(entity: Entity, projection: Projection): Record<string, unknown> {
const subject = entity.key === undefined ? "the entity" : `entity ${entity.key}`
const { tag } = projection
if (tag !== undefined) {
const found = attributeOf(entity, SCHEMA_ATTRIBUTE)
if (found?.type !== "str" || found.value !== tag.value) {
throw new SchemaMismatchError(
SCHEMA_ATTRIBUTE,
`${subject} is ${describeTag(found)}, not ${JSON.stringify(tag.value)}, so it was not ` +
"written through this schema",
)
}
}
const row: Record<string, unknown> = {}
for (const field of projection.fields) row[field] = entity[entityField(field)]
for (const [name, column] of projection.columns) {
const value = attributeOf(entity, name)
if (column !== undefined) {
if (value === undefined) {
if (!column.isOptional) {
throw new SchemaMismatchError(
name,
`${subject} has no value for this required ${column.type} column, so it does not ` +
"match the schema. Declare the column .optional() if entities may leave it unset",
)
}
} else if (value.type !== column.type) {
throw new SchemaMismatchError(
name,
`${subject} holds a ${value.type}, not the ${column.type} the schema declares`,
)
}
}
row[name] = value
}
if (projection.attributes) {
row.$attributes = withoutTag(entity.attributes ?? {}, tag)
}
if (projection.attributeSchema) {
row.$attributeSchema = withoutTag(entity.attributeSchema ?? {}, tag)
}
return row
}
/** The {@link Entity} property, and RPC `select` key, a system field reads: its name without `$`. */
function entityField<F extends SystemField>(field: F): EntityField<F> {
return field.slice(1) as EntityField<F>
}
/**
* A copy of `record` without a named schema's tag: the tag is how a query finds the schema's
* entities, not one of their attributes, so a row never carries it — and a map read back from a
* row drops into a write without sending the reserved name.
*/
function withoutTag<T>(record: Readonly<Record<string, T>>, tag: StrValue | undefined) {
if (tag === undefined || !Object.hasOwn(record, SCHEMA_ATTRIBUTE)) return record
const { [SCHEMA_ATTRIBUTE]: _tag, ...rest } = record
return rest
}
/**
* The entity's attribute `name`, or `undefined` if it has none. An own-property read: a column may
* be named `constructor` or `toString`, and an entity that leaves it unset must not find the
* inherited one instead.
*/
function attributeOf(entity: Entity, name: string): ArkivValue | undefined {
const { attributes } = entity
return attributes !== undefined && Object.hasOwn(attributes, name) ? attributes[name] : undefined
}
/** How an entity's schema tag reads in a mismatch — whatever type another writer gave it. */
function describeTag(found: ArkivValue | undefined): string {
if (found === undefined) return "not tagged with a schema"
if (found.type === "str") return `tagged ${JSON.stringify(found.value)}`
return `tagged with a value of type ${found.type}`
}
function nothingSelected(): Error {
return new Error(
'select() requires at least one field to be selected. Pass select() or select("*") to select everything.',
)
}