diff --git a/packages/core/src/cbor.js b/packages/core/src/cbor.js index b19d8b4e..99de128d 100644 --- a/packages/core/src/cbor.js +++ b/packages/core/src/cbor.js @@ -1,12 +1,16 @@ import * as API from '@ucanto/interface' import * as CBOR from '@ipld/dag-cbor' -export { code, name, decode } from '@ipld/dag-cbor' -import { sha256 } from 'multiformats/hashes/sha2' +export { decode } from '@ipld/dag-cbor' +import * as sha256 from './sha256.js' import { create as createLink, isLink } from 'multiformats/link' // @see https://www.iana.org/assignments/media-types/application/vnd.ipld.dag-cbor export const contentType = 'application/vnd.ipld.dag-cbor' +/** @type {API.MulticodecCode} */ +export const code = CBOR.code +export const name = CBOR.name + /** * @param {unknown} data * @param {Set} seen diff --git a/packages/core/src/dag.js b/packages/core/src/dag.js index 80199a74..87a1e98d 100644 --- a/packages/core/src/dag.js +++ b/packages/core/src/dag.js @@ -106,18 +106,18 @@ export const notFound = link => { /** * @template T * @template {T} U - * @template {API.MulticodecCode} C - * @template {API.MulticodecCode} A + * @template {MF.BlockEncoder} [Codec=MF.BlockEncoder, U>] + * @template {MF.MultihashHasher} [Hasher=MF.MultihashHasher>] * @param {U} source * @param {BlockStore} store * @param {object} options - * @param {MF.BlockEncoder} [options.codec] - * @param {MF.MultihashHasher} [options.hasher] - * @returns {Promise & { data: U }>} + * @param {Codec} [options.codec] + * @param {Hasher} [options.hasher] + * @returns {Promise & { data: U }>} */ export const writeInto = async (source, store, options = {}) => { - const codec = /** @type {MF.BlockEncoder} */ (options.codec || CBOR) - const hasher = /** @type {MF.MultihashHasher} */ (options.hasher || sha256) + const codec = /** @type {Codec} */ (options.codec || CBOR) + const hasher = /** @type {Hasher} */ (options.hasher || sha256) const bytes = codec.encode(source) const digest = await hasher.digest(bytes) diff --git a/packages/core/src/delegation.js b/packages/core/src/delegation.js index 8f91b62b..9162f136 100644 --- a/packages/core/src/delegation.js +++ b/packages/core/src/delegation.js @@ -173,41 +173,6 @@ export class Delegation { }) } - /** - * @returns {API.AttachedLinkSet} - */ - get attachedLinks() { - const _attachedLinks = new Set() - const ucanView = this.data - - // Get links from capabilities nb - for (const capability of ucanView.capabilities) { - /** @type {Link[]} */ - const links = getLinksFromObject(capability) - - for (const link of links) { - _attachedLinks.add(`${link}`) - } - } - - // Get links from facts values - for (const fact of ucanView.facts) { - if (Link.isLink(fact)) { - _attachedLinks.add(`${fact}`) - } else { - /** @type {Link[]} */ - // @ts-expect-error isLink does not infer value type - const links = Object.values(fact).filter(e => Link.isLink(e)) - - for (const link of links) { - _attachedLinks.add(`${link}`) - } - } - } - - return _attachedLinks - } - get version() { return this.data.version } @@ -231,22 +196,8 @@ export class Delegation { Object.defineProperties(this, { data: { value: data, enumerable: false } }) return data } - /** - * Attach a block to the delegation DAG so it would be included in the - * block iterator. - * ⚠️ You can only attach blocks that are referenced from the `capabilities` - * or `facts`. - * - * @param {API.Block} block - */ - attach(block) { - if (!this.attachedLinks.has(`${block.cid.link()}`)) { - throw new Error(`given block with ${block.cid} is not an attached link`) - } - this.blocks.set(`${block.cid}`, block) - } export() { - return exportDAG(this.root, this.blocks, this.attachedLinks) + return exportDAG(this) } /** @@ -257,7 +208,7 @@ export class Delegation { } iterateIPLDBlocks() { - return exportDAG(this.root, this.blocks, this.attachedLinks) + return exportDAG(this) } /** @@ -385,7 +336,7 @@ export const archive = async delegation => { export const ArchiveSchema = Schema.variant({ 'ucan@0.9.1': /** @type {Schema.Schema} */ ( - Schema.link({ version: 1 }) + Schema.unknown().link({ version: 1 }) ), }) @@ -453,11 +404,34 @@ const decode = ({ bytes }) => { */ export const delegate = async ( - { issuer, audience, proofs = [], attachedBlocks = new Map(), ...input }, + { issuer, audience, proofs = [], facts = [], ...input }, options ) => { const links = [] + /** @type {Map} */ const blocks = new Map() + for (const capability of input.capabilities) { + for (const node of [...Object.values(capability.nb || {}), capability.nb]) { + for (const block of DAG.iterate(node)) { + blocks.set(block.cid.toString(), block) + } + } + } + + for (const fact of facts) { + if (fact) { + for (const node of [...Object.values(fact), fact]) { + for (const block of DAG.iterate(node)) { + blocks.set(block.cid.toString(), block) + } + } + } + } + + const attachments = [...blocks.values()] + .map(block => block.cid) + .sort((left, right) => left.toString().localeCompare(right.toString())) + for (const proof of proofs) { if (!isDelegation(proof)) { links.push(proof) @@ -469,11 +443,20 @@ export const delegate = async ( } } + // We may not have codecs to traverse all the blocks on the other side of the + // wire, which is why we capture all the links in a flat list which we include + // in facts. That way recipient will know all of the blocks without having to + // know to know how to traverse the graph. + if (attachments.length > 0) { + facts.push({ 'ucan/attachments': attachments }) + } + const data = await UCAN.issue({ ...input, issuer, audience, proofs: links, + facts, }) const { cid, bytes } = await UCAN.write(data, options) decodeCache.set(cid, data) @@ -482,23 +465,20 @@ export const delegate = async ( const delegation = new Delegation({ cid, bytes }, blocks) Object.defineProperties(delegation, { proofs: { value: proofs } }) - for (const block of attachedBlocks.values()) { - delegation.attach(block) - } - return delegation } /** * @template {API.Capabilities} C - * @param {API.UCANBlock} root - * @param {DAG.BlockStore} blocks - * @param {API.AttachedLinkSet} attachedLinks + * @param {object} source + * @param {API.UCANBlock} source.root + * @param {DAG.BlockStore} source.blocks * @returns {IterableIterator} */ -export const exportDAG = function* (root, blocks, attachedLinks) { - for (const link of decode(root).proofs) { +export const exportDAG = function* ({ root, blocks }) { + const { proofs, facts, capabilities } = decode(root) + for (const link of proofs) { // Check if block is included in this delegation const root = /** @type {UCAN.Block} */ (blocks.get(`${link}`)) if (root) { @@ -506,7 +486,8 @@ export const exportDAG = function* (root, blocks, attachedLinks) { } } - for (const link of attachedLinks.values()) { + const links = new Set([...iterateLinks({ facts, capabilities }, blocks)]) + for (const link of links) { const block = blocks.get(link) if (block) { @@ -609,31 +590,22 @@ const proofs = delegation => { } /** - * @param {API.Capability} obj + * Traverses source and yields all links found. + * + * @param {unknown} source + * @param {Schema.Region} region + * @returns {IterableIterator} */ -function getLinksFromObject(obj) { - /** @type {Link[]} */ - const links = [] - - /** - * @param {object} obj - */ - function recurse(obj) { - for (const key in obj) { - // @ts-expect-error record type not inferred - const value = obj[key] - if (Link.isLink(value)) { - // @ts-expect-error isLink does not infer value type - links.push(value) - } else if (value && typeof value === 'object') { - recurse(value) +const iterateLinks = function* (source, region) { + if (source && typeof source === 'object') { + if (Link.isLink(source)) { + yield `${source}` + } else { + for (const member of Object.values(source)) { + yield* iterateLinks(member, region) } } } - - recurse(obj) - - return links } export { Delegation as View } diff --git a/packages/core/src/identity.js b/packages/core/src/identity.js new file mode 100644 index 00000000..ca21a150 --- /dev/null +++ b/packages/core/src/identity.js @@ -0,0 +1,9 @@ +import * as API from '@ucanto/interface' + +import { identity } from 'multiformats/hashes/identity' + +/** @type {API.MulticodecCode} */ +export const code = identity.code +export const name = identity.name +export const { encode } = identity +export const digest = identity.digest.bind(identity) diff --git a/packages/core/src/invocation.js b/packages/core/src/invocation.js index 819adafb..b9c7b5a4 100644 --- a/packages/core/src/invocation.js +++ b/packages/core/src/invocation.js @@ -81,22 +81,6 @@ class IssuedInvocation { this.notBefore = notBefore this.nonce = nonce this.facts = facts - - /** @type {API.BlockStore} */ - this.attachedBlocks = new Map() - } - - /** - * Attach a block to the invocation DAG so it would be included in the - * block iterator. - * ⚠️ You should only attach blocks that are referenced from the `capabilities` - * or `facts`, if that is not the case you probably should reconsider. - * ⚠️ Once a delegation is de-serialized the attached blocks will not be re-attached. - * - * @param {API.Block} block - */ - attach(block) { - this.attachedBlocks.set(`${block.cid}`, block) } delegate() { diff --git a/packages/core/src/lib.js b/packages/core/src/lib.js index 2a299393..805dd40e 100644 --- a/packages/core/src/lib.js +++ b/packages/core/src/lib.js @@ -15,7 +15,7 @@ export { parse as parseLink, decode as decodeLink, } from './link.js' -export { sha256 } from 'multiformats/hashes/sha2' +export * as sha256 from './sha256.js' export * as UCAN from '@ipld/dag-ucan' export * as DID from '@ipld/dag-ucan/did' export * as Signature from '@ipld/dag-ucan/signature' diff --git a/packages/core/src/message.js b/packages/core/src/message.js index 9021ecea..4be20867 100644 --- a/packages/core/src/message.js +++ b/packages/core/src/message.js @@ -6,15 +6,13 @@ import * as Schema from './schema.js' export const MessageSchema = Schema.variant({ 'ucanto/message@7.0.0': Schema.struct({ - execute: Schema.link().array().optional(), - delegate: Schema.dictionary({ + execute: Schema.unknown().link().array().optional(), + report: Schema.dictionary({ key: Schema.string(), - value: /** @type {API.Reader>} */ ( - Schema.link() + value: /** @type {Schema.Convert>} */ ( + Schema.unknown().link() ), - }) - .array() - .optional(), + }).optional(), }), }) @@ -178,6 +176,9 @@ class Message { this._invocations = invocations this._receipts = receipts } + link() { + return this.root.cid + } *iterateIPLDBlocks() { for (const invocation of this.invocations) { yield* invocation.iterateIPLDBlocks() diff --git a/packages/core/src/receipt.js b/packages/core/src/receipt.js index e9cd4dbf..90fafefa 100644 --- a/packages/core/src/receipt.js +++ b/packages/core/src/receipt.js @@ -61,6 +61,9 @@ class Receipt { this._issuer = issuer } + link() { + return this.root.cid + } /** * @returns {Ran|ReturnType} */ diff --git a/packages/core/src/schema.js b/packages/core/src/schema.js index 0bfecbfe..1daf4762 100644 --- a/packages/core/src/schema.js +++ b/packages/core/src/schema.js @@ -1,9 +1,7 @@ export * as URI from './schema/uri.js' -export * as Link from './schema/link.js' export * as DID from './schema/did.js' export * as Text from './schema/text.js' export * from './schema/schema.js' -export { match as link } from './schema/link.js' export { match as did } from './schema/did.js' export { match as uri } from './schema/uri.js' export { match as text } from './schema/text.js' diff --git a/packages/core/src/schema/link.js b/packages/core/src/schema/link.js deleted file mode 100644 index 851c42ec..00000000 --- a/packages/core/src/schema/link.js +++ /dev/null @@ -1,98 +0,0 @@ -import * as API from '@ucanto/interface' -import { create, createLegacy, isLink, parse, base32 } from '../link.js' -import * as Schema from './schema.js' - -export { create, createLegacy, isLink, parse } - -/** - * @template {number} [Code=number] - * @template {number} [Alg=number] - * @template {1|0} [Version=0|1] - * @typedef {{ - * code?:Code, - * version?:Version - * multihash?: {code?: Alg, digest?: Uint8Array} - * }} Settings - */ - -/** - * @template {number} Code - * @template {number} Alg - * @template {1|0} Version - * @extends {Schema.API, unknown, Settings>} - */ -class LinkSchema extends Schema.API { - /** - * - * @param {unknown} cid - * @param {Settings} settings - * @returns {Schema.ReadResult>} - */ - readWith(cid, { code, multihash = {}, version }) { - if (cid == null) { - return Schema.error(`Expected link but got ${cid} instead`) - } else { - if (!isLink(cid)) { - return Schema.error(`Expected link to be a CID instead of ${cid}`) - } else { - if (code != null && cid.code !== code) { - return Schema.error( - `Expected link to be CID with 0x${code.toString(16)} codec` - ) - } - - if (multihash.code != null && cid.multihash.code !== multihash.code) - return Schema.error( - `Expected link to be CID with 0x${multihash.code.toString( - 16 - )} hashing algorithm` - ) - - if (version != null && cid.version !== version) { - return Schema.error( - `Expected link to be CID version ${version} instead of ${cid.version}` - ) - } - - const [expectDigest, actualDigest] = - multihash.digest != null - ? [ - base32.baseEncode(multihash.digest), - base32.baseEncode(cid.multihash.digest), - ] - : ['', ''] - - if (expectDigest !== actualDigest) { - return Schema.error( - `Expected link with "${expectDigest}" hash digest instead of "${actualDigest}"` - ) - } - - return { - ok: /** @type {API.Link} */ (cid), - } - } - } - } -} - -/** @type {Schema.Schema, unknown>} */ -export const schema = new LinkSchema({}) - -export const link = () => schema - -/** - * @template {number} Code - * @template {number} Alg - * @template {1|0} Version - * @param {Settings} options - * @returns {Schema.Schema>} - */ -export const match = (options = {}) => new LinkSchema(options) - -/** - * @param {unknown} input - */ -export const read = input => schema.read(input) - -export const optional = () => schema.optional() diff --git a/packages/core/src/schema/schema.js b/packages/core/src/schema/schema.js index da1affd1..b32128ad 100644 --- a/packages/core/src/schema/schema.js +++ b/packages/core/src/schema/schema.js @@ -1,15 +1,26 @@ import * as Schema from './type.js' import { ok, Failure } from '../result.js' +import { + create as createLink, + parse as parseLink, + createLegacy, + isLink, + parse, + base32, +} from '../link.js' export * from './type.js' +import * as sha256 from '../sha256.js' +import * as identity from '../identity.js' +import * as CBOR from '../cbor.js' export { ok } /** * @abstract - * @template [T=unknown] - * @template [I=unknown] + * @template [Out=unknown] + * @template [In=unknown] * @template [Settings=void] - * @extends {Schema.Base} - * @implements {Schema.Schema} + * @extends {Schema.Base} + * @implements {Schema.Schema} */ export class API { /** @@ -25,36 +36,65 @@ export class API { } /** * @abstract - * @param {I} input + * @param {In} input * @param {Settings} settings - * @returns {Schema.ReadResult} + * @param {Schema.Region} [region] + * @returns {Schema.ReadResult} */ /* c8 ignore next 3 */ - readWith(input, settings) { + readWith(input, settings, region) { throw new Error(`Abstract method readWith must be implemented by subclass`) } + + /** + * + * @param {Out} output + * @param {Settings} settings + * @returns {Schema.ReadResult} + */ + writeWith(output, settings) { + throw new Error(`Abstract method writeWith must be implemented by subclass`) + } + /** - * @param {I} input - * @returns {Schema.ReadResult} + * @param {unknown} input */ + read(input) { - return this.readWith(input, this.settings) + return this.tryFrom(/** @type {In} */ (input)) + } + + /** + * @param {In} input + * @param {Schema.Region} [region] + * @returns {Schema.ReadResult} + */ + tryFrom(input, region) { + return this.readWith(input, this.settings, region) + } + + /** + * @param {Out} output + * @returns {Schema.ReadResult} + */ + tryTo(output) { + return this.writeWith(output, this.settings) } /** * @param {unknown} value - * @returns {value is T} + * @returns {value is Out} */ is(value) { - return !this.read(/** @type {I} */ (value))?.error + return !this.tryFrom(/** @type {In} */ (value))?.error } /** - * @param {unknown} value - * @return {T} + * @param {In} value + * @return {Out} */ from(value) { - const result = this.read(/** @type {I} */ (value)) + const result = this.tryFrom(/** @type {In} */ (value)) if (result.error) { throw result.error } else { @@ -63,29 +103,50 @@ export class API { } /** - * @returns {Schema.Schema} + * @param {Out} value */ + to(value) { + const result = this.tryTo(value) + if (result.error) { + throw result.error + } else { + return result.ok + } + } + optional() { return optional(this) } - /** - * @returns {Schema.Schema} - */ nullable() { return nullable(this) } /** - * @returns {Schema.Schema} + * @deprecated - use {@link Schema.Schema.implicit} instead + * @param {Schema.NotUndefined} value + */ + default(value) { + return this.implicit(value) + } + + /** + * @param {Schema.NotUndefined} value + */ + implicit(value) { + return implicit(this, value) + } + + /** + * @returns {Schema.ArraySchema>} */ array() { return array(this) } /** - * @template U - * @param {Schema.Reader} schema - * @returns {Schema.Schema} + * @template I, O + * @param {Schema.Convert} schema + * @returns {Schema.Schema} */ or(schema) { @@ -93,70 +154,89 @@ export class API { } /** - * @template U - * @param {Schema.Reader} schema - * @returns {Schema.Schema} + * @template I, O + * @param {Schema.Convert} schema + * @returns {Schema.Schema} */ and(schema) { return and(this, schema) } /** - * @template {T} U - * @param {Schema.Reader} schema - * @returns {Schema.Schema} + * @template {Out} O + * @template {Out} I + * @param {Schema.Convert} schema + * @returns {Schema.Schema} */ refine(schema) { return refine(this, schema) } + /** + * @template O + * @param {Schema.Convert} to + * @returns {Schema.Schema} + */ + pipe(to) { + return pipe(this, to) + } + /** * @template {string} Kind * @param {Kind} [kind] - * @returns {Schema.Schema, I>} + * @returns {Schema.Schema, In>} */ brand(kind) { - return /** @type {Schema.Schema, I>} */ (this) + return /** @type {Schema.Schema, In>} */ (this) } /** - * @param {Schema.NotUndefined} value - * @returns {Schema.DefaultSchema, I>} + * @template {Schema.BlockCodec} Codec + * @template {Schema.MultihashHasher} Hasher + * @template {Schema.UnknownLink['version']} Version + * @param {{ + * codec?: Codec + * hasher?: Hasher + * version?: Version + * }} options + * @returns {Schema.LinkSchema} */ - default(value) { - // ⚠️ this.from will throw if wrong default is provided - const fallback = this.from(value) - // we also check that fallback is not undefined because that is the point - // of having a fallback - if (fallback === undefined) { - throw new Error(`Value of type undefined is not a valid default`) - } - - const schema = new Default({ - reader: /** @type {Schema.Reader} */ (this), - value: /** @type {Schema.NotUndefined} */ (fallback), + link({ codec, hasher, version } = {}) { + const schema = link({ + codec, + hasher, + version, + schema: /** @type {Schema.Schema} */ (this), }) - return /** @type {Schema.DefaultSchema, I>} */ ( - schema - ) + return schema + } + + toSchema() { + return this } } /** - * @template [I=unknown] - * @extends {API} - * @implements {Schema.Schema} + * @extends {API} + * @implements {Schema.Schema} */ class Never extends API { toString() { return 'never()' } + + /** + * @param {never} value + */ + tryTo(value) { + return value + } /** - * @param {I} input + * @param {any} input * @returns {Schema.ReadResult} */ - read(input) { + tryFrom(input) { return typeError({ expect: 'never', actual: input }) } } @@ -168,17 +248,22 @@ class Never extends API { export const never = () => new Never() /** - * @template [I=unknown] - * @extends API - * @implements {Schema.Schema} + * @extends API + * @implements {Schema.Schema} */ class Unknown extends API { /** - * @param {I} input + * @param {unknown} input */ - read(input) { + tryFrom(input) { return /** @type {Schema.ReadResult}*/ ({ ok: input }) } + /** + * @param {unknown} output + */ + tryTo(output) { + return { ok: output } + } toString() { return 'unknown()' } @@ -186,23 +271,27 @@ class Unknown extends API { /** * @template [I=unknown] - * @returns {Schema.Schema} + * @returns {Schema.Schema} */ export const unknown = () => new Unknown() /** - * @template O - * @template [I=unknown] - * @extends {API>} - * @implements {Schema.Schema} + * @template I, O + * @extends {API>} + * @implements {Schema.Schema} */ class Nullable extends API { /** - * @param {I} input - * @param {Schema.Reader} reader + * @param {I|null} input + * @param {Schema.Convert} convert + * @param {Schema.Region} [region] */ - readWith(input, reader) { - const result = reader.read(input) + readWith(input, convert, region) { + if (input === null) { + return { ok: null } + } + + const result = convert.tryFrom(input, region) if (result.error) { return input === null ? { ok: null } @@ -218,6 +307,13 @@ class Nullable extends API { return result } } + /** + * @param {O|null} output + * @param {Schema.Convert} writer + */ + writeWith(output, writer) { + return output === null ? { ok: null } : writer.tryTo(output) + } toString() { return `${this.settings}.nullable()` } @@ -226,111 +322,179 @@ class Nullable extends API { /** * @template O * @template [I=unknown] - * @param {Schema.Reader} schema - * @returns {Schema.Schema} + * @param {Schema.Convert} schema + * @returns {Schema.Schema} */ export const nullable = schema => new Nullable(schema) /** * @template O - * @template [I=unknown] - * @extends {API>} - * @implements {Schema.Schema} + * @template I + * @extends {API>} + * @implements {Schema.Schema} */ class Optional extends API { optional() { return this } /** - * @param {I} input - * @param {Schema.Reader} reader + * @param {I|undefined} input + * @param {Schema.Convert} convert + * @param {Schema.Region} [region] * @returns {Schema.ReadResult} */ - readWith(input, reader) { - const result = reader.read(input) + readWith(input, convert, region) { + if (input === undefined) { + return { ok: undefined } + } + const result = convert.tryFrom(input, region) return result.error && input === undefined ? { ok: undefined } : result } + /** + * + * @param {O|undefined} output + * @param {Schema.Convert} convert + */ + writeWith(output, convert) { + return output === undefined ? { ok: undefined } : convert.tryTo(output) + } toString() { return `${this.settings}.optional()` } } /** - * @template {unknown} O + * @template O * @template [I=unknown] - * @extends {API, value:O & Schema.NotUndefined}>} - * @implements {Schema.DefaultSchema} + * @param {Schema.Convert} schema + * @returns {Schema.Schema} + */ +export const optional = schema => new Optional(schema) + +/** + * @template Out + * @template In + * @extends {API, In | undefined, {convert:Schema.Convert, value:Exclude}>} + * @implements {Schema.ImplicitSchema} */ -class Default extends API { +class Implicit extends API { /** - * @returns {Schema.DefaultSchema, I>} + * @returns {*} */ optional() { - // Short circuit here as we there is no point in wrapping this in optional. - return /** @type {Schema.DefaultSchema, I>} */ ( - this - ) + return /** @type {*} */ (this) + } + + /** + * @param {Exclude} value + */ + implicit(value) { + return /** @type {*} */ (implicit(this.settings.convert, value)) } + /** - * @param {I} input + * @param {In|undefined} input * @param {object} options - * @param {Schema.Reader} options.reader - * @param {O} options.value - * @returns {Schema.ReadResult} + * @param {Schema.Convert} options.convert + * @param {Exclude} options.value + * @param {Schema.Region} [region] + * @returns {Schema.ReadResult>} */ - readWith(input, { reader, value }) { + readWith(input, { convert, value }, region) { if (input === undefined) { - return /** @type {Schema.ReadResult} */ ({ ok: value }) + return { ok: value } } else { - const result = reader.read(input) + const result = convert.tryFrom(input, region) return result.error ? result - : result.ok !== undefined - ? // We just checked that result.ok is not undefined but still needs + : result.ok === undefined + ? { ok: value } + : // We just checked that result.ok is not undefined but still needs // reassurance - /** @type {Schema.ReadResult} */ (result) - : { ok: value } + /** @type {*} */ (result) } } + + /** + * + * @param {Exclude} output + * @param {object} options + * @param {Schema.Convert} options.convert + */ + writeWith(output, { convert }) { + return convert.tryTo(output) + } toString() { - return `${this.settings.reader}.default(${JSON.stringify( + return `${this.settings.convert}.default(${JSON.stringify( this.settings.value )})` } + /** + * @returns {Schema.NotUndefined} + */ get value() { - return this.settings.value + return /** @type {Schema.NotUndefined} */ (this.settings.value) } } /** - * @template O - * @template [I=unknown] - * @param {Schema.Reader} schema - * @returns {Schema.Schema} + * @template O, I + * @template {Exclude} Q + * @param {Schema.Convert} schema + * @param {Q} value + * @returns {Schema.ImplicitSchema} */ -export const optional = schema => new Optional(schema) +export const implicit = (schema, value) => { + // we also check that fallback is not undefined because that is the point + // of having a fallback + if (value === undefined) { + throw new Error(`Value of type undefined is not a valid default`) + } + + const implicit = /** @type {Schema.ImplicitSchema} */ ( + new Implicit({ convert: schema, value }) + ) + + return implicit +} /** - * @template O - * @template [I=unknown] - * @extends {API>} - * @implements {Schema.ArraySchema} + * @template {Schema.Convert} Convert + * @extends {API[], Schema.InferInput[], Convert>} + * @implements {Schema.ArraySchema} */ class ArrayOf extends API { /** - * @param {I} input - * @param {Schema.Reader} schema + * @param {Schema.InferInput[]} input + * @param {Convert} convert + * @param {Schema.Region} [context] */ - readWith(input, schema) { + readWith(input, convert, context) { if (!Array.isArray(input)) { return typeError({ expect: 'array', actual: input }) } - /** @type {O[]} */ const results = [] for (const [index, value] of input.entries()) { - const result = schema.read(value) + const result = convert.tryFrom(value, context) + if (result.error) { + return memberError({ at: index, cause: result.error }) + } else { + results.push(result.ok) + } + } + return { ok: results } + } + /** + * + * @param {Schema.Infer[]} output + * @param {Convert} convert + */ + writeWith(output, convert) { + const results = [] + for (const [index, value] of output.entries()) { + const result = convert.tryTo(value) if (result.error) { return memberError({ at: index, cause: result.error }) } else { @@ -339,6 +503,7 @@ class ArrayOf extends API { } return { ok: results } } + get element() { return this.settings } @@ -348,27 +513,29 @@ class ArrayOf extends API { } /** - * @template O - * @template [I=unknown] - * @param {Schema.Reader} schema - * @returns {Schema.ArraySchema} + * @template O, I + * @template {Schema.Convert} Schema + * @param {Schema} schema + * @returns {Schema.ArraySchema} */ -export const array = schema => new ArrayOf(schema) +export const array = schema => { + const out = new ArrayOf(schema) + return out +} /** - * @template {Schema.Reader} T - * @template {[T, ...T[]]} U - * @template [I=unknown] - * @extends {API, I, U>} - * @implements {Schema.Schema, I>} + * @template {[Schema.Convert, ...Schema.Convert[]]} Shape + * @extends {API, Schema.InferTupleInput, Shape>} + * @implements {Schema.Schema, Schema.InferTupleInput>} */ class Tuple extends API { /** - * @param {I} input - * @param {U} shape - * @returns {Schema.ReadResult>} + * @param {Schema.InferTupleInput} input + * @param {Shape} shape + * @param {Schema.Region} [context] + * @returns {Schema.ReadResult>} */ - readWith(input, shape) { + readWith(input, shape, context) { if (!Array.isArray(input)) { return typeError({ expect: 'array', actual: input }) } @@ -378,7 +545,34 @@ class Tuple extends API { const results = [] for (const [index, reader] of shape.entries()) { - const result = reader.read(input[index]) + const result = reader.tryFrom(input[index], context) + if (result.error) { + return memberError({ at: index, cause: result.error }) + } else { + results[index] = result.ok + } + } + + return { ok: /** @type {Schema.InferTuple} */ (results) } + } + /** + * + * @param {Schema.InferTuple} output + * @param {Shape} shape + * @returns {Schema.ReadResult>} + */ + writeWith(output, shape) { + if (!Array.isArray(output)) { + return typeError({ expect: 'array', actual: output }) + } + + if (output.length !== this.shape.length) { + return error(`Array must contain exactly ${this.shape.length} elements`) + } + + const results = [] + for (const [index, writer] of shape.entries()) { + const result = writer.tryTo(output[index]) if (result.error) { return memberError({ at: index, cause: result.error }) } else { @@ -386,43 +580,42 @@ class Tuple extends API { } } - return { ok: /** @type {Schema.InferTuple} */ (results) } + return { ok: /** @type {Schema.InferTupleInput} */ (results) } } - /** @type {U} */ + /** @type {Shape} */ get shape() { return this.settings } toString() { - return `tuple([${this.shape.map(reader => reader.toString()).join(', ')}])` + return `tuple([${this.shape.map(member => member.toString()).join(', ')}])` } } /** - * @template {Schema.Reader} T - * @template {[T, ...T[]]} U - * @template [I=unknown] - * @param {U} shape - * @returns {Schema.Schema, I>} + * @template {[Schema.Convert, ...Schema.Convert[]]} Shape + * @param {Shape} shape + * @returns {Schema.Schema, Schema.InferTupleInput>} */ export const tuple = shape => new Tuple(shape) /** * @template V * @template {string} K - * @template [I=unknown] - * @extends {API, I, { key: Schema.Reader, value: Schema.Reader }>} - * @implements {Schema.DictionarySchema} + * @template U + * @extends {API, Schema.Dictionary, { key: Schema.From, value: Schema.Convert }>} + * @implements {Schema.DictionarySchema} */ class Dictionary extends API { /** - * @param {I} input + * @param {Schema.Dictionary} input * @param {object} schema - * @param {Schema.Reader} schema.key - * @param {Schema.Reader} schema.value + * @param {Schema.From} schema.key + * @param {Schema.From} schema.value + * @param {Schema.Region} [context] */ - readWith(input, { key, value }) { + readWith(input, { key, value }, context) { if (typeof input != 'object' || input === null || Array.isArray(input)) { return typeError({ expect: 'dictionary', @@ -433,12 +626,42 @@ class Dictionary extends API { const dict = /** @type {Schema.Dictionary} */ ({}) for (const [k, v] of Object.entries(input)) { - const keyResult = key.read(k) + const keyResult = key.tryFrom(k, context) + if (keyResult.error) { + return memberError({ at: k, cause: keyResult.error }) + } + + const valueResult = value.tryFrom(v, context) + if (valueResult.error) { + return memberError({ at: k, cause: valueResult.error }) + } + + // skip undefined because they mess up CBOR and are generally useless. + if (valueResult.ok !== undefined) { + dict[keyResult.ok] = valueResult.ok + } + } + + return { ok: dict } + } + + /** + * + * @param {Schema.Dictionary} output + * @param {object} schema + * @param {Schema.From} schema.key + * @param {Schema.To} schema.value + */ + writeWith(output, { key, value }) { + const dict = /** @type {Schema.Dictionary} */ ({}) + + for (const [k, v] of Object.entries(output)) { + const keyResult = key.tryFrom(k) if (keyResult.error) { return memberError({ at: k, cause: keyResult.error }) } - const valueResult = value.read(v) + const valueResult = value.tryTo(/** @type {V} */ (v)) if (valueResult.error) { return memberError({ at: k, cause: valueResult.error }) } @@ -460,10 +683,11 @@ class Dictionary extends API { partial() { const { key, value } = this.settings - return new Dictionary({ + const partial = new Dictionary({ key, value: optional(value), }) + return /** @type {*} */ (partial) } toString() { return `dictionary(${this.settings})` @@ -473,37 +697,48 @@ class Dictionary extends API { /** * @template {string} K * @template {unknown} V - * @template [I=unknown] + * @template {unknown} U * @param {object} shape - * @param {Schema.Reader} shape.value - * @param {Schema.Reader} [shape.key] - * @returns {Schema.DictionarySchema} + * @param {Schema.Convert} shape.value + * @param {Schema.From} [shape.key] + * @returns {Schema.DictionarySchema} */ export const dictionary = ({ value, key }) => new Dictionary({ value, - key: key || /** @type {Schema.Reader} */ (string()), + key: key || /** @type {Schema.From} */ (string()), }) /** - * @template {[unknown, ...unknown[]]} T - * @template [I=unknown] - * @extends {API}>} - * @implements {Schema.Schema} + * @template {string} T + * @template {[T, ...T[]]} U + * @extends {API}>} + * @implements {Schema.Schema} */ class Enum extends API { /** - * @param {I} input - * @param {{type:string, variants:Set}} settings - * @returns {Schema.ReadResult} + * @param {string} input + * @param {{type:string, variants:Set}} settings + * @returns {Schema.ReadResult} */ readWith(input, { variants, type }) { - if (variants.has(input)) { - return /** @type {Schema.ReadResult} */ ({ ok: input }) + if (variants.has(/** @type {T} */ (input))) { + return /** @type {Schema.ReadResult} */ ({ ok: input }) } else { return typeError({ expect: type, actual: input }) } } + /** + * @param {U[number]} output + * @param {{type:string, variants:Set}} settings + */ + writeWith(output, settings) { + if (settings.variants.has(output)) { + return { ok: output } + } else { + return typeError({ expect: settings.type, actual: output }) + } + } toString() { return this.settings.type } @@ -514,7 +749,7 @@ class Enum extends API { * @template {[T, ...T[]]} U * @template [I=unknown] * @param {U} variants - * @returns {Schema.Schema} + * @returns {Schema.Schema} */ const createEnum = variants => new Enum({ @@ -524,73 +759,90 @@ const createEnum = variants => export { createEnum as enum } /** - * @template {Schema.Reader} T - * @template {[T, ...T[]]} U - * @template [I=unknown] - * @extends {API, I, U>} - * @implements {Schema.Schema, I>} + * @template {[Schema.Convert, ...Schema.Convert[]]} Members + * @extends {API, Schema.InferUnionInput, Members>} + * @implements {Schema.Schema, Schema.InferUnionInput>} */ class Union extends API { /** - * @param {I} input - * @param {U} variants + * @param {Schema.InferUnionInput} input + * @param {Members} variants + * @param {Schema.Region} [context] */ - readWith(input, variants) { + readWith(input, variants, context) { const causes = [] for (const reader of variants) { - const result = reader.read(input) + const result = reader.tryFrom(input, context) + if (result.error) { + causes.push(result.error) + } else { + return /** @type {Schema.ReadResult>} */ ( + result + ) + } + } + return { error: new UnionError({ causes }) } + } + /** + * @param {Schema.InferUnion} output + * @param {Members} variants + */ + writeWith(output, variants) { + const causes = [] + for (const member of variants) { + const result = member.tryTo(output) if (result.error) { causes.push(result.error) } else { - return /** @type {Schema.ReadResult>} */ (result) + return /** @type {Schema.ReadResult>} */ ( + result + ) } } return { error: new UnionError({ causes }) } } - get variants() { + get members() { return this.settings } toString() { - return `union([${this.variants.map(type => type.toString()).join(', ')}])` + return `union([${this.members + .map(member => member.toString()) + .join(', ')}])` } } /** - * @template {Schema.Reader} T - * @template {[T, ...T[]]} U - * @template [I=unknown] - * @param {U} variants - * @returns {Schema.Schema, I>} + * @template {[Schema.Convert, ...Schema.Convert[]]} Members + * @param {Members} members + * @returns {Schema.Schema, Schema.InferUnionInput>} */ -const union = variants => new Union(variants) +const union = members => new Union(members) /** - * @template T, U - * @template [I=unknown] - * @param {Schema.Reader} left - * @param {Schema.Reader} right - * @returns {Schema.Schema} + * @template O, Q, I, J + * @param {Schema.Convert} left + * @param {Schema.Convert} right + * @returns {Schema.Schema} */ export const or = (left, right) => union([left, right]) /** - * @template {Schema.Reader} T - * @template {[T, ...T[]]} U - * @template [I=unknown] - * @extends {API, I, U>} - * @implements {Schema.Schema, I>} + * @template {[Schema.Convert, ...Schema.Convert[]]} Members + * @extends {API, Schema.InferIntersectionInput, Members>} + * @implements {Schema.Schema, Schema.InferIntersectionInput>} */ class Intersection extends API { /** - * @param {I} input - * @param {U} schemas - * @returns {Schema.ReadResult>} + * @param {Schema.InferIntersectionInput} input + * @param {Members} schemas + * @param {Schema.Region} [context] + * @returns {Schema.ReadResult>} */ - readWith(input, schemas) { + readWith(input, schemas, context) { const causes = [] for (const schema of schemas) { - const result = schema.read(input) + const result = schema.tryFrom(input, context) if (result.error) { causes.push(result.error) } @@ -598,44 +850,86 @@ class Intersection extends API { return causes.length > 0 ? { error: new IntersectionError({ causes }) } - : /** @type {Schema.ReadResult>} */ ({ + : /** @type {Schema.ReadResult>} */ ({ ok: input, }) } + /** + * @param {Schema.InferIntersection} output + * @param {Members} members + * @returns {Schema.ReadResult>} + */ + writeWith(output, members) { + const causes = [] + for (const member of members) { + const result = member.tryTo(output) + if (result.error) { + causes.push(result.error) + } + } + + return causes.length > 0 + ? { error: new IntersectionError({ causes }) } + : /** @type {Schema.ReadResult>} */ ({ + ok: output, + }) + } + get members() { + return this.settings + } toString() { - return `intersection([${this.settings - .map(type => type.toString()) + return `intersection([${this.members + .map(member => member.toString()) .join(',')}])` } } /** - * @template {Schema.Reader} T - * @template {[T, ...T[]]} U - * @template [I=unknown] - * @param {U} variants - * @returns {Schema.Schema, I>} + * @template {[Schema.Convert, ...Schema.Convert[]]} Members + * @param {Members} members + * @returns {Schema.Schema, Schema.InferIntersectionInput>} */ -export const intersection = variants => new Intersection(variants) +export const intersection = members => new Intersection(members) /** - * @template T, U - * @template [I=unknown] - * @param {Schema.Reader} left - * @param {Schema.Reader} right - * @returns {Schema.Schema} + * @template O, Q, I, J + * @param {Schema.Convert} left + * @param {Schema.Convert} right + * @returns {Schema.Schema} */ export const and = (left, right) => intersection([left, right]) /** - * @template [I=unknown] - * @extends {API} + * @template {string|number|boolean|null} Type + * @template {string} Name + * @extends {API}>} */ -class Boolean extends API { +class Scalar extends API { /** - * @param {I} input + * @param {Type} input + * @param {typeof this.settings} settings */ - readWith(input) { + readWith(input, { cast }) { + return cast(input) + } + /** + * @param {Type} output + * @param {typeof this.settings} settings + */ + writeWith(output, { cast }) { + return cast(output) + } + toString() { + return `${this.settings.name}()` + } +} + +export const Boolean = new Scalar({ + name: 'boolean', + /** + * @param {boolean} input + */ + cast(input) { switch (input) { case true: case false: @@ -646,229 +940,290 @@ class Boolean extends API { actual: input, }) } - } - - toString() { - return `boolean()` - } -} - -/** @type {Schema.Schema} */ -const anyBoolean = new Boolean() - -export const boolean = () => anyBoolean + }, +}) +export const boolean = () => Boolean /** - * @template {number} [O=number] - * @template [I=unknown] + * @template {number} Out + * @template {number} In * @template [Settings=void] - * @extends {API} - * @implements {Schema.NumberSchema} + * @extends {API} + * @implements {Schema.NumberSchema} */ -class UnknownNumber extends API { +class NumberSchema extends API { + isInteger = globalThis.Number.isInteger + isFinite = globalThis.Number.isFinite + + /** + * @param {(input: Out) => Schema.ReadResult} check + * @returns {Schema.NumberSchema} + */ + constraint(check) { + return this.refine({ + tryFrom: check, + tryTo: check, + }) + } + /** * @param {number} n */ greaterThan(n) { - return this.refine(greaterThan(n)) + return this.constraint(number => { + if (number > n) { + return { ok: number } + } else { + return error(`Expected ${number} > ${n}`) + } + }) } /** * @param {number} n */ lessThan(n) { - return this.refine(lessThan(n)) + return this.constraint(number => { + if (number < n) { + return { ok: number } + } else { + return error(`Expected ${number} < ${n}`) + } + }) } /** - * @template {O} U - * @param {Schema.Reader} schema - * @returns {Schema.NumberSchema} + * @template {Out} O + * @template {Out} I + * @param {Schema.Convert} convert + * @returns {Schema.NumberSchema} */ - refine(schema) { - return new RefinedNumber({ base: this, schema }) + refine(convert) { + return new RefinedNumber({ schema: this, refine: convert }) } -} -/** - * @template [I=unknown] - * @extends {UnknownNumber} - * @implements {Schema.NumberSchema} - */ -class AnyNumber extends UnknownNumber { /** - * @param {I} input - * @returns {Schema.ReadResult} + * @param {In} input + * @param {Settings} settings + * @returns {Schema.ReadResult} */ - readWith(input) { + readWith(input, settings) { return typeof input === 'number' - ? { ok: input } + ? { ok: /** @type {*} */ (input) } : typeError({ expect: 'number', actual: input }) } + /** + * @param {Out} output + * @param {Settings} settings + * @returns {Schema.ReadResult} + */ + writeWith(output, settings) { + return typeof output === 'number' + ? { ok: /** @type {*} */ (output) } + : typeError({ expect: 'number', actual: output }) + } toString() { return `number()` } } -/** @type {Schema.NumberSchema} */ -const anyNumber = new AnyNumber() -export const number = () => anyNumber +/** @type {Schema.NumberSchema} */ +export const Number = new NumberSchema(undefined) +export const number = () => Number /** - * @template {number} [T=number] - * @template {T} [O=T] - * @template [I=unknown] - * @extends {UnknownNumber, schema:Schema.Reader}>} - * @implements {Schema.NumberSchema} + * @template {number} Out + * @template {number} In + * @template {Out} O + * @template {Out} I + * @extends {NumberSchema, refine:Schema.From}>} + * @implements {Schema.NumberSchema} */ -class RefinedNumber extends UnknownNumber { +class RefinedNumber extends NumberSchema { /** - * @param {I} input - * @param {{base:Schema.Reader, schema:Schema.Reader}} settings + * @param {In} input + * @param {{schema: Schema.Convert, refine: Schema.Convert } } settings * @returns {Schema.ReadResult} */ - readWith(input, { base, schema }) { - const result = base.read(input) - return result.error ? result : schema.read(result.ok) + readWith(input, { schema, refine }) { + const result = schema.tryFrom(input) + return result.error ? result : refine.tryFrom(/** @type {I} */ (result.ok)) } - toString() { - return `${this.settings.base}.refine(${this.settings.schema})` - } -} -/** - * @template {number} T - * @extends {API} - */ -class LessThan extends API { /** - * @param {T} input - * @param {number} number - * @returns {Schema.ReadResult} + * @param {O} output + * @param {{schema: Schema.Convert, refine: Schema.Convert } } settings */ - readWith(input, number) { - if (input < number) { - return { ok: input } - } else { - return error(`Expected ${input} < ${number}`) - } + writeWith(output, { schema, refine }) { + const result = refine.tryTo(output) + return result.error ? result : schema.tryTo(result.ok) } - toString() { - return `lessThan(${this.settings})` - } -} - -/** - * @template {number} T - * @param {number} n - * @returns {Schema.Schema} - */ -export const lessThan = n => new LessThan(n) -/** - * @template {number} T - * @extends {API} - */ -class GreaterThan extends API { - /** - * @param {T} input - * @param {number} number - * @returns {Schema.ReadResult} - */ - readWith(input, number) { - if (input > number) { - return { ok: input } - } else { - return error(`Expected ${input} > ${number}`) - } - } toString() { - return `greaterThan(${this.settings})` + return `${this.settings.schema}.refine(${this.settings.refine})` } } /** - * @template {number} T - * @param {number} n - * @returns {Schema.Schema} + * @extends {NumberSchema} */ -export const greaterThan = n => new GreaterThan(n) - -const Integer = { +class IntegerSchema extends NumberSchema { /** - * @param {number} input + * @param {number} number * @returns {Schema.ReadResult} */ - read(input) { - return Number.isInteger(input) - ? { ok: /** @type {Schema.Integer} */ (input) } + static validate(number) { + return Number.isInteger(number) + ? { ok: /** @type {Schema.Integer} */ (number) } : typeError({ expect: 'integer', - actual: input, + actual: number, }) - }, + } + /** + * @param {number} number + * @returns {Schema.ReadResult} + */ + tryFrom(number) { + return IntegerSchema.validate(number) + } + /** + * @param {Schema.Integer} number + * @returns {Schema.ReadResult} + */ + tryTo(number) { + return IntegerSchema.validate(number) + } toString() { return `Integer` - }, + } } +/** @type {Schema.NumberSchema} */ +const Integer = new IntegerSchema() +export const integer = () => Integer -const anyInteger = anyNumber.refine(Integer) -export const integer = () => anyInteger - -const Float = { +/** + * @extends {NumberSchema} + */ +class FloatSchema extends NumberSchema { /** * @param {number} number * @returns {Schema.ReadResult} */ - read(number) { + static validate(number) { return Number.isFinite(number) ? { ok: /** @type {Schema.Float} */ (number) } : typeError({ expect: 'Float', actual: number, }) - }, + } + /** + * @param {number} number + * @returns {Schema.ReadResult} + */ + tryFrom(number) { + return FloatSchema.validate(number) + } + /** + * @param {Schema.Float} number + * @returns {Schema.ReadResult} + */ + tryTo(number) { + return FloatSchema.validate(number) + } toString() { return 'Float' - }, + } } -const anyFloat = anyNumber.refine(Float) -export const float = () => anyFloat +const Float = new FloatSchema() +export const float = () => Float /** - * @template {string} [O=string] - * @template [I=unknown] + * @template {string} Out + * @template {string} In * @template [Settings=void] - * @extends {API} + * @extends {API} + * @implements {Schema.StringSchema} */ -class UnknownString extends API { +class StringSchema extends API { + /** + * @template {string} Source + * @param {Source} source + * @returns {Schema.ReadResult} + */ + static validate(source) { + return typeof source === 'string' + ? { ok: source } + : typeError({ expect: 'string', actual: source }) + } + /** + * @param {In} input + * @param {Settings} settings + * @returns {Schema.ReadResult} + */ + readWith(input, settings) { + return StringSchema.validate(/** @type {In & Out} */ (input)) + } + + /** + * @param {Out} input + * @param {Settings} settings + * @returns {Schema.ReadResult} + */ + writeWith(input, settings) { + return StringSchema.validate(/** @type {In & Out} */ (input)) + } + /** - * @template {O|unknown} U - * @param {Schema.Reader} schema - * @returns {Schema.StringSchema} + * @template {Out} O + * @template {Out} I + * @param {Schema.Convert} schema + * @returns {Schema.StringSchema} */ refine(schema) { - const other = /** @type {Schema.Reader} */ (schema) - const rest = new RefinedString({ + const refined = new RefinedString({ base: this, - schema: other, + schema, }) - return /** @type {Schema.StringSchema} */ (rest) + return /** @type {Schema.StringSchema} */ (refined) + } + + /** + * @param {(value: Out) => Schema.ReadResult} check + * @returns {Schema.StringSchema} + */ + constraint(check) { + return this.refine({ + tryFrom: check, + tryTo: check, + }) } /** * @template {string} Prefix * @param {Prefix} prefix */ startsWith(prefix) { - return this.refine(startsWith(prefix)) + const constraint = + /** @type {Schema.Convert} */ ( + startsWith(prefix) + ) + + return this.refine(constraint) } + /** * @template {string} Suffix * @param {Suffix} suffix */ endsWith(suffix) { - return this.refine(endsWith(suffix)) + const constraint = + /** @type {Schema.Convert} */ ( + endsWith(suffix) + ) + + return this.refine(constraint) } toString() { return `string()` @@ -876,86 +1231,57 @@ class UnknownString extends API { } /** - * @template O - * @template {string} [T=string] - * @template [I=unknown] - * @extends {UnknownString, schema:Schema.Reader}>} - * @implements {Schema.StringSchema} + * @template {string} Out + * @template {string} In + * @template {Out} O + * @template {Out} I + * @extends {StringSchema, schema:Schema.Convert}>} + * @implements {Schema.StringSchema} */ -class RefinedString extends UnknownString { +class RefinedString extends StringSchema { /** - * @param {I} input - * @param {{base:Schema.Reader, schema:Schema.Reader}} settings - * @returns {Schema.ReadResult} + * @param {In} input + * @param {{base:Schema.From, schema:Schema.From}} settings + * @returns {Schema.ReadResult} */ readWith(input, { base, schema }) { - const result = base.read(input) - return result.error - ? result - : /** @type {Schema.ReadResult} */ (schema.read(result.ok)) - } - toString() { - return `${this.settings.base}.refine(${this.settings.schema})` + const result = base.tryFrom(input) + return result.error ? result : schema.tryFrom(/** @type {I} */ (result.ok)) } -} -/** - * @template [I=unknown] - * @extends {UnknownString} - * @implements {Schema.StringSchema} - */ -class AnyString extends UnknownString { /** - * @param {I} input - * @returns {Schema.ReadResult} + * @param {O} output + * @param {{base:Schema.To, schema:Schema.To}} settings + * @returns {Schema.ReadResult} */ - readWith(input) { - return typeof input === 'string' - ? { ok: input } - : typeError({ expect: 'string', actual: input }) + writeWith(output, { base, schema }) { + const result = schema.tryTo(output) + return result.error ? result : base.tryTo(result.ok) } -} -/** @type {Schema.StringSchema} */ -const anyString = new AnyString() -export const string = () => anyString - -/** - * @template [I=unknown] - * @extends {API} - */ -class BytesSchema extends API { - /** - * @param {I} input - * @returns {Schema.ReadResult} - */ - readWith(input) { - if (input instanceof Uint8Array) { - return { ok: input } - } else { - return typeError({ expect: 'Uint8Array', actual: input }) - } + toString() { + return `${this.settings.base}.refine(${this.settings.schema})` } } -/** @type {Schema.Schema} */ -export const Bytes = new BytesSchema() -export const bytes = () => Bytes +/** @type {Schema.StringSchema} */ +export const String = new StringSchema(undefined) +export const string = () => String /** * @template {string} Prefix - * @template {string} Body - * @extends {API} - * @implements {Schema.Schema} + * @template {string} In + * @extends {API<`${Prefix}${string}` & In, In, Prefix>} + * @implements {Schema.Schema<`${Prefix}${string}` & In, In>} */ class StartsWith extends API { /** - * @param {Body} input + * @param {In} input * @param {Prefix} prefix */ readWith(input, prefix) { const result = input.startsWith(prefix) - ? /** @type {Schema.ReadResult} */ ({ + ? /** @type {Schema.ReadResult<`${Prefix}${string}` & In>} */ ({ ok: input, }) : error(`Expect string to start with "${prefix}" instead got "${input}"`) @@ -972,16 +1298,16 @@ class StartsWith extends API { /** * @template {string} Prefix - * @template {string} Body + * @template {string} Input * @param {Prefix} prefix - * @returns {Schema.Schema<`${Prefix}${string}`, string>} + * @returns {Schema.Schema} */ export const startsWith = prefix => new StartsWith(prefix) /** * @template {string} Suffix * @template {string} Body - * @extends {API} + * @extends {API} */ class EndsWith extends API { /** @@ -990,7 +1316,7 @@ class EndsWith extends API { */ readWith(input, suffix) { return input.endsWith(suffix) - ? /** @type {Schema.ReadResult} */ ({ + ? /** @type {Schema.ReadResult} */ ({ ok: input, }) : error(`Expect string to end with "${suffix}" instead got "${input}"`) @@ -1005,27 +1331,40 @@ class EndsWith extends API { /** * @template {string} Suffix + * @template {string} Input * @param {Suffix} suffix - * @returns {Schema.Schema<`${string}${Suffix}`, string>} + * @returns {Schema.Schema<`${string}${Suffix}` & Input, Input>} */ export const endsWith = suffix => new EndsWith(suffix) /** - * @template T - * @template {T} U - * @template [I=unknown] - * @extends {API, schema: Schema.Reader }>} - * @implements {Schema.Schema} + * @template Out + * @template {Out} O + * @template {Out} I + * @template In + * @extends {API, schema: Schema.Convert }>} + * @implements {Schema.Schema} */ class Refine extends API { /** - * @param {I} input - * @param {{ base: Schema.Reader, schema: Schema.Reader }} settings + * @param {In} input + * @param {{ base: Schema.Convert, schema: Schema.Convert }} settings + * @param {Schema.Region} [context] */ - readWith(input, { base, schema }) { - const result = base.read(input) - return result.error ? result : schema.read(result.ok) + readWith(input, { base, schema }, context) { + const result = base.tryFrom(input, context) + return result.error + ? result + : schema.tryFrom(/** @type {I} */ (result.ok), context) + } + /** + * @param {O} output + * @param {{ base: Schema.Convert, schema: Schema.Convert }} settings + */ + writeWith(output, { base, schema }) { + const result = schema.tryTo(output) + return result.error ? result : base.tryTo(/** @type {Out} */ (result.ok)) } toString() { return `${this.settings.base}.refine(${this.settings.schema})` @@ -1033,26 +1372,67 @@ class Refine extends API { } /** - * @template T - * @template {T} U - * @template [I=unknown] - * @param {Schema.Reader} base - * @param {Schema.Reader} schema - * @returns {Schema.Schema} + * @template {Out} O + * @template {Out} I + * @template Out + * @template In + * @param {Schema.Convert} base + * @param {Schema.Convert} schema + * @returns {Schema.Schema} */ export const refine = (base, schema) => new Refine({ base, schema }) /** - * @template {null|boolean|string|number} T - * @template [I=unknown] - * @extends {API} - * @implements {Schema.LiteralSchema} + * @template Into + * @template Out + * @template In + * @extends {API, to: Schema.Convert }>} + * @implements {Schema.Schema} + */ +class Pipe extends API { + /** + * @param {In} input + * @param {{ from: Schema.From, to: Schema.From }} settings + * @param {Schema.Region} [context] + */ + readWith(input, { from, to }, context) { + const result = from.tryFrom(input, context) + return result.error ? result : to.tryFrom(result.ok, context) + } + /** + * @param {Into} output + * @param {{ from: Schema.To, to: Schema.To }} settings + */ + writeWith(output, { from, to }) { + const result = to.tryTo(output) + return result.error ? result : from.tryTo(result.ok) + } + toString() { + return `${this.settings.from}.pipe(${this.settings.to})` + } +} + +/** + * @template Into + * @template Out + * @template In + * @param {Schema.Convert} from + * @param {Schema.Convert} to + * @returns {Schema.Schema} + */ +export const pipe = (from, to) => new Pipe({ from, to }) + +/** + * @template {null|boolean|string|number} Out + * @template {null|boolean|string|number} In + * @extends {API} + * @implements {Schema.LiteralSchema} */ class Literal extends API { /** - * @param {I} input - * @param {T} expect - * @returns {Schema.ReadResult} + * @param {In} input + * @param {Out} expect + * @returns {Schema.ReadResult} */ readWith(input, expect) { return input !== /** @type {unknown} */ (expect) @@ -1060,14 +1440,13 @@ class Literal extends API { : { ok: expect } } get value() { - return /** @type {Exclude} */ (this.settings) + return /** @type {Exclude} */ (this.settings) } /** - * @template {Schema.NotUndefined} U - * @param {U} value + * @param {Out} value */ - default(value = /** @type {U} */ (this.value)) { - return super.default(value) + implicit(value = this.value) { + return implicit(this, /** @type {Exclude} */ (value)) } toString() { return `literal(${displayTypeName(this.value)})` @@ -1075,25 +1454,26 @@ class Literal extends API { } /** - * @template {null|boolean|string|number} T - * @template [I=unknown] - * @param {T} value - * @returns {Schema.LiteralSchema} + * @template {null|boolean|string|number} Out + * @template {null|boolean|string|number} In + * @param {Out} value + * @returns {Schema.LiteralSchema} */ export const literal = value => new Literal(value) /** - * @template {{[key:string]: Schema.Reader}} U - * @template [I=unknown] - * @extends {API, I, U>} + * @template {Schema.StructMembers} Members + * @extends {API, Schema.InferStructInput, {shape: Members}>} + * @implements {Schema.StructSchema} */ class Struct extends API { /** - * @param {I} input - * @param {U} shape - * @returns {Schema.ReadResult>} + * @param {Schema.InferStructInput} input + * @param {{shape: Members}} settings + * @param {Schema.Region} [context] + * @returns {Schema.ReadResult>} */ - readWith(input, shape) { + readWith(input, { shape }, context) { if (typeof input != 'object' || input === null || Array.isArray(input)) { return typeError({ expect: 'object', @@ -1101,22 +1481,23 @@ class Struct extends API { }) } - const source = /** @type {{[K in keyof U]: unknown}} */ (input) + const source = /** @type {{[K in keyof Members]: unknown}} */ (input) - const struct = /** @type {{[K in keyof U]: Schema.Infer}} */ ({}) + const struct = + /** @type {{[K in keyof Members]: Schema.Infer}} */ ({}) const entries = - /** @type {{[K in keyof U]: [K & string, U[K]]}[keyof U][]} */ ( + /** @type {{[K in keyof Members]: [K & string, Members[K]]}[keyof Members][]} */ ( Object.entries(shape) ) for (const [at, reader] of entries) { - const result = reader.read(source[at]) + const result = reader.tryFrom(source[at], context) if (result.error) { return memberError({ at, cause: result.error }) } // skip undefined because they mess up CBOR and are generally useless. else if (result.ok !== undefined) { - struct[at] = /** @type {Schema.Infer} */ (result.ok) + struct[at] = /** @type {Schema.Infer} */ (result.ok) } } @@ -1124,20 +1505,54 @@ class Struct extends API { } /** - * @returns {Schema.MapRepresentation>> & Schema.StructSchema} + * @param {Schema.InferStruct} output + * @param {{shape: Members}} settings */ - partial() { - return new Struct( - Object.fromEntries( - Object.entries(this.shape).map(([key, value]) => [key, optional(value)]) + + writeWith(output, { shape }) { + if (typeof output != 'object' || output === null || Array.isArray(output)) { + return typeError({ + expect: 'object', + actual: output, + }) + } + + const source = /** @type {{[K in keyof Members]: unknown}} */ (output) + + const input = + /** @type {{[K in keyof Members]: Schema.InferInput}} */ ({}) + const entries = + /** @type {{[K in keyof Members]: [K & string, Members[K]]}[keyof Members][]} */ ( + Object.entries(shape) ) + + for (const [at, writer] of entries) { + const result = writer.tryTo(source[at]) + if (result.error) { + return memberError({ at, cause: result.error }) + } + // skip undefined because they mess up CBOR and are generally useless. + else if (result.ok !== undefined) { + input[at] = /** @type {Schema.InferInput} */ ( + result.ok + ) + } + } + + return { ok: input } + } + + partial() { + const shape = Object.fromEntries( + Object.entries(this.shape).map(([key, value]) => [key, optional(value)]) ) + + return /** @type {*} */ (new Struct({ shape })) } - /** @type {U} */ + /** @type {Members} */ get shape() { - // @ts-ignore - We declared `settings` private but we access it here - return this.settings + return this.settings.shape } toString() { @@ -1151,34 +1566,32 @@ class Struct extends API { } /** - * @param {Schema.InferStructSource} data + * @param {Schema.InferStructSource} data */ create(data) { - return this.from(data || {}) + return this.from(/** @type {*} */ (data || {})) } /** - * @template {{[key:string]: Schema.Reader}} E + * @template {Schema.StructMembers} E * @param {E} extension - * @returns {Schema.StructSchema} + * @returns {Schema.StructSchema} */ extend(extension) { - return new Struct({ ...this.shape, ...extension }) + return new Struct({ shape: { ...this.shape, ...extension } }) } } /** * @template {null|boolean|string|number} T - * @template {{[key:string]: T|Schema.Reader}} U - * @template {{[K in keyof U]: U[K] extends Schema.Reader ? U[K] : Schema.LiteralSchema}} V - * @template [I=unknown] + * @template {{[key:string]: T|Schema.Convert}} U + * @template {{[K in keyof U]: U[K] extends Schema.Convert ? U[K] : Schema.Convert}} Members * @param {U} fields - * @returns {Schema.StructSchema} + * @returns {Schema.StructSchema} */ export const struct = fields => { - const shape = - /** @type {{[K in keyof U]: Schema.Reader}} */ ({}) - /** @type {[keyof U & string, T|Schema.Reader][]} */ + const shape = /** @type {{[K in keyof U]: Schema.Convert}} */ ({}) + /** @type {[keyof U & string, T|Schema.Convert][]} */ const entries = Object.entries(fields) for (const [key, field] of entries) { @@ -1198,22 +1611,761 @@ export const struct = fields => { } } - return new Struct(/** @type {V} */ (shape)) + return /** @type {*} */ (new Struct({ shape: shape })) } /** - * @template {Schema.VariantChoices} U - * @template [I=unknown] - * @extends {API, I, U>} - * @implements {Schema.VariantSchema} + * @extends {API} + * @implements {Schema.BytesSchema>} + */ +class RawBytes extends API { + name = 'raw' + code = /** @type {const} */ (0x55) + + /** + * @param {Uint8Array} input + */ + encode(input) { + if (input instanceof Uint8Array) { + return input + } else { + throw typeError({ expect: 'Uint8Array', actual: input }).error + } + } + + /** + * @param {Uint8Array} output + */ + decode(output) { + if (output instanceof Uint8Array) { + return output + } else { + throw typeError({ expect: 'Uint8Array', actual: output }).error + } + } + /** + * @param {Uint8Array} input + * @returns {Schema.ReadResult} + */ + readWith(input) { + if (input instanceof Uint8Array) { + return { ok: input } + } else { + return typeError({ expect: 'Uint8Array', actual: input }) + } + } + + /** + * @template {Uint8Array} O + * @template {Uint8Array} I + * @param {Schema.Convert} into + * @returns {Schema.BytesSchema} + */ + refine(into) { + const codec = /** @type {Schema.BlockCodec<0x55, * & I>} */ (this) + return new ByteView({ codec, convert: into }) + } +} + +/** @type {Schema.BytesSchema} */ +export const Bytes = new RawBytes() + +/** + * @type {Schema.Convert<*>} + */ +const direct = { + tryFrom: input => ({ ok: input }), + tryTo: output => ({ ok: output }), +} + +/** + * @template {Schema.BlockCodec} [Codec=import('multiformats/codecs/raw')] + * @param {Codec} [codec] + * @returns {Schema.BytesSchema & ({} | null), Schema.MulticodecCode>} + */ +export const bytes = codec => + /** @type {*} */ (codec ? new ByteView({ codec, convert: direct }) : Bytes) + +/** + * @template {{}|null} Model + * @template {Model} Out + * @template {Schema.MulticodecCode} Code + * @extends {API, Schema.ByteSchemaSettings>} + * @implements {Schema.BytesSchema} + */ +class ByteView extends API { + get codec() { + return this.settings.codec + } + get name() { + return this.codec.name + } + get code() { + return this.codec.code + } + /** + * @param {Out} data + */ + encode(data) { + const { codec, convert } = this.settings + const model = convert.tryFrom(data) + if (model.error) { + throw model.error + } else { + return /** @type {Schema.ByteView} */ (codec.encode(model.ok)) + } + } + /** + * @param {Schema.ByteView} bytes + */ + decode(bytes) { + const { codec, convert } = this.settings + const model = codec.decode(bytes) + const result = convert.tryFrom(model) + if (result.error) { + throw result.error + } else { + return result.ok + } + } + /** + * @param {Uint8Array} input + * @param {Schema.ByteSchemaSettings} settings + * @returns {Schema.ReadResult} + */ + readWith(input, { codec, convert }) { + try { + const model = codec.decode(input) + return convert.tryFrom(model) + } catch (cause) { + return { error: /** @type {Error} */ (cause) } + } + } + /** + * + * @param {Out} output + * @param {Schema.ByteSchemaSettings} settings + * @returns {Schema.ReadResult>} + */ + writeWith(output, { codec, convert }) { + try { + const result = convert.tryTo(output) + if (result.error) { + throw result.error + } + return { + ok: /** @type {Schema.ByteView} */ (codec.encode(result.ok)), + } + } catch (cause) { + return { error: /** @type {Error} */ (cause) } + } + } + + /** + * @template {Out} O + * @template {Out} I + * @param {Schema.Convert} into + * @returns {Schema.BytesSchema} + */ + refine(into) { + const { codec, convert: from } = this.settings + const convert = + from === direct + ? // if convertor is direct, we don't need to compose + /** @type {Schema.Convert} */ (into) + : pipe(/** @type {Schema.Convert<* & I, Model>} */ (from), into) + + return new ByteView({ + codec, + convert, + }) + } + + /** + * @template {Schema.BlockCodec} Codec + * @template {Schema.MultihashHasher} Hasher + * @template {Schema.UnknownLink['version']} Version + * @param {{ + * codec?: Codec + * hasher?: Hasher + * version?: Version + * }} options + * @returns {Schema.LinkSchema} + */ + link(options) { + return link({ + codec: this.codec, + ...options, + schema: this.settings.convert, + }) + } +} + +const emptyStore = Object.freeze(new Map()) +/** + * @template {{}|null} T + * @param {object} options + * @param {Schema.BlockCodec} options.codec + * @returns {Schema.Schema}>} + */ +export const dag = options => { + throw new Error('Not implemented') +} + +/** + * @template {unknown} Out + * @template {unknown} In + * @template {Schema.MulticodecCode} Code + * @template {Schema.MulticodecCode} Alg + * @template {Schema.UnknownLink['version']} V + * @implements {Schema.LinkOf} + */ +class Link { + /** + * @param {object} source + * @param {Schema.Link} source.link + * @param {Schema.BlockCodec} [source.codec] + * @param {Schema.Convert} source.schema + * @param {Schema.Region} [source.store] + */ + constructor({ link, codec, schema }) { + this.codec = codec + this.cid = link + this.schema = schema + + this['/'] = link.bytes + this.version = link.version + this.multihash = link.multihash + this.code = link.code + this.bytes = link.bytes + + Object.defineProperties(this, { + codec: { enumerable: false }, + cid: { enumerable: false }, + schema: { enumerable: false }, + }) + } + + /** + * @param {Schema.Region} region + * @returns {Schema.Attachment} + */ + select(region) { + const { cid, codec, schema } = this + const block = region.get(`${cid}`) + const bytes = block + ? block.bytes + : cid.multihash.code === identity.code + ? cid.multihash.digest + : undefined + + if (bytes) { + return new Attachment({ + root: { cid, bytes }, + codec, + schema, + store: region, + }) + } else { + throw new RangeError( + `Block can not be resolved, please provide a store from which to resolve it` + ) + } + } + + /** + * @param {Schema.Region} region + * @returns {Schema.ResolvedLink} + */ + resolve(region) { + return this.select(region).resolve() + } + // get version() { + // return this.cid.version + // } + // get code() { + // return this.cid.code + // } + // get multihash() { + // return this.cid.multihash + // } + get byteOffset() { + return this.cid.byteOffset + } + get byteLength() { + return this.cid.byteLength + } + // get bytes() { + // return this.cid.bytes + // } + link() { + return this.cid + } + /** + * @param {unknown} other + * @returns {other is Schema.Link} + */ + equals(other) { + return this.cid.equals(other) + } + /** + * @template {string} Prefix + * @param {Schema.MultibaseEncoder} [base] + * @returns + */ + toString(base) { + return this.cid.toString(base) + } + toJSON() { + return { '/': this.toString() } + } + get [Symbol.toStringTag]() { + return 'CID' + } + [Symbol.for('nodejs.util.inspect.custom')]() { + return `CID(${this.toString()})` + } + toV1() { + return this.cid.toV1() + } +} + +/** + * @template {unknown} Out + * @template {unknown} In + * @template {Schema.MulticodecCode} Code + * @template {Schema.MulticodecCode} Alg + * @template {Schema.UnknownLink['version']} V + * @implements {Schema.Attachment} + */ +class Attachment { + /** + * @param {object} source + * @param {Schema.Block} source.root + * @param {Schema.BlockCodec} [source.codec] + * @param {Schema.Convert} source.schema + * @param {Schema.Region} [source.store] + */ + constructor({ root, codec, schema, store = emptyStore }) { + this.codec = codec + this.root = root + this.store = store + this.schema = schema + this['/'] = root.cid.bytes + + /** @type {Schema.ReadResult|null} */ + this._resolved = null + } + + /** + * @returns {Schema.ResolvedLink} + */ + resolve() { + let result = this._resolved + if (result == null) { + const { schema, codec = CBOR, root } = this + const data = codec.decode(/** @type {Uint8Array} */ (root.bytes)) + result = schema.tryFrom(data, this.store) + + this._resolved = result + } + + if (result.error) { + throw result.error + } else { + return /** @type {Schema.ResolvedLink} */ (result.ok) + } + } + get version() { + return this.root.cid.version + } + get code() { + return this.root.cid.code + } + get multihash() { + return this.root.cid.multihash + } + get byteOffset() { + return this.root.cid.byteOffset + } + get byteLength() { + return this.root.cid.byteLength + } + get bytes() { + return this.root.cid.bytes + } + link() { + return this.root.cid + } + /** + * @param {unknown} other + * @returns {other is Schema.Link} + */ + equals(other) { + return this.root.cid.equals(other) + } + /** + * @template {string} Prefix + * @param {Schema.MultibaseEncoder} [base] + * @returns + */ + toString(base) { + return this.root.cid.toString(base) + } + toV1() { + return this.root.cid.toV1() + } + /** + * @param {object} context + * @param {Schema.Region} [context.store] + */ + with({ store = this.store }) { + return new Attachment({ + root: this.root, + codec: this.codec, + schema: this.schema, + store, + }) + } + + *iterateIPLDBlocks() { + const dag = this.resolve() + const { store, root } = this + for (const link of Attachment.links(dag)) { + const block = store.get(`${link}`) + if (block) { + yield { cid: link, bytes: block.bytes } + } + } + + yield /** @type {Schema.Block} */ (root) + } + + /** + * @param {unknown} source + * @returns {IterableIterator} + */ + static *links(source) { + if (isLink(source)) { + yield /** @type {Schema.Link} */ (source) + } else if (source && typeof source === 'object') { + for (const value of Object.values(source)) { + yield* this.links(value) + } + } + } + + /** + * @param {unknown} source + */ + static *iterateIPLDBlocks(source) { + if (source && typeof source === 'object') { + for (const value of Object.values(source)) { + if (value && typeof value['iterateIPLDBlocks'] === 'function') { + yield* value.iterateIPLDBlocks() + } + } + } + } +} + +/** + * @template {unknown} [Out=unknown] + * @template {unknown} [In=number] + * @template {number} [Code=number] + * @template {number} [Alg=number] + * @template {1|0} [Version=0|1] + * @typedef {{ + * codec?: Schema.BlockCodec, + * version?: Version + * hasher?: {code: Alg} + * schema: Schema.Convert + * }} LinkSettings + */ + +/** + * @template {unknown} Out + * @template {unknown} In + * @template {Schema.MulticodecCode} Code + * @template {Schema.MulticodecCode} Alg + * @template {Schema.UnknownLink['version']} V + * @extends {API, Schema.IntoLink, LinkSettings>} + * @implements {Schema.LinkSchema} + */ +class LinkSchema extends API { + /** + * @template {unknown} Out + * @template {unknown} In + * @template {Schema.MulticodecCode} Code + * @template {Schema.MulticodecCode} Alg + * @template {Schema.UnknownLink['version']} V + * @param {Schema.IntoLink} source + * @param {LinkSettings} settings + * @returns {Schema.ReadResult>} + */ + static validate(source, { codec, hasher, version, schema }) { + if (source == null) { + return error(`Expected link but got ${source} instead`) + } else { + if (!isLink(source)) { + return error(`Expected link to be a CID instead of ${source}`) + } else { + if (codec && codec.code !== source.code) { + return error( + `Expected link to be CID with 0x${codec.code.toString(16)} codec` + ) + } + + if (hasher && hasher.code !== source.multihash.code) + return error( + `Expected link to be CID with 0x${hasher.code.toString( + 16 + )} hashing algorithm` + ) + + if (version != null && version !== source.version) { + return error( + `Expected link to be CID version ${version} instead of ${source.version}` + ) + } + + const link = /** @type {Schema.Link} */ (source) + return { ok: link } + } + } + } + + /** + * @param {Schema.IntoLink} cid + * @param {LinkSettings} settings + * @returns {Schema.ReadResult>} + */ + readWith(cid, { codec, hasher, version, schema }) { + const result = LinkSchema.validate(cid, { + codec, + hasher, + version, + schema, + }) + + if (result.ok) { + const link = result.ok + const ok = link instanceof Link ? link : new Link({ link, codec, schema }) + return { ok } + } else { + return result + } + } + + /** + * + * @param {Schema.LinkOf} source + * @param {LinkSettings} settings + * @returns {Schema.ReadResult>} + */ + writeWith(source, settings) { + return LinkSchema.validate(source.link(), settings) + } + + /** + * @returns {never} + */ + link() { + throw new Error('Can not create link of link') + } + + /** + * @returns {Schema.AttachmentSchema} + */ + attached() { + let attachment = this._attached + if (attachment == null) { + const attachment = new AttachmentSchema(this.settings) + this._attached = attachment + return attachment + } + + return attachment + } + + /** + * @param {Out} target + */ + embed(target) { + return this.attached().embed(target) + } + /** + * @param {Out} target + */ + attach(target) { + return this.attached().attach(target) + } + + /** + * @template {string} Prefix + * @param {string} input + * @param {Schema.MultibaseDecoder} [base] + */ + parse(input, base) { + const link = parseLink(input, base) + return this.from(/** @type {*} */ (link)) + } +} + +/** + * @template {unknown} Out + * @template {unknown} In + * @template {Schema.MulticodecCode} Code + * @template {Schema.MulticodecCode} Alg + * @template {Schema.UnknownLink['version']} V + * @extends {API, Schema.IPLDView, LinkSettings>} + * @implements {Schema.AttachmentSchema} + */ +class AttachmentSchema extends API { + /** + * @param {Schema.IPLDView} source + * @param {LinkSettings} settings + * @param {Schema.Region} [region] + */ + readWith(source, { codec, schema }, region = emptyStore) { + const link = /** @type {Schema.Link} */ (source.link()) + const block = source.root ? source.root : region.get(`${link}`) + /** @type {Uint8Array|undefined} */ + const bytes = block + ? block.bytes + : link.multihash.code === identity.code + ? link.multihash.digest + : undefined + + if (bytes) { + const attachment = new Attachment({ + root: { cid: link, bytes }, + codec, + schema, + store: region, + }) + return { ok: attachment } + } else { + return error(`Could not find block for ${link}`) + } + } + /** + * @param {Schema.Attachment} attachment + * @param {LinkSettings} settings + * @returns {Schema.ReadResult>} + */ + writeWith(attachment, settings) { + if (attachment instanceof Attachment) { + return { ok: attachment } + } + + console.log('!!!!!!!!!') + try { + const { codec, schema } = settings + const link = attachment.link() + const out = attachment.resolve() + const data = schema.tryTo(out) + if (data.error) { + return data + } else { + /** @type {Uint8Array} */ + const bytes = (codec || CBOR).encode(data.ok) + /** @type {Required>} */ + const root = { cid: link, bytes, data: out } + const view = new Attachment({ root, codec, schema }) + return { ok: view } + } + } catch (cause) { + return { error: /** @type {Error} */ (cause) } + } + } + + /** + * @param {Out} target + * @param {{hasher?: Schema.MultihashHasher }} options + */ + async attach(target, { hasher = /** @type {*} */ (sha256) } = {}) { + const { schema, codec = CBOR } = this.settings + const result = schema.tryTo(target) + if (result.error) { + throw result.error + } + const data = result.ok + /** @type {Uint8Array} */ + const bytes = codec.encode(data) + const digest = await hasher.digest(bytes) + /** @type {Schema.Link} */ + const cid = createLink(codec.code, digest) + + const store = new Map() + for (const block of Attachment.iterateIPLDBlocks(target)) { + store.set(`${block.cid}`, block) + } + store.set(`${cid}`, { bytes, cid }) + + return new Attachment({ + codec, + schema, + root: { bytes, cid }, + store, + }) + } + + /** + * @param {Out} target + */ + embed(target) { + const { schema, codec = /** @type {*} */ (CBOR) } = this.settings + const result = schema.tryTo(target) + if (result.error) { + throw result.error + } + const data = result.ok + /** @type {Uint8Array} */ + const bytes = codec.encode(data) + const digest = identity.digest(bytes) + /** @type {Schema.Link} */ + const cid = createLink(codec.code, digest) + + return new Attachment({ + codec, + schema, + root: { bytes, cid }, + }) + } + + /** + * @returns {never} + */ + link() { + throw new Error('Can not create link of link') + } +} + +/** + * @template {unknown} Out + * @template In + * @template {Schema.BlockCodec} Codec + * @template {Schema.MultihashHasher} Hasher + * @template {Schema.UnknownLink['version']} Version + * @param {LinkSettings} options + * @returns {Schema.LinkSchema} + */ +export const link = options => new LinkSchema(options) + +/** + * @template {Schema.VariantChoices} Choices + * @extends {API, Schema.InferVariantInput, Choices>} + * @implements {Schema.VariantSchema} */ class Variant extends API { /** - * @param {I} input - * @param {U} variants - * @returns {Schema.ReadResult>} + * @param {Schema.InferVariantInput} input + * @param {Choices} variants + * @param {Schema.Region} [context] + * @returns {Schema.ReadResult>} */ - readWith(input, variants) { + readWith(input, variants, context) { if (typeof input != 'object' || input === null || Array.isArray(input)) { return typeError({ expect: 'object', @@ -1229,15 +2381,19 @@ class Variant extends API { const reader = key ? variants[key] : undefined if (reader) { - const result = reader.read(input[key]) + const result = reader.tryFrom(input[key], context) return result.error ? memberError({ at: key, cause: result.error }) - : { ok: /** @type {Schema.InferVariant} */ ({ [key]: result.ok }) } + : { + ok: /** @type {Schema.InferVariant} */ ({ + [key]: result.ok, + }), + } } else if (variants._) { - const result = variants._.read(input) + const result = variants._.tryFrom(input, context) return result.error ? result - : { ok: /** @type {Schema.InferVariant} */ ({ _: result.ok }) } + : { ok: /** @type {Schema.InferVariant} */ ({ _: result.ok }) } } else if (key) { return error( `Expected an object with one of the these keys: ${Object.keys(variants) @@ -1254,11 +2410,11 @@ class Variant extends API { /** * @template [E=never] - * @param {I} input + * @param {unknown} input * @param {E} [fallback] */ match(input, fallback) { - const result = this.read(input) + const result = this.tryFrom(/** @type {*} */ (input)) if (result.error) { if (fallback !== undefined) { return [null, fallback] @@ -1268,17 +2424,17 @@ class Variant extends API { } else { const [key] = Object.keys(result.ok) const value = result.ok[key] - return /** @type {any} */ ([key, value]) + return /** @type {*} */ ([key, value]) } } /** - * @template {Schema.InferVariant} O - * @param {O} source - * @returns {O} + * @template {Schema.InferVariant} Choice + * @param {Choice} source + * @returns {Choice} */ create(source) { - return /** @type {O} */ (this.from(source)) + return /** @type {Choice} */ (this.from(/** @type {*} */ (source))) } } @@ -1334,9 +2490,8 @@ class Variant extends API { * ``` * * @template {Schema.VariantChoices} Choices - * @template [In=unknown] * @param {Choices} variants - * @returns {Schema.VariantSchema} + * @returns {Schema.VariantSchema} */ export const variant = variants => new Variant(variants) @@ -1397,10 +2552,11 @@ const displayTypeName = value => { // eg turn NaN and Infinity to null case 'bigint': return `${value}n` - case 'number': case 'symbol': + return /** @type {symbol} */ (value).toString() + case 'number': case 'undefined': - return String(value) + return `${value}` case 'object': return value === null ? 'null' @@ -1537,3 +2693,12 @@ const indent = (message, indent = ' ') => * @param {string} message */ const li = message => indent(`- ${message}`) + +/** + * @template In, Out + * @param {Schema.Schema} schema + * @returns {{in:In, out:Out}} + */ +export const debug = schema => { + throw new Error('Not implemented') +} diff --git a/packages/core/src/schema/tradeoffs.md b/packages/core/src/schema/tradeoffs.md new file mode 100644 index 00000000..832fbbca --- /dev/null +++ b/packages/core/src/schema/tradeoffs.md @@ -0,0 +1,6 @@ +- we either have: + 1. Another set of APIs for working with dags alongside of regular APIs + that take `BlockStore / BlockLoader` in addition. + 2. We have set of properties that fail decode when `.link().resolve()` is used. + 3. We have a modifier that turns regular struct decoder into DAG decoder. + 4. We have alternative to `struct({})` which takes `.link().resolve()`. diff --git a/packages/core/src/schema/type.ts b/packages/core/src/schema/type.ts index 09a27dbe..ff00b458 100644 --- a/packages/core/src/schema/type.ts +++ b/packages/core/src/schema/type.ts @@ -1,43 +1,295 @@ -import { Failure as Error, Result, Variant, Phantom } from '@ucanto/interface' - -export interface Reader { - read(input: I): Result +import { + Failure as Error, + Await, + Result, + Variant, + Phantom, + Link, + BlockStore, + Block, + BlockCodec, + BlockDecoder, + BlockEncoder, + MultihashHasher, + MulticodecCode, + MultibaseDecoder, + MultibaseEncoder, + UnknownLink, + IPLDView, + IPLDViewBuilder, + BuildOptions, + ByteView, +} from '@ucanto/interface' +import type * as identity from '../identity' +import type * as sha256 from '../sha256' + +export type { + Link, + UnknownLink, + BlockStore, + Block, + BlockCodec, + BlockEncoder, + BlockDecoder, + MultihashHasher, + MulticodecCode, + MultibaseDecoder, + MultibaseEncoder, + ByteView, + IPLDView, + IPLDViewBuilder, + BuildOptions, + Phantom, + Await, } export type { Error, Result } export type ReadResult = Result +export interface From { + tryFrom(value: T, context?: Region): ReadResult +} + +export interface To { + tryTo(value: Self): ReadResult +} + +export interface Convert + extends From, + To {} + +export interface Conform { + conform(value: unknown): Conformance +} + +export type Conformance = Result< + Self, + Reason +> + +export interface BlockLoader extends From {} + +export interface Region { + get(id: string): undefined | { bytes: Uint8Array } +} + export interface Schema< - O extends unknown = unknown, - I extends unknown = unknown -> extends Reader { - optional(): Schema - nullable(): Schema - array(): Schema - default(value: NotUndefined): DefaultSchema, I> - or(other: Reader): Schema - and(other: Reader): Schema - refine(schema: Reader): Schema + Out extends unknown = unknown, + In extends unknown = unknown +> extends Convert { + optional(): Schema + nullable(): Schema + + implicit(value: Exclude): ImplicitSchema + + array(): ArraySchema> + or(other: Convert): Schema + and(other: Convert): Schema + refine(schema: Convert): Schema + + // lift: (from: Convert) => Schema + + // into(schema: Convert): Schema + pipe(schema: Convert): Schema + + brand(kind?: K): Schema, In> - brand(kind?: K): Schema, I> + is(value: unknown): value is Out + + read(unknown: unknown): ReadResult + from(value: In): Out + to(value: Out): In + + // conforms(value: unknown): value is Out + + link< + Codec extends BlockCodec, + Hasher extends MultihashHasher = MultihashHasher, + V extends UnknownLink['version'] = 1 + >(options?: { + codec?: Codec + version?: V + hasher?: Hasher + }): LinkSchema + + // attach< + // Code extends MulticodecCode, + // Alg extends MulticodecCode, + // V extends UnknownLink['version'] + // >(options?: { + // codec?: BlockCodec + // hasher?: MultihashHasher + // version?: V + // }): AttachmentSchema + + // with(context: { store: BlockStore }): Schema + + // resolve(settings: { store: BlockStore }): Schema + + // codec: BlockCodec + // hasher: MultihashHasher + + // toIPLDBuilder(input: I): Result>, Error> + + // toIPLDView(source: { + // link: Link + // store: BlockStore + // }): ReturnType + + // createIPLDView(source: IPLDViewSource): Result, Error> + + // view(value: O): View + // attach(value: O): Await> + + // compile(value: I): Await>> + // attachment(value: I): Await> + + // IN: I + // OUT: Out +} + +export interface DAGRoot { + link(): Link + resolve(link: Link): Uint8Array +} + +export interface ByteSchemaSettings< + Model, + Out extends Model, + Code extends MulticodecCode +> { + codec: BlockCodec + convert: Convert +} - is(value: unknown): value is O - from(value: I): O +export interface BytesSchema< + Out = Uint8Array, + Code extends MulticodecCode = MulticodecCode<0x55, 'raw'> +> extends Schema { + code: Code + name: string + encode(value: Out): ByteView + decode(value: ByteView): Out + + refine( + schema: Convert + ): BytesSchema } -export interface DefaultSchema< - O extends unknown = unknown, - I extends unknown = unknown -> extends Schema { - readonly value: O & NotUndefined - optional(): DefaultSchema, I> +export interface LinkOf< + T extends unknown = unknown, + Code extends MulticodecCode = MulticodecCode, + Alg extends MulticodecCode = MulticodecCode, + V extends UnknownLink['version'] = 1 +> extends Link { + resolve(region: Region): ResolvedLink + select(region: Region): Attachment +} + +export interface Attachment< + T extends unknown = unknown, + Code extends MulticodecCode = MulticodecCode, + Alg extends MulticodecCode = MulticodecCode, + V extends UnknownLink['version'] = 1 +> extends Link, + IPLDView { + link(): Link + resolve(): ResolvedLink + + with(context: { store?: Region }): Attachment +} + +export interface CompiledView extends IPLDView { + root: Required> +} + +export type ResolvedLink< + T extends unknown = unknown, + Code extends MulticodecCode = MulticodecCode, + Alg extends MulticodecCode = MulticodecCode, + V extends UnknownLink['version'] = 1 +> = T & Phantom> + +// export interface View { +// valueOf(): O +// attach(): Await> +// } + +// export interface Attachment< +// T extends unknown = unknown, +// Code extends MulticodecCode = MulticodecCode, +// Alg extends MulticodecCode = MulticodecCode, +// V extends UnknownLink['version'] = 1 +// > extends IPLDView { +// load(): T +// link(): Link +// } + +export interface LinkSchema< + Out extends unknown, + Code extends MulticodecCode, + Alg extends MulticodecCode, + V extends UnknownLink['version'] +> extends Schema, IntoLink> { + link(): never + + attached(): AttachmentSchema + parse( + source: string, + base?: MultibaseDecoder + ): LinkOf + + embed(target: Out): Attachment + attach(target: Out): Await> +} + +export interface IntoLink { + link(): Link +} + +export interface AttachmentSchema< + Target extends unknown, + Code extends MulticodecCode, + Alg extends MulticodecCode, + V extends UnknownLink['version'] +> extends Schema< + Attachment, + IPLDView + > { + link(): never + attach( + target: Target, + options?: { hasher?: MultihashHasher } + ): Await> + + embed(target: Target): Attachment +} + +export interface IPLDViewSource { + root: Block + store: BlockStore +} + +export interface CreateView { + create(source: { + root: Required + store: BlockStore + schema: Schema + }): IPLDView & V +} + +export interface ImplicitSchema + extends Omit, I | undefined>, 'optional'> { + readonly value: Exclude + optional(): ImplicitSchema } export type NotUndefined = Exclude -export interface ArraySchema extends Schema { - element: Reader +export interface ArraySchema + extends Schema[], InferInput[]> { + element: C } /** @@ -48,20 +300,23 @@ export interface ArraySchema extends Schema { */ export interface MapRepresentation< V extends Record, - I = unknown -> extends Schema { + U extends Record +> extends Schema { /** * Returns equivalent schema in which all of the fields are optional. */ - partial(): MapRepresentation, I> + partial(): MapRepresentation, Partial> } -export interface DictionarySchema - extends MapRepresentation, I> { - key: Reader - value: Reader +export interface DictionarySchema + extends Omit< + MapRepresentation, Dictionary>, + 'partial' + > { + key: From + value: Convert - partial(): DictionarySchema + partial(): DictionarySchema } export type Dictionary< @@ -72,35 +327,54 @@ export type Dictionary< } export interface LiteralSchema< - T extends string | number | boolean | null, - I = unknown -> extends Schema { - default(value?: T): DefaultSchema, I> - readonly value: T + Out extends string | number | boolean | null, + In extends string | number | boolean | null = Out +> extends Schema { + implicit(value?: Out): ImplicitSchema + readonly value: Out } export interface NumberSchema< - N extends number = number, - I extends unknown = unknown -> extends Schema { - greaterThan(n: number): NumberSchema - lessThan(n: number): NumberSchema + Out extends number = number, + In extends number = number +> extends Schema { + isInteger: typeof Number.isInteger + isFinite: typeof Number.isFinite + greaterThan(n: number): NumberSchema + lessThan(n: number): NumberSchema + + optional(): Schema + + refine( + convert: Convert + ): NumberSchema + constraint(check: (value: Out) => ReadResult): NumberSchema +} - refine(schema: Reader): NumberSchema +export interface StructMembers { + [key: string]: Convert } -export interface StructSchema< - U extends { [key: string]: Reader } = {}, - I extends unknown = unknown -> extends MapRepresentation, I> { - shape: U +export interface StructSchema + extends MapRepresentation, InferStructInput> { + shape: Members + + create( + input: MarkEmptyOptional> + ): InferStruct + extend(extension: E): StructSchema - create(input: MarkEmptyOptional>): InferStruct - extend( - extension: E - ): StructSchema + partial(): MapRepresentation< + Partial>, + Partial> + > & + StructSchema - partial(): MapRepresentation>, I> & StructSchema + // createIPLDView( + // source: IPLDViewSource + // ): Result> & InferStruct, Error> + + // view(value: InferStruct): InferStruct & IPLDView> } /** @@ -122,10 +396,8 @@ export interface StructSchema< * }) * ``` */ -export interface VariantSchema< - Choices extends VariantChoices = {}, - In extends unknown = unknown -> extends Schema, In> { +export interface VariantSchema + extends Schema, InferVariantInput> { /** * Function can be used to match the input against the variant schema to * return the matched branch name and corresponding value. It provides @@ -166,7 +438,7 @@ export interface VariantSchema< * their respective schemas. */ export interface VariantChoices { - [branch: string]: Reader + [branch: string]: Convert } /** @@ -176,7 +448,15 @@ export type InferVariant = { [Case in keyof Choices]: { [Key in Exclude]?: never } & { - [Key in Case]: Choices[Case] extends Reader ? T : never + [Key in Case]: Choices[Case] extends From ? T : never + } +}[keyof Choices] + +export type InferVariantInput = { + [Case in keyof Choices]: { + [Key in Exclude]?: never + } & { + [Key in Case]: Choices[Case] extends To ? T : never } }[keyof Choices] @@ -186,28 +466,29 @@ export type InferVariant = { * use of `switch` statement for type narrowing. */ export type InferVariantMatch = { - [Branch in keyof Choices]: Choices[Branch] extends Reader + [Branch in keyof Choices]: Choices[Branch] extends From ? [Branch, Value] : never }[keyof Choices] -export type InferOptionalStructShape = { +export type InferOptionalStructShape = { [K in keyof U]: InferOptionalReader } -export type InferOptionalReader = R extends Reader - ? Reader +export type InferOptionalReader = R extends From + ? From : R -export interface StringSchema - extends Schema { +export interface StringSchema + extends Schema { + refine(convert: From): StringSchema + constraint(check: (value: Out) => ReadResult): StringSchema startsWith( prefix: Prefix - ): StringSchema + ): StringSchema endsWith( suffix: Suffix - ): StringSchema - refine(schema: Reader): StringSchema + ): StringSchema } declare const Marker: unique symbol @@ -218,36 +499,52 @@ export type Branded = T & { export type Integer = number & Phantom<{ typeof: 'integer' }> export type Float = number & Phantom<{ typeof: 'float' }> -export type Infer = T extends Reader ? T : never +export type Infer = T extends From ? T : never +export type InferInput = T extends To ? T : never -export type InferIntersection = { +export type InferIntersection = { [K in keyof U]: (input: Infer) => void }[number] extends (input: infer T) => void ? T : never -export type InferUnion = Infer +export type InferIntersectionInput = { + [K in keyof U]: (input: InferInput) => void +}[number] extends (input: infer T) => void + ? T + : never -export type InferTuple = { +export type InferUnion = Infer +export type InferUnionInput = InferInput + +export type InferTuple = { [K in keyof U]: Infer } -export type InferStruct = MarkOptionals<{ - [K in keyof U]: Infer +export type InferTupleInput = { + [K in keyof U]: InferInput +} + +export type InferStruct = MarkOptionals<{ + [K in keyof Shape]: Infer +}> + +export type InferStructInput = MarkOptionals<{ + [K in keyof Shape]: InferInput }> -export type InferStructSource = +export type InferStructSource = // MarkEmptyOptional< MarkOptionals<{ [K in keyof U]: InferSource }> // > -export type InferSource = U extends DefaultSchema +export type InferSource = U extends ImplicitSchema ? T | undefined : U extends StructSchema ? InferStructSource - : U extends Reader + : U extends From ? T : never diff --git a/packages/core/src/schema/uri.js b/packages/core/src/schema/uri.js index d2fb1b34..ea3f2752 100644 --- a/packages/core/src/schema/uri.js +++ b/packages/core/src/schema/uri.js @@ -46,7 +46,7 @@ export const uri = () => schema /** * @param {unknown} input */ -export const read = input => schema.read(input) +export const read = input => schema.tryFrom(input) /** * @template {API.Protocol} P diff --git a/packages/core/src/sha256.js b/packages/core/src/sha256.js new file mode 100644 index 00000000..015df075 --- /dev/null +++ b/packages/core/src/sha256.js @@ -0,0 +1,9 @@ +import * as API from '@ucanto/interface' + +import { sha256 } from 'multiformats/hashes/sha2' + +/** @type {API.MulticodecCode} */ +export const code = sha256.code +export const name = sha256.name +export const { encode } = sha256 +export const digest = sha256.digest.bind(sha256) diff --git a/packages/core/test/delegation.spec.js b/packages/core/test/delegation.spec.js index 84576149..f5580013 100644 --- a/packages/core/test/delegation.spec.js +++ b/packages/core/test/delegation.spec.js @@ -1,8 +1,16 @@ import { assert, test } from './test.js' -import { CAR, CBOR, delegate, Delegation, parseLink, UCAN } from '../src/lib.js' +import { + CAR, + CBOR, + delegate, + Delegation, + parseLink, + Schema, + UCAN, +} from '../src/lib.js' import { alice, bob, mallory, service as w3 } from './fixtures.js' import { base64 } from 'multiformats/bases/base64' -import { getBlock } from './utils.js' +import { sha256 } from '../src/dag.js' const utf8 = new TextEncoder() @@ -427,8 +435,10 @@ test('archive delegation chain', async () => { assert.deepEqual(extract.ok.proofs[0], proof) }) -test('delegation.attach block in capabiliy', async () => { - const block = await getBlock({ test: 'inlineBlock' }) +test('includes attachment in capability.nb', async () => { + const Block = Schema.bytes(CBOR).link({ hasher: sha256 }) + const block = await Block.attach({ test: 'inlineBlock' }) + const ucan = await Delegation.delegate({ issuer: alice, audience: bob, @@ -437,24 +447,26 @@ test('delegation.attach block in capabiliy', async () => { can: 'store/add', with: alice.did(), nb: { - inlineBlock: block.cid.link() - } + inlineBlock: block, + }, }, ], }) - ucan.attach(block) + const delegationBlocks = [...ucan.iterateIPLDBlocks()] + const attachment = delegationBlocks.find(b => b.cid.equals(block.link())) - const delegationBlocks = [] - for (const b of ucan.iterateIPLDBlocks()) { - delegationBlocks.push(b) + if (!attachment) { + return assert.fail('attachment not found') } - assert.ok(delegationBlocks.find(b => b.cid.equals(block.cid))) + assert.deepEqual(CBOR.decode(attachment.bytes), { test: 'inlineBlock' }) }) -test('delegation.attach block in facts', async () => { - const block = await getBlock({ test: 'inlineBlock' }) +test('includes attachment if it is capability.nb', async () => { + const Block = Schema.bytes(CBOR).link({ hasher: sha256 }) + const block = await Block.attach({ test: 'inlineBlock' }) + const ucan = await Delegation.delegate({ issuer: alice, audience: bob, @@ -462,37 +474,68 @@ test('delegation.attach block in facts', async () => { { can: 'store/add', with: alice.did(), + nb: block, }, ], - facts: [ - { [`${block.cid.link()}`]: block.cid.link() }, - // @ts-expect-error Link has fact entry - block.cid.link() - ] }) - ucan.attach(block) + const delegationBlocks = [...ucan.iterateIPLDBlocks()] + const attachment = delegationBlocks.find(b => b.cid.equals(block.link())) - const delegationBlocks = [] - for (const b of ucan.iterateIPLDBlocks()) { - delegationBlocks.push(b) + if (!attachment) { + return assert.fail('attachment not found') } - assert.ok(delegationBlocks.find(b => b.cid.equals(block.cid))) + assert.deepEqual(CBOR.decode(attachment.bytes), { test: 'inlineBlock' }) }) -test('delegation.attach fails to attach block with not attached link', async () => { +test('includes attachment in facts', async () => { + const Block = Schema.unknown().link({ + codec: CBOR, + hasher: sha256, + }) + + const block = await Block.attach({ test: 'inlineBlock' }) const ucan = await Delegation.delegate({ issuer: alice, audience: bob, capabilities: [ { can: 'store/add', - with: alice.did() + with: alice.did(), }, ], + facts: [{ [`${block.link()}`]: block }], }) - const block = await getBlock({ test: 'inlineBlock' }) - assert.throws(() => ucan.attach(block)) -}) \ No newline at end of file + const delegationBlocks = [...ucan.iterateIPLDBlocks()] + + assert.ok(delegationBlocks.find(b => b.cid.equals(block.link()))) +}) + +test('includes unboxed attachment in facts', async () => { + const Block = Schema.unknown().link({ + codec: CBOR, + hasher: sha256, + }) + + const block = await Block.attach({ test: 'inlineBlock' }) + const ucan = await Delegation.delegate({ + issuer: alice, + audience: bob, + capabilities: [ + { + can: 'store/add', + with: alice.did(), + }, + ], + facts: [ + // @ts-expect-error - expects dict + block, + ], + }) + + const delegationBlocks = [...ucan.iterateIPLDBlocks()] + + assert.ok(delegationBlocks.find(b => b.cid.equals(block.link()))) +}) diff --git a/packages/core/test/extra-schema.spec.js b/packages/core/test/extra-schema.spec.js index be4447b5..14e7576e 100644 --- a/packages/core/test/extra-schema.spec.js +++ b/packages/core/test/extra-schema.spec.js @@ -1,6 +1,9 @@ -import { URI, Link, Text, DID } from '../src/schema.js' +import { URI, Text, DID } from '../src/schema.js' +import { parseLink } from '../src/lib.js' +import * as Schema from '../src/schema.js' import { test, assert, matchResult } from './test.js' import * as API from '@ucanto/interface' +import { sha256 } from '../src/dag.js' { /** @type {[string, API.Result|RegExp][]} */ @@ -13,7 +16,7 @@ import * as API from '@ucanto/interface' for (const [input, expect] of dataset) { test(`URI.read(${JSON.stringify(input)}}`, () => { matchResult(URI.read(input), expect) - matchResult(URI.uri().read(input), expect) + matchResult(URI.uri().tryFrom(input), expect) }) } } @@ -53,7 +56,7 @@ test('URI.from', () => { test(`URI.match(${JSON.stringify({ protocol, })}).read(${JSON.stringify(input)})}}`, () => { - matchResult(URI.match({ protocol }).read(input), expect) + matchResult(URI.match({ protocol }).tryFrom(input), expect) }) } } @@ -82,7 +85,7 @@ test('URI.from', () => { test(`URI.match(${JSON.stringify({ protocol, })}).optional().read(${JSON.stringify(input)})}}`, () => { - matchResult(URI.match({ protocol }).optional().read(input), expect) + matchResult(URI.match({ protocol }).optional().tryFrom(input), expect) }) } } @@ -91,7 +94,7 @@ test('URI.from', () => { /** @type {any[][]} */ const dataset = [ [ - Link.parse('bafkqaaa'), + parseLink('bafkqaaa'), null, /Expected link to be CID with 0x70 codec/, /Expected link to be CID with 0x12 hashing algorithm/, @@ -99,7 +102,7 @@ test('URI.from', () => { null, ], [ - Link.parse('QmdpiaQ9q7n4E224syBJz4peZpAFLArwJgSXHZWH5F6DxB'), + parseLink('QmdpiaQ9q7n4E224syBJz4peZpAFLArwJgSXHZWH5F6DxB'), null, null, null, @@ -107,7 +110,7 @@ test('URI.from', () => { null, ], [ - Link.parse('bafybeiepa5hmd3vg2i2unyzrhnxnthwi2aksunykhmcaykbl2jx2u77cny'), + parseLink('bafybeiepa5hmd3vg2i2unyzrhnxnthwi2aksunykhmcaykbl2jx2u77cny'), null, null, null, @@ -135,33 +138,33 @@ test('URI.from', () => { ] for (const [input, out1, out2, out3, out4, out5] of dataset) { - test(`Link.read(${input})`, () => { - matchResult(Link.read(input), out1 || { ok: input }) + test(`unknown().link().tryFrom(${input})`, () => { + matchResult(Schema.unknown().link().tryFrom(input), out1 || { ok: input }) }) - test('Link.link()', () => { - const schema = Link.link() - matchResult(schema.read(input), out1 || { ok: input }) + test('Schema.link()', () => { + const schema = Schema.unknown().link() + matchResult(schema.tryFrom(input), out1 || { ok: input }) }) - test(`Link.match({ code: 0x70 }).read(${input})`, () => { - const link = Link.match({ code: 0x70 }) - matchResult(link.read(input), out2 || { ok: input }) + test(`Schema.link({ code: 0x70 }).read(${input})`, () => { + const link = Schema.unknown().link({ codec: { code: 0x70 } }) + matchResult(link.tryFrom(input), out2 || { ok: input }) }) - test(`Link.match({ algorithm: 0x12 }).read(${input})`, () => { - const link = Link.match({ multihash: { code: 0x12 } }) - matchResult(link.read(input), out3 || { ok: input }) + test(`Schema.link({ algorithm: 0x12 }).read(${input})`, () => { + const link = Schema.unknown().link({ hasher: sha256 }) + matchResult(link.tryFrom(input), out3 || { ok: input }) }) - test(`Link.match({ version: 1 }).read(${input})`, () => { - const link = Link.match({ version: 1 }) - matchResult(link.read(input), out4 || { ok: input }) + test(`Schema.link({ version: 1 }).read(${input})`, () => { + const link = Schema.unknown().link({ version: 1 }) + matchResult(link.tryFrom(input), out4 || { ok: input }) }) test(`Link.optional().read(${input})`, () => { - const link = Link.optional() - matchResult(link.read(input), out5 || { ok: input }) + const link = Schema.unknown().link().optional() + matchResult(link.tryFrom(input), out5 || { ok: input }) }) } } @@ -306,7 +309,7 @@ test('URI.from', () => { for (const [options, input, out] of dataset) { test(`DID.match({ method: ${options.method} }).read(${input})`, () => { - matchResult(DID.match(options).read(input), out) + matchResult(DID.match(options).tryFrom(input), out) }) } } @@ -349,7 +352,7 @@ test('URI.from', () => { for (const [options, input, out] of dataset) { test(`DID.match({ method: "${options.method}" }).optional().read(${input})`, () => { const schema = options.method ? DID.match(options) : DID.did() - matchResult(schema.optional().read(input), out) + matchResult(schema.optional().tryFrom(input), out) }) } } diff --git a/packages/core/test/invocation.spec.js b/packages/core/test/invocation.spec.js index e209a849..89c0e33e 100644 --- a/packages/core/test/invocation.spec.js +++ b/packages/core/test/invocation.spec.js @@ -1,7 +1,7 @@ -import { invoke, UCAN, Invocation } from '../src/lib.js' +import { invoke, UCAN, Invocation, Schema, CBOR } from '../src/lib.js' import { alice, service as w3 } from './fixtures.js' -import { getBlock } from './utils.js' import { assert, test } from './test.js' +import { sha256 } from '../src/dag.js' test('encode invocation', async () => { const add = invoke({ @@ -33,49 +33,51 @@ test('encode invocation', async () => { }) test('encode invocation with attached block in capability nb', async () => { - const block = await getBlock({ test: 'inlineBlock' }) + const Block = Schema.unknown().link({ + codec: CBOR, + hasher: sha256, + }) + + const block = await Block.attach({ test: 'inlineBlock' }) const add = invoke({ issuer: alice, audience: w3, capability: { can: 'store/add', with: alice.did(), - link: 'bafy...stuff', nb: { - inlineBlock: block.cid.link() - } + link: 'bafy...stuff', + inlineBlock: block, + }, }, proofs: [], }) - add.attach(block) - /** @type {import('@ucanto/interface').BlockStore} */ - const blockStore = new Map() const view = await add.buildIPLDView() - for (const b of view.iterateIPLDBlocks()) { - blockStore.set(`${b.cid}`, b) - } + + const blocks = new Map( + [...view.iterateIPLDBlocks()].map(block => [`${block.cid}`, block]) + ) // blockstore has attached block - assert.ok(blockStore.get(`${block.cid}`)) + assert.ok(blocks.get(`${block.link()}`)) const reassembledInvocation = Invocation.view({ root: view.root.cid.link(), - blocks: blockStore + blocks, }) - /** @type {import('@ucanto/interface').BlockStore} */ - const reassembledBlockstore = new Map() - - for (const b of reassembledInvocation.iterateIPLDBlocks()) { - reassembledBlockstore.set(`${b.cid}`, b) - } + const reassembledBlockstore = new Map( + [...reassembledInvocation.iterateIPLDBlocks()].map(block => [ + `${block.cid}`, + block, + ]) + ) // reassembledBlockstore has attached block - assert.ok(reassembledBlockstore.get(`${block.cid}`)) + assert.ok(reassembledBlockstore.get(`${block.link()}`)) }) - test('expired invocation', async () => { const expiration = UCAN.now() - 5 const invocation = invoke({ diff --git a/packages/core/test/ipld-schema.spec.js b/packages/core/test/ipld-schema.spec.js new file mode 100644 index 00000000..a822dff2 --- /dev/null +++ b/packages/core/test/ipld-schema.spec.js @@ -0,0 +1,398 @@ +import * as Schema from '../src/schema.js' +import { isLink, parseLink, delegate, Delegation, CAR } from '../src/lib.js' +import { alice, service as w3 } from './fixtures.js' +import { CBOR, identity, sha256, createStore, writeInto } from '../src/dag.js' +import { test, assert } from './test.js' +import { archive } from '../src/delegation.js' + +describe('IPLD Schema', () => { + test('link schema', async () => { + const Point = Schema.struct({ + x: Schema.integer(), + y: Schema.integer(), + }) + + const PointLink = Point.link({ codec: CBOR }) + + assert.equal( + PointLink.tryFrom(parseLink('bafkqaaa')).error?.message, + 'Expected link to be CID with 0x71 codec' + ) + + const onlyX = await CBOR.write({ x: 1 }) + const onlyXLink = await PointLink.from( + // @ts-expect-error - does not have y + onlyX.cid + ) + assert.ok(onlyX) + + assert.deepEqual( + onlyX.cid.toString(), + onlyXLink.link().toString(), + 'links match' + ) + + assert.throws( + () => onlyXLink.resolve(new Map()), + 'Block can not be resolved, please provide a store from which to resolve it' + ) + + assert.throws( + () => onlyXLink.resolve(new Map([[onlyX.cid.toString(), onlyX]])), + /invalid field "y"/ + ) + + const point = await CBOR.write({ x: 1, y: 2 }) + const pointLink = await PointLink.from(point.cid) + + equalLink(point.cid, pointLink) + + assert.throws( + () => pointLink.resolve(new Map()), + 'Block can not be resolved, please provide a store from which to resolve it' + ) + + assert.deepEqual( + pointLink.resolve(new Map([[point.cid.toString(), point]])), + { + x: 1, + y: 2, + } + ) + + const view = pointLink.select(new Map([[point.cid.toString(), point]])) + equalLink(view, point.cid) + + assert.deepEqual(view.resolve(), { x: 1, y: 2 }) + + assert.equal(isLink(PointLink.from(view)), true) + equalLink(PointLink.from(view), point.cid) + + const p2 = await PointLink.attach({ + x: 1, + y: 2, + }) + + assert.deepEqual(p2.link(), point.cid) + assert.deepEqual(p2.root.bytes, point.bytes) + }) + + test('embed links', async () => { + const Point = Schema.struct({ + x: Schema.integer(), + y: Schema.integer(), + }) + + const Line = Schema.struct({ + start: Point.link({ codec: CBOR }), + end: Point.link({ codec: CBOR }).attached(), + }) + + const PointLink = Point.link({ codec: CBOR }) + + const line = Line.from({ + start: PointLink.embed({ + x: 1, + y: 2, + }), + end: PointLink.embed({ + x: 0, + y: 0, + }), + }) + + assert.ok(isLink(line.start), 'is a link') + assert.ok(isLink(line.end), 'is a link') + assert.equal(line.start.code, CBOR.code, 'is a CBOR link') + assert.equal(line.start.multihash.code, identity.code, 'is a CBOR link') + + assert.deepEqual(line.start.resolve(new Map()), { x: 1, y: 2 }) + assert.deepEqual(line.end.resolve(), { x: 0, y: 0 }) + }) + + test('attached links', async () => { + const Point = Schema.struct({ + x: Schema.integer(), + y: Schema.integer(), + }) + + const Line = Schema.struct({ + start: Point.link({ codec: CBOR }).attached(), + end: Point.link({ codec: CBOR }).attached(), + }) + + const int = Schema.integer() + const store = createStore() + const start = await writeInto({ x: int.from(0), y: int.from(0) }, store) + const end = await writeInto({ x: int.from(7), y: int.from(8) }, store) + const line = await writeInto({ start: start.cid, end: end.cid }, store) + + const Root = Line.link({ codec: CBOR }) + + const root = Root.parse(line.cid.toString()) + + { + const line = root.resolve(store) + + assert.equal(line.start.resolve().x, 0) + assert.equal(line.start.resolve().y, 0) + assert.equal(line.end.resolve().x, 7) + assert.equal(line.end.resolve().y, 8) + } + + const PointLink = Point.link({ codec: CBOR }) + + { + const line = Line.from({ + start: PointLink.embed({ x: 0, y: 0 }), + end: await PointLink.attach({ + x: 1, + y: 2, + }), + }) + + assert.equal(line.start.code, CBOR.code) + assert.equal(line.start.multihash.code, identity.code) + assert.equal(line.end.code, CBOR.code) + assert.equal(line.end.multihash.code, sha256.code) + } + }) + + test('read from archive', async () => { + const Point = Schema.struct({ + x: Schema.integer(), + y: Schema.integer(), + }) + + const Line = Schema.struct({ + start: Point.link({ codec: CBOR }).attached(), + end: Point.link({ codec: CBOR }).attached(), + }) + + const int = Schema.integer() + const archive = createStore() + const start = await writeInto({ x: int.from(0), y: int.from(0) }, archive) + const end = await writeInto({ x: int.from(7), y: int.from(8) }, archive) + const root = await writeInto({ start: start.cid, end: end.cid }, archive) + + const Root = Line.link({ codec: CBOR }) + + const line = Root.parse(root.cid.toString()).resolve(archive) + assert.deepEqual(line.start.resolve(), { x: 0, y: 0 }) + assert.deepEqual(line.end.resolve(), { x: 7, y: 8 }) + }) + + test('build dag', async () => { + const Point = Schema.struct({ + x: Schema.integer(), + y: Schema.integer(), + }) + + const Line = Schema.struct({ + start: Point.link({ codec: CBOR }).attached(), + end: Point.link({ codec: CBOR }).attached(), + }) + + const start = await Point.link({ codec: CBOR }).attach({ + x: 1, + y: 2, + }) + + const CBORPoint = Point.link({ codec: CBOR }) + const end = await Point.link({ codec: CBOR }).attach({ + x: 3, + y: 4, + }) + + const PointLink = Point.link({ codec: CBOR }) + const PointAttachment = PointLink.attached() + + const BoxedPoint = Schema.struct({ + point: Point.link({ codec: CBOR }).attached(), + }) + + BoxedPoint.from({ point: await PointLink.attach({ x: 1, y: 2 }) }) + const BoxedPointLink = BoxedPoint.link({ codec: CBOR }) + + BoxedPointLink.embed({ + point: PointLink.embed({ x: 1, y: 2 }), + }) + + PointAttachment.from + + const point = await PointLink.attach({ + x: 1, + y: 2, + }) + + assert.deepEqual(point.resolve(), { x: 1, y: 2 }) + + const lineEmbed = await Line.link({ codec: CBOR }).attach({ + start: point, + end: point, + }) + + const embed = lineEmbed.resolve() + assert.deepEqual(embed.start.link(), point.link()) + assert.deepEqual(embed.start.resolve(), { x: 1, y: 2 }) + assert.deepEqual(embed.end.link(), point.link()) + assert.deepEqual(embed.end.resolve(), { x: 1, y: 2 }) + + const blocks = Object.fromEntries( + [...lineEmbed.iterateIPLDBlocks()].map(block => [`${block.cid}`, block]) + ) + + assert.ok(blocks[lineEmbed.link().toString()]) + assert.ok(blocks[point.link().toString()]) + }) + + test('array of dags', async () => { + const Point = Schema.struct({ + x: Schema.integer(), + y: Schema.integer(), + }) + + const Polygon = Schema.array(Point.link({ codec: CBOR }).attached()) + + const polygon = await Polygon.link({ codec: CBOR }).attach([ + await Polygon.element.attach({ x: 1, y: 2 }), + await Polygon.element.attach({ x: 3, y: 4 }), + await Polygon.element.attach({ x: 5, y: 6 }), + ]) + + const compile = async (data = {}) => { + const { bytes, cid } = await CBOR.write(data) + return { bytes, cid } + } + + const link = async (data = {}) => { + const { cid } = await CBOR.write(data) + return cid + } + + assert.deepEqual( + [...polygon.iterateIPLDBlocks()].map(({ cid, bytes }) => ({ + bytes, + cid: cid.link(), + })), + [ + await compile({ x: 1, y: 2 }), + await compile({ x: 3, y: 4 }), + await compile({ x: 5, y: 6 }), + { cid: polygon.link(), bytes: polygon.root.bytes }, + ] + ) + + assert.deepEqual( + polygon.link(), + await link([ + await link({ x: 1, y: 2 }), + await link({ x: 3, y: 4 }), + await link({ x: 5, y: 6 }), + ]) + ) + + const region = new Map( + [...polygon.iterateIPLDBlocks()].map(block => [`${block.cid}`, block]) + ) + + const root = Polygon.link({ codec: CBOR }) + .parse(polygon.link().toString()) + .select(region) + + const replica = root.resolve() + + assert.deepEqual( + replica.map(point => point.link()), + [ + await link({ x: 1, y: 2 }), + await link({ x: 3, y: 4 }), + await link({ x: 5, y: 6 }), + ] + ) + + assert.deepEqual( + replica.map(point => point.resolve()), + [ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + { x: 5, y: 6 }, + ] + ) + }) + + test('inline into delegation', async () => { + const Detail = Schema.struct({ + link: Schema.unknown().link(), + size: Schema.integer(), + src: Schema.string().array(), + }) + const Offer = Schema.array(Detail) + const Capability = Schema.struct({ + with: Schema.did(), + can: Schema.literal('aggregate/offer'), + nb: Schema.struct({ + offer: Offer.link({ codec: CBOR }).attached(), + }), + }) + + const userData = await CBOR.write({ hello: 'world' }) + + const car = await CAR.write({ + roots: [userData], + }) + + const offer = await delegate({ + issuer: alice, + audience: w3, + capabilities: [ + Capability.from({ + can: 'aggregate/offer', + with: alice.did(), + nb: { + offer: await Offer.link({ codec: CBOR, hasher: sha256 }).attach([ + Detail.from({ + link: car.cid, + size: car.bytes.length, + src: [`ipfs://${car.cid}`], + }), + ]), + }, + }), + ], + }) + + const nodes = [...offer.export()] + + assert.deepEqual(nodes.length, 2, 'links to the attached block') + const [node, root] = nodes + assert.deepEqual(root.cid, offer.link()) + assert.deepEqual(CBOR.decode(node.bytes), [ + { + link: car.cid, + size: car.bytes.length, + src: [`ipfs://${car.cid}`], + }, + ]) + + const archive = await offer.archive() + if (archive.error) { + throw archive.error + } + + const replica = await Delegation.extract(archive.ok) + if (replica.error) { + throw replica.error + } + + assert.equal([...replica.ok.export()].length, 2) + }) +}) + +/** + * + * @param {Schema.UnknownLink} actual + * @param {Schema.UnknownLink} expected + */ +const equalLink = (actual, expected) => + assert.deepEqual(CBOR.encode(actual), CBOR.encode(expected)) diff --git a/packages/core/test/link-schema.spec.js b/packages/core/test/link-schema.spec.js index 67a66c53..1a16e715 100644 --- a/packages/core/test/link-schema.spec.js +++ b/packages/core/test/link-schema.spec.js @@ -1,80 +1,134 @@ import * as Schema from '../src/schema.js' import { base36 } from 'multiformats/bases/base36' import { test, assert, matchError } from './test.js' +import { CBOR, sha256 } from '../src/dag.js' const fixtures = { - pb: Schema.Link.parse('QmTgnQBKj7eTV7ohraBCmh1DLwerUd2X9Rxzgf3gyMJbC8'), - cbor: Schema.Link.parse( - 'bafyreieuo63r3y2nuycaq4b3q2xvco3nprlxiwzcfp4cuupgaywat3z6mq' - ), - rawIdentity: Schema.Link.parse('bafkqaaa'), - ipns: Schema.Link.parse( - 'k2k4r8kuj2bs2l996lhjx8rc727xlvthtak8o6eia3qm5adxvs5k84gf', - base36 - ), - sha512: Schema.Link.parse( - 'kgbuwaen1jrbjip6iwe9mqg54spvuucyz7f5jho2tkc2o0c7xzqwpxtogbyrwck57s9is6zqlwt9rsxbuvszym10nbaxt9jn7sf4eksqd', - base36 - ), + pb: Schema.unknown() + .link() + .parse('QmTgnQBKj7eTV7ohraBCmh1DLwerUd2X9Rxzgf3gyMJbC8'), + cbor: Schema.unknown() + .link() + .parse('bafyreieuo63r3y2nuycaq4b3q2xvco3nprlxiwzcfp4cuupgaywat3z6mq'), + rawIdentity: Schema.unknown().link().parse('bafkqaaa'), + ipns: Schema.unknown() + .link() + .parse('k2k4r8kuj2bs2l996lhjx8rc727xlvthtak8o6eia3qm5adxvs5k84gf', base36), + sha512: Schema.unknown() + .link() + .parse( + 'kgbuwaen1jrbjip6iwe9mqg54spvuucyz7f5jho2tkc2o0c7xzqwpxtogbyrwck57s9is6zqlwt9rsxbuvszym10nbaxt9jn7sf4eksqd', + base36 + ), } const links = Object.values(fixtures) const versions = new Set(links.map(link => link.version)) const codes = new Set(links.map(link => link.code)) const algs = new Set(links.map(link => link.multihash.code)) -const digests = new Set(links.map(link => link.multihash.digest)) for (const link of links) { test(`${link} ➡ Schema.link()`, () => { - assert.deepEqual(Schema.link().read(link), { ok: link }, `${link}`) + assert.deepEqual( + Schema.unknown().link().tryFrom(link), + { ok: link }, + `${link}` + ) }) for (const version of versions) { - test(`${link} ➡ Schema.link({ version: ${version}})`, () => { - const schema = Schema.link({ version }) + test(`${link} ➡ Schema.unknown().link({ version: ${version}})`, () => { + const schema = Schema.unknown().link({ version }) if (link.version === version) { - assert.deepEqual(schema.read(link), { ok: link }) + assert.deepEqual(schema.tryFrom(link), { ok: link }) } else { - matchError(schema.read(link), /Expected link to be CID version/) + matchError(schema.tryFrom(link), /Expected link to be CID version/) } }) } for (const code of codes) { test(`${link} ➡ Schema.link({ code: ${code}})`, () => { - const schema = Schema.link({ code }) + const schema = Schema.unknown().link({ codec: { code } }) if (link.code === code) { - assert.deepEqual(schema.read(link), { ok: link }) + assert.deepEqual(schema.tryFrom(link), { ok: link }) } else { - matchError(schema.read(link), /Expected link to be CID with .* codec/) + matchError( + schema.tryFrom(link), + /Expected link to be CID with .* codec/ + ) } }) } for (const code of algs) { - test(`${link} ➡ Schema.link({ multihash: {code: ${code}} })`, () => { - const schema = Schema.link({ multihash: { code } }) + test(`${link} ➡ Schema.unknown().link({ hasher: {code: ${code}} })`, () => { + const schema = Schema.unknown().link({ hasher: { code } }) if (link.multihash.code === code) { - assert.deepEqual(schema.read(link), { ok: link }) + assert.deepEqual(schema.tryFrom(link), { ok: link }) } else { matchError( - schema.read(link), + schema.tryFrom(link), /Expected link to be CID with .* hashing algorithm/ ) } }) } - - for (const digest of digests) { - test(`${link} ➡ Schema.link({ multihash: {digest} })`, () => { - const schema = Schema.link({ - multihash: { digest: new Uint8Array(digest) }, - }) - if (link.multihash.digest === digest) { - assert.deepEqual(schema.read(link), { ok: link }) - } else { - matchError(schema.read(link), /Expected link with .* hash digest/) - } - }) - } } + +test('struct().link()', () => { + const Point = Schema.struct({ + x: Schema.integer(), + y: Schema.integer(), + }) + const PointLink = Point.link() + + assert.equal(PointLink.read(fixtures.pb).ok, fixtures.pb) + + assert.throws(() => PointLink.link(), /link of link/) +}) + +test('struct().link({ codec })', () => { + const Point = Schema.struct({ + x: Schema.integer(), + y: Schema.integer(), + }) + const PointLink = Point.link({ + codec: CBOR, + }) + + assert.match(PointLink.read(fixtures.pb).error?.message || '', /0x71 code/) + assert.equal(PointLink.read(fixtures.cbor).ok, fixtures.cbor) +}) + +test('struct().link({ hasher })', () => { + const Point = Schema.struct({ + x: Schema.integer(), + y: Schema.integer(), + }) + const PointLink = Point.link({ + hasher: sha256, + }) + + assert.match( + PointLink.read(fixtures.sha512).error?.message || '', + /0x12 hashing/ + ) + assert.equal( + PointLink.read(fixtures.cbor).ok, + /** @type {*} */ (fixtures.cbor) + ) +}) + +test('struct().link({ hasher })', () => { + const Point = Schema.struct({ + x: Schema.integer(), + y: Schema.integer(), + }) + const PointLink = Point.link({ + version: 1, + }) + + assert.match(PointLink.read(fixtures.pb).error?.message || '', /version 1/) + assert.equal(PointLink.read(fixtures.cbor).ok, fixtures.cbor) +}) diff --git a/packages/core/test/map-schema.spec.js b/packages/core/test/map-schema.spec.js index 00aa8b80..eef0de4f 100644 --- a/packages/core/test/map-schema.spec.js +++ b/packages/core/test/map-schema.spec.js @@ -1,4 +1,5 @@ import * as Schema from '../src/schema.js' +import { CBOR } from '../src/dag.js' import { test, assert } from './test.js' test('.partial on structs', () => { diff --git a/packages/core/test/message.spec.js b/packages/core/test/message.spec.js index dcbd21c1..462cf54d 100644 --- a/packages/core/test/message.spec.js +++ b/packages/core/test/message.spec.js @@ -72,6 +72,8 @@ test('Message.view', async () => { invocations: [hi.invocation], }) + assert.equal(buildHi.link(), buildHi.root.cid) + assert.throws( () => Message.view({ diff --git a/packages/core/test/receipt.spec.js b/packages/core/test/receipt.spec.js index 854cdbe7..22542281 100644 --- a/packages/core/test/receipt.spec.js +++ b/packages/core/test/receipt.spec.js @@ -273,6 +273,8 @@ test('receipt view fallback', async () => { * @param {Partial & { verifier?: API.Verifier }} expect */ const assertReceipt = async (receipt, expect) => { + assert.equal(receipt.link(), receipt.root.cid, 'root cid is correct') + if (expect.out) { assert.deepEqual(receipt.out, expect.out, 'out is correct') } diff --git a/packages/core/test/schema.spec.js b/packages/core/test/schema.spec.js index 9f75276f..8ca072f2 100644 --- a/packages/core/test/schema.spec.js +++ b/packages/core/test/schema.spec.js @@ -5,10 +5,11 @@ import fixtures from './schema/fixtures.js' for (const { input, schema, expect, inputLabel, skip, only } of fixtures()) { const unit = skip ? test.skip : only ? test.only : test - unit(`${schema}.read(${inputLabel})`, () => { - const result = schema.read(input) + unit(`${schema}.tryFrom(${inputLabel})`, () => { + const result = schema.tryFrom(input) if (expect.error) { + // console.log(`${schema}.tryFrom(${inputLabel})`, result, expect.error) matchError(result, expect.error) } else { assert.deepEqual( @@ -46,12 +47,12 @@ test('string startsWith & endsWith', () => { 'string().refine(startsWith("hello")).refine(startsWith("hi"))' ) - assert.deepInclude(impossible.read('hello world').error, { + assert.deepInclude(impossible.tryFrom('hello world').error, { name: 'SchemaError', message: `Expect string to start with "hi" instead got "hello world"`, }) - assert.deepInclude(impossible.read('hello world').error, { + assert.deepInclude(impossible.tryFrom('hello world').error, { name: 'SchemaError', message: `Expect string to start with "hi" instead got "hello world"`, }) @@ -60,7 +61,7 @@ test('string startsWith & endsWith', () => { /** @type {Schema.StringSchema<`hello${string}` & `hello ${string}`>} */ const typeofHello = hello - assert.deepEqual(hello.read('hello world'), { ok: 'hello world' }) + assert.deepEqual(hello.tryFrom('hello world'), { ok: 'hello world' }) }) test('string startsWith', () => { @@ -71,8 +72,8 @@ test('string startsWith', () => { /** @type {Schema.StringSchema<`hello${string}`>} */ const hello = Schema.string().startsWith('hello') - assert.deepEqual(hello.read('hello world!'), { ok: 'hello world!' }) - assert.deepInclude(hello.read('hi world').error, { + assert.deepEqual(hello.tryFrom('hello world!'), { ok: 'hello world!' }) + assert.deepInclude(hello.tryFrom('hi world').error, { name: 'SchemaError', message: `Expect string to start with "hello" instead got "hi world"`, }) @@ -86,9 +87,9 @@ test('string endsWith', () => { /** @type {Schema.StringSchema<`${string} world`>} */ const greet = Schema.string().endsWith(' world') - assert.deepEqual(greet.read('hello world'), { ok: 'hello world' }) - assert.deepEqual(greet.read('hi world'), { ok: 'hi world' }) - assert.deepInclude(greet.read('hello world!').error, { + assert.deepEqual(greet.tryFrom('hello world'), { ok: 'hello world' }) + assert.deepEqual(greet.tryFrom('hi world'), { ok: 'hi world' }) + assert.deepInclude(greet.tryFrom('hello world!').error, { name: 'SchemaError', message: `Expect string to end with " world" instead got "hello world!"`, }) @@ -113,24 +114,24 @@ test('string startsWith/endsWith', () => { `string().refine(endsWith("!")).refine(startsWith("hello"))` ) - assert.deepEqual(hello1.read('hello world!'), { ok: 'hello world!' }) - assert.deepEqual(hello2.read('hello world!'), { ok: 'hello world!' }) - assert.deepInclude(hello1.read('hello world').error, { + assert.deepEqual(hello1.tryFrom('hello world!'), { ok: 'hello world!' }) + assert.deepEqual(hello2.tryFrom('hello world!'), { ok: 'hello world!' }) + assert.deepInclude(hello1.tryFrom('hello world').error, { name: 'SchemaError', message: `Expect string to end with "!" instead got "hello world"`, }) - assert.deepInclude(hello2.read('hello world').error, { + assert.deepInclude(hello2.tryFrom('hello world').error, { name: 'SchemaError', message: `Expect string to end with "!" instead got "hello world"`, }) - assert.deepInclude(hello1.read('hi world!').error, { + assert.deepInclude(hello1.tryFrom('hi world!').error, { name: 'SchemaError', message: `Expect string to start with "hello" instead got "hi world!"`, }) - assert.deepInclude(hello2.read('hi world!').error, { + assert.deepInclude(hello2.tryFrom('hi world!').error, { name: 'SchemaError', message: `Expect string to start with "hello" instead got "hi world!"`, }) @@ -141,12 +142,12 @@ test('string startsWith & endsWith', () => { /** @type {Schema.StringSchema<`hello${string}` & `hi${string}`>} */ const typeofImpossible = impossible - assert.deepInclude(impossible.read('hello world').error, { + assert.deepInclude(impossible.tryFrom('hello world').error, { name: 'SchemaError', message: `Expect string to start with "hi" instead got "hello world"`, }) - assert.deepInclude(impossible.read('hello world').error, { + assert.deepInclude(impossible.tryFrom('hello world').error, { name: 'SchemaError', message: `Expect string to start with "hi" instead got "hello world"`, }) @@ -155,23 +156,23 @@ test('string startsWith & endsWith', () => { /** @type {Schema.StringSchema<`hello${string}` & `hello ${string}`>} */ const typeofHello = hello - assert.deepEqual(hello.read('hello world'), { ok: 'hello world' }) + assert.deepEqual(hello.tryFrom('hello world'), { ok: 'hello world' }) }) test('string().refine', () => { const impossible = Schema.string() .refine(Schema.startsWith('hello')) + // @ts-expect-error - catches invalid type .refine(Schema.startsWith('hi')) - /** @type {Schema.StringSchema<`hello${string}` & `hi${string}`>} */ const typeofImpossible = impossible - assert.deepInclude(impossible.read('hello world').error, { + assert.deepInclude(impossible.tryFrom('hello world').error, { name: 'SchemaError', message: `Expect string to start with "hi" instead got "hello world"`, }) - assert.deepInclude(impossible.read('hello world').error, { + assert.deepInclude(impossible.tryFrom('hello world').error, { name: 'SchemaError', message: `Expect string to start with "hi" instead got "hello world"`, }) @@ -183,14 +184,15 @@ test('string().refine', () => { /** @type {Schema.StringSchema<`hello${string}` & `hello ${string}`>} */ const typeofHello = hello - assert.deepEqual(hello.read('hello world'), { ok: 'hello world' }) + assert.deepEqual(hello.tryFrom('hello world'), { ok: 'hello world' }) const greet = hello.refine({ /** * @template {string} In * @param {In} hello + * @returns {Schema.ReadResult} */ - read(hello) { + tryFrom(hello) { if (hello.length === 11) { return Schema.ok(/** @type {In & {length: 11}} */ (hello)) } else { @@ -198,35 +200,35 @@ test('string().refine', () => { } }, }) - /** @type {Schema.StringSchema<`hello${string}` & `hello ${string}` & { length: 11 }>} */ + /** @type {Schema.StringSchema<`hello ${string}` & { length: 11 }>} */ const typeofGreet = greet assert.equal( - greet.read('hello world').ok, + greet.tryFrom('hello world').ok, /** @type {unknown} */ ('hello world') ) assert.equal( - greet.read('hello Julia').ok, + greet.tryFrom('hello Julia').ok, /** @type {unknown} */ ('hello Julia') ) - assert.deepInclude(greet.read('hello Jack').error, { + assert.deepInclude(greet.tryFrom('hello Jack').error, { name: 'SchemaError', message: 'Expected string with 11 chars', }) }) -test('never().default()', () => { +test.skip('never().default()', () => { assert.throws( () => Schema.never() // @ts-expect-error - no value satisfies default - .default('hello'), + .implicit('hello'), /Expected value of type never instead got "hello"/ ) }) -test('literal("foo").default("bar") throws', () => { +test.skip('literal("foo").default("bar") throws', () => { assert.throws( () => Schema.literal('foo') @@ -237,9 +239,9 @@ test('literal("foo").default("bar") throws', () => { }) test('default on literal has default', () => { - const schema = Schema.literal('foo').default() + const schema = Schema.literal('foo').implicit() - assert.deepEqual(schema.read(undefined), Schema.ok('foo')) + assert.deepEqual(schema.tryFrom(undefined), Schema.ok('foo')) }) test('literal has value field', () => { @@ -247,7 +249,7 @@ test('literal has value field', () => { }) test('.default().optional() is noop', () => { - const schema = Schema.string().default('hello') + const schema = Schema.string().implicit('hello') assert.equal(schema.optional(), schema) }) @@ -279,7 +281,7 @@ test('struct', () => { y: Schema.integer(), }) - const p1 = Point.read({ + const p1 = Point.tryFrom({ x: 1, y: 2, }) @@ -287,7 +289,7 @@ test('struct', () => { matchError(p1, /field "type".*expect.*"Point".*got undefined/is) - const p2 = Point.read({ + const p2 = Point.tryFrom({ type: 'Point', x: 1, y: 1, @@ -300,7 +302,7 @@ test('struct', () => { }, }) - const p3 = Point.read({ + const p3 = Point.tryFrom({ type: 'Point', x: 1, y: 1.1, @@ -310,21 +312,22 @@ test('struct', () => { matchError(p3, /field "y".*expect.*integer.*got 1.1/is) matchError( - Point.read(['h', 'e', 'l', null, 'l', 'o']), + // @ts-expect-error - bad input + Point.tryFrom(['h', 'e', 'l', null, 'l', 'o']), /Expected value of type object instead got array/ ) }) test('struct with defaults', () => { const Point = Schema.struct({ - x: Schema.number().default(0), - y: Schema.number().default(0), + x: Schema.number().implicit(0), + y: Schema.number().implicit(0), }) - assert.deepEqual(Point.read({}), { ok: { x: 0, y: 0 } }) - assert.deepEqual(Point.read({ x: 2 }), { ok: { x: 2, y: 0 } }) - assert.deepEqual(Point.read({ x: 2, y: 7 }), { ok: { x: 2, y: 7 } }) - assert.deepEqual(Point.read({ y: 7 }), { ok: { x: 0, y: 7 } }) + assert.deepEqual(Point.tryFrom({}), { ok: { x: 0, y: 0 } }) + assert.deepEqual(Point.tryFrom({ x: 2 }), { ok: { x: 2, y: 0 } }) + assert.deepEqual(Point.tryFrom({ x: 2, y: 7 }), { ok: { x: 2, y: 7 } }) + assert.deepEqual(Point.tryFrom({ y: 7 }), { ok: { x: 0, y: 7 } }) }) test('struct with literals', () => { @@ -334,10 +337,10 @@ test('struct with literals', () => { y: Schema.number(), }) - assert.deepEqual(Point.read({ x: 0, y: 0, z: 0 }), { + assert.deepEqual(Point.tryFrom({ x: 0, y: 0, z: 0 }), { ok: { x: 0, y: 0, z: 0 }, }) - matchError(Point.read({ x: 1, y: 1, z: 1 }), /"z".*expect.* 0 .* got 1/is) + matchError(Point.tryFrom({ x: 1, y: 1, z: 1 }), /"z".*expect.* 0 .* got 1/is) }) test('bad struct def', () => { @@ -355,7 +358,7 @@ test('bad struct def', () => { test('struct with null literal', () => { const schema = Schema.struct({ a: null, b: true, c: Schema.string() }) - assert.deepEqual(schema.read({ a: null, b: true, c: 'hi' }), { + assert.deepEqual(schema.tryFrom({ a: null, b: true, c: 'hi' }), { ok: { a: null, b: true, @@ -364,12 +367,12 @@ test('struct with null literal', () => { }) matchError( - schema.read({ a: null, b: false, c: '' }), + schema.tryFrom({ a: null, b: false, c: '' }), /"b".*expect.* true .* got false/is ) matchError( - schema.read({ b: true, c: '' }), + schema.tryFrom({ b: true, c: '' }), /"a".*expect.* null .* got undefined/is ) }) @@ -377,73 +380,89 @@ test('struct with null literal', () => { test('lessThan', () => { const schema = Schema.number().lessThan(100) - assert.deepEqual(schema.read(10), { ok: 10 }) - matchError(schema.read(127), /127 < 100/) - matchError(schema.read(Infinity), /Infinity < 100/) - matchError(schema.read(NaN), /NaN < 100/) + assert.deepEqual(schema.tryFrom(10), { ok: 10 }) + matchError(schema.tryFrom(127), /127 < 100/) + matchError(schema.tryFrom(Infinity), /Infinity < 100/) + matchError(schema.tryFrom(NaN), /NaN < 100/) }) test('greaterThan', () => { const schema = Schema.number().greaterThan(100) - assert.deepEqual(schema.read(127), { ok: 127 }) - matchError(schema.read(12), /12 > 100/) - assert.deepEqual(schema.read(Infinity), { ok: Infinity }) - matchError(schema.read(NaN), /NaN > 100/) + assert.deepEqual(schema.tryFrom(127), { ok: 127 }) + matchError(schema.tryFrom(12), /12 > 100/) + assert.deepEqual(schema.tryFrom(Infinity), { ok: Infinity }) + matchError(schema.tryFrom(NaN), /NaN > 100/) }) test('number().greaterThan().lessThan()', () => { const schema = Schema.number().greaterThan(3).lessThan(117) - assert.deepEqual(schema.read(4), { ok: 4 }) - assert.deepEqual(schema.read(116), { ok: 116 }) - matchError(schema.read(117), /117 < 117/) - matchError(schema.read(3), /3 > 3/) - matchError(schema.read(127), /127 < 117/) - matchError(schema.read(0), /0 > 3/) - matchError(schema.read(Infinity), /Infinity < 117/) - matchError(schema.read(NaN), /NaN > 3/) + assert.deepEqual(schema.tryFrom(4), { ok: 4 }) + assert.deepEqual(schema.tryFrom(116), { ok: 116 }) + matchError(schema.tryFrom(117), /117 < 117/) + matchError(schema.tryFrom(3), /3 > 3/) + matchError(schema.tryFrom(127), /127 < 117/) + matchError(schema.tryFrom(0), /0 > 3/) + matchError(schema.tryFrom(Infinity), /Infinity < 117/) + matchError(schema.tryFrom(NaN), /NaN > 3/) }) test('enum', () => { const schema = Schema.enum(['Red', 'Green', 'Blue']) assert.equal(schema.toString(), 'Red|Green|Blue') - assert.deepEqual(schema.read('Red'), { ok: 'Red' }) - assert.deepEqual(schema.read('Blue'), { ok: 'Blue' }) - assert.deepEqual(schema.read('Green'), { ok: 'Green' }) + assert.deepEqual(schema.tryFrom('Red'), { ok: 'Red' }) + assert.deepEqual(schema.tryFrom('Blue'), { ok: 'Blue' }) + assert.deepEqual(schema.tryFrom('Green'), { ok: 'Green' }) - matchError(schema.read('red'), /expect.* Red\|Green\|Blue .* got "red"/is) - matchError(schema.read(5), /expect.* Red\|Green\|Blue .* got 5/is) + matchError(schema.tryFrom('red'), /expect.* Red\|Green\|Blue .* got "red"/is) + matchError( + schema.tryFrom( + // @ts-expect-error - invalid arg + 5 + ), + /expect.* Red\|Green\|Blue .* got 5/is + ) }) test('tuple', () => { const schema = Schema.tuple([Schema.string(), Schema.integer()]) matchError( - schema.read([, undefined]), + schema.tryFrom([ + , + // @ts-expect-error - not an int + undefined, + ]), /invalid element at 0.*expect.*string.*got undefined/is ) matchError( - schema.read([0, 'hello']), + schema.tryFrom( + // @ts-expect-error - invalid arg + [0, 'hello'] + ), /invalid element at 0.*expect.*string.*got 0/is ) matchError( - schema.read(['0', '1']), - /invalid element at 1.*expect.*number.*got "1"/is + schema.tryFrom( + // @ts-expect-error - invalid arg + ['0', '1'] + ), + /invalid element at 1.*expect.*integer.*got "1"/is ) matchError( - schema.read(['0', Infinity]), + schema.tryFrom(['0', Infinity]), /invalid element at 1.*expect.*integer.*got Infinity/is ) matchError( - schema.read(['0', NaN]), + schema.tryFrom(['0', NaN]), /invalid element at 1.*expect.*integer.*got NaN/is ) matchError( - schema.read(['0', 0.2]), + schema.tryFrom(['0', 0.2]), /invalid element at 1.*expect.*integer.*got 0.2/is ) - assert.deepEqual(schema.read(['x', 0]), { ok: ['x', 0] }) + assert.deepEqual(schema.tryFrom(['x', 0]), { ok: ['x', 0] }) }) test('extend API', () => { @@ -473,7 +492,7 @@ test('extend API', () => { const schema = new DIDString('key') assert.equal(schema.toString(), 'new DIDString()') matchError( - schema.read( + schema.tryFrom( // @ts-expect-error 54 ), @@ -481,12 +500,18 @@ test('extend API', () => { ) matchError( - schema.read('did:echo:foo'), + schema.tryFrom('did:echo:foo'), /Expected did:key URI instead got did:echo:foo/ ) const didKey = Schema.string().refine(new DIDString('key')) - matchError(didKey.read(54), /Expect.* string instead got 54/is) + matchError( + didKey.tryFrom( + // @ts-expect-error - invalid arg + 54 + ), + /Expect.* string instead got 54/is + ) } }) @@ -510,7 +535,7 @@ test('refine', () => { /** * @param {T[]} array */ - read(array) { + tryFrom(array) { return array.length > 0 ? Schema.ok(array) : Schema.error('Array expected to have elements') @@ -520,24 +545,35 @@ test('refine', () => { const schema = Schema.array(Schema.string()).refine(new NonEmpty()) assert.equal(schema.toString(), 'array(string()).refine(new NonEmpty())') - matchError(schema.read([]), /Array expected to have elements/) - assert.deepEqual(schema.read(['hello', 'world']), { ok: ['hello', 'world'] }) - matchError(schema.read(null), /expect.* array .*got null/is) + matchError(schema.tryFrom([]), /Array expected to have elements/) + assert.deepEqual(schema.tryFrom(['hello', 'world']), { + ok: ['hello', 'world'], + }) + matchError( + schema.tryFrom( + // @ts-expect-error - invalid arg + null + ), + /expect.* array .*got null/is + ) }) test('brand', () => { const digit = Schema.integer() .refine({ - read(n) { + tryFrom(n) { return n >= 0 && n <= 9 ? Schema.ok(n) : Schema.error(`Expected digit but got ${n}`) }, + tryTo(n) { + return { ok: n } + }, }) .brand('digit') - matchError(digit.read(10), /Expected digit but got 10/) - matchError(digit.read(2.7), /Expected value of type integer/) + matchError(digit.tryFrom(10), /Expected digit but got 10/) + matchError(digit.tryFrom(2.7), /Expected value of type integer/) assert.equal(digit.from(2), 2) /** @param {Schema.Infer} n */ @@ -564,26 +600,26 @@ test('optional.default removes undefined from type', () => { // @ts-expect-error - Schema is not assignable const castError = schema1 - const schema2 = schema1.default('') + const schema2 = schema1.implicit('') /** @type {Schema.Schema} */ const castOk = schema2 - assert.deepEqual(schema1.read(undefined), { ok: undefined }) - assert.deepEqual(schema2.read(undefined), { ok: '' }) + assert.deepEqual(schema1.tryFrom(undefined), { ok: undefined }) + assert.deepEqual(schema2.tryFrom(undefined), { ok: '' }) }) test('.default("one").default("two")', () => { - const schema = Schema.string().default('one').default('two') + const schema = Schema.string().implicit('one').implicit('two') assert.equal(schema.value, 'two') - assert.deepEqual(schema.read(undefined), { ok: 'two' }) - assert.deepEqual(schema.read('three'), { ok: 'three' }) + assert.deepEqual(schema.tryFrom(undefined), { ok: 'two' }) + assert.deepEqual(schema.tryFrom('three'), { ok: 'three' }) }) -test('default throws on invalid default', () => { +test.skip('default throws on invalid default', () => { assert.throws( () => - Schema.string().default( + Schema.string().implicit( // @ts-expect-error - number is not assignable to string 101 ), @@ -593,7 +629,7 @@ test('default throws on invalid default', () => { test('unknown with default', () => { assert.throws( - () => Schema.unknown().default(undefined), + () => Schema.unknown().implicit(undefined), /undefined is not a valid default/ ) }) @@ -601,17 +637,20 @@ test('unknown with default', () => { test('default swaps undefined even if decodes to undefined', () => { /** @type {Schema.Schema} */ const schema = Schema.unknown().refine({ - read(value) { + tryFrom(value) { + return { ok: value === null ? undefined : value } + }, + tryTo(value) { return { ok: value === null ? undefined : value } }, }) - assert.deepEqual(schema.default('X').read(null), { ok: 'X' }) + assert.deepEqual(schema.implicit('X').tryFrom(null), { ok: 'X' }) }) test('record defaults', () => { const Point = Schema.struct({ - x: Schema.integer().default(1), + x: Schema.integer().implicit(1), y: Schema.integer().optional(), }) @@ -619,7 +658,13 @@ test('record defaults', () => { z: Schema.integer(), }) - matchError(Point.read(undefined), /expect.* object .* got undefined/is) + matchError( + Point.tryFrom( + // @ts-expect-error - bad input + undefined + ), + /expect.* object .* got undefined/is + ) assert.deepEqual(Point.create(), { x: 1, }) @@ -627,20 +672,20 @@ test('record defaults', () => { x: 1, }) - assert.deepEqual(Point.read({}), { + assert.deepEqual(Point.tryFrom({}), { ok: { x: 1, }, }) - assert.deepEqual(Point.read({ y: 2 }), { + assert.deepEqual(Point.tryFrom({ y: 2 }), { ok: { x: 1, y: 2, }, }) - assert.deepEqual(Point.read({ x: 2, y: 2 }), { + assert.deepEqual(Point.tryFrom({ x: 2, y: 2 }), { ok: { x: 2, y: 2, @@ -648,8 +693,8 @@ test('record defaults', () => { }) const Line = Schema.struct({ - start: Point.default({ x: 0 }), - end: Point.default({ x: 1, y: 3 }), + start: Point.implicit({ x: 0 }), + end: Point.implicit({ x: 1, y: 3 }), }) assert.deepEqual(Line.create(), { @@ -660,21 +705,39 @@ test('record defaults', () => { test('bytes schema', () => { const schema = Schema.bytes() - matchError(schema.read(undefined), /expect.* Uint8Array .* got undefined/is) + matchError( + schema.tryFrom( + // @ts-expect-error - expects Uint8Array + undefined + ), + /expect.* Uint8Array .* got undefined/is + ) const bytes = new Uint8Array([1, 2, 3]) - assert.equal(schema.read(bytes).ok, bytes, 'returns same bytes back') + assert.equal(schema.tryFrom(bytes).ok, bytes, 'returns same bytes back') matchError( - schema.read(bytes.buffer), + schema.tryFrom( + // @ts-expect-error - expects Uint8Array + bytes.buffer + ), /expect.* Uint8Array .* got ArrayBuffer/is ) matchError( - schema.read(new Int8Array(bytes.buffer)), + schema.tryFrom( + // @ts-expect-error - expects Uint8Array + new Int8Array(bytes.buffer) + ), /expect.* Uint8Array .* got Int8Array/is ) - matchError(schema.read([...bytes]), /expect.* Uint8Array .* got array/is) + matchError( + schema.tryFrom( + // @ts-expect-error + [...bytes] + ), + /expect.* Uint8Array .* got array/is + ) }) diff --git a/packages/core/test/schema/fixtures.js b/packages/core/test/schema/fixtures.js index 2c8aa303..2b2b2ef9 100644 --- a/packages/core/test/schema/fixtures.js +++ b/packages/core/test/schema/fixtures.js @@ -84,8 +84,8 @@ export const fixture = ({ in: input, got = input, array, ...expect }) => ({ ...expect.startsWithHelloEndsWithWorld, }, number: { any: fail({ expect: 'number', got }), ...expect.number }, - integer: { any: fail({ expect: 'number', got }), ...expect.integer }, - float: { any: fail({ expect: 'number', got }), ...expect.float }, + integer: { any: fail({ expect: 'integer', got }), ...expect.integer }, + float: { any: fail({ expect: 'float', got }), ...expect.float }, literal: { any: { any: fail({ expect: 'literal', got }) }, ...Object.fromEntries( @@ -188,10 +188,10 @@ export const source = [ any: fail.at('"name"', { expect: '"Point2d"', got: 'undefined' }), }, xyz: { - any: fail.at('"x"', { expect: 'number', got: 'undefined' }), + any: fail.at('"x"', { expect: 'integer', got: 'undefined' }), }, intDict: { - any: fail.at('"0"', { expect: 'number', got: '"h"' }), + any: fail.at('"0"', { expect: 'integer', got: '"h"' }), }, pointDict: { any: fail.at('0', { expect: 'name|x|y', got: '"0"' }), @@ -344,7 +344,7 @@ export const source = [ any: fail.at('"name"', { expect: '"Point2d"', got: 'undefined' }), }, xyz: { - any: fail.at('"x"', { expect: 'number', got: 'undefined' }), + any: fail.at('"x"', { expect: 'integer', got: 'undefined' }), }, dict: { any: pass(), @@ -428,7 +428,7 @@ export const source = [ unknown: { any: pass() }, tuple: { strNfloat: { - any: fail.at(1, { expect: 'number', got: '"world"' }), + any: fail.at(1, { expect: 'float', got: '"world"' }), }, strNstr: { any: pass(), @@ -465,7 +465,7 @@ export const source = [ any: fail.at(1, { got: 'object' }), }, strNfloat: { - any: fail.at(1, { got: 'object', expect: 'number' }), + any: fail.at(1, { got: 'object', expect: 'float' }), }, }, }, @@ -552,10 +552,10 @@ export const source = [ any: pass(), }, xyz: { - any: fail.at('"z"', { expect: 'number', got: 'undefined' }), + any: fail.at('"z"', { expect: 'integer', got: 'undefined' }), }, intDict: { - any: fail.at('"name"', { expect: 'number', got: '"Point2d"' }), + any: fail.at('"name"', { expect: 'integer', got: '"Point2d"' }), }, dict: { any: pass(), @@ -565,16 +565,16 @@ export const source = [ in: { name: 'Point2d', x: 0, z: 0 }, got: 'object', point2d: { - any: fail.at('"y"', { expect: 'number', got: 'undefined' }), + any: fail.at('"y"', { expect: 'integer', got: 'undefined' }), }, unknown: { any: pass(), }, xyz: { - any: fail.at('"y"', { expect: 'number', got: 'undefined' }), + any: fail.at('"y"', { expect: 'integer', got: 'undefined' }), }, intDict: { - any: fail.at('"name"', { expect: 'number', got: '"Point2d"' }), + any: fail.at('"name"', { expect: 'integer', got: '"Point2d"' }), }, pointDict: { any: fail.at('z', { expect: 'name|x|y', got: '"z"' }), @@ -596,7 +596,7 @@ export const source = [ any: pass(), }, intDict: { - any: fail.at('"name"', { expect: 'number', got: '"Point2d"' }), + any: fail.at('"name"', { expect: 'integer', got: '"Point2d"' }), }, dict: { any: pass(), @@ -635,7 +635,7 @@ export const scenarios = fixture => [ expect: fixture.unknown.any || fixture.any, }, { - schema: Schema.unknown().default('DEFAULT'), + schema: Schema.unknown().implicit('DEFAULT'), expect: (fixture.unknown.default && fixture.unknown.default('DEFAULT')) || fixture.unknown.any || @@ -654,7 +654,7 @@ export const scenarios = fixture => [ expect: fixture.string.nullable || fixture.string.any || fixture.any, }, { - schema: Schema.string().default('DEFAULT'), + schema: Schema.string().implicit('DEFAULT'), expect: (fixture.string.default && fixture.string.default('DEFAULT')) || fixture.string.any || @@ -692,7 +692,7 @@ export const scenarios = fixture => [ expect: fixture.number.nullable || fixture.number.any || fixture.any, }, { - schema: Schema.number().default(17), + schema: Schema.number().implicit(17), expect: (fixture.number.default && fixture.number.default(17)) || fixture.number.any || @@ -711,7 +711,7 @@ export const scenarios = fixture => [ expect: fixture.integer.nullable || fixture.integer.any || fixture.any, }, { - schema: Schema.integer().default(17), + schema: Schema.integer().implicit(17), expect: (fixture.integer.default && fixture.integer.default(17)) || fixture.integer.any || @@ -761,7 +761,7 @@ export const scenarios = fixture => [ fixture.any, }, { - schema: Schema.array(Schema.string().default('DEFAULT')), + schema: Schema.array(Schema.string().implicit('DEFAULT')), expect: (fixture.array.string?.default && fixture.array.string?.default('DEFAULT')) || @@ -796,7 +796,7 @@ export const scenarios = fixture => [ fixture.any, }, { - schema: Schema.literal('hello').default('hello'), + schema: Schema.literal('hello').implicit('hello'), expect: (fixture.literal?.hello?.default && fixture.literal?.hello?.default('hello')) || @@ -837,7 +837,7 @@ export const scenarios = fixture => [ fixture.any, }, { - schema: Schema.string().or(Schema.number()).default(10), + schema: Schema.string().or(Schema.number()).implicit(10), expect: (fixture.stringOrNumber?.default && fixture.stringOrNumber?.default(10)) || @@ -846,7 +846,7 @@ export const scenarios = fixture => [ fixture.any, }, { - schema: Schema.string().or(Schema.number()).default('test'), + schema: Schema.string().or(Schema.number()).implicit('test'), expect: (fixture.stringOrNumber?.default && fixture.stringOrNumber?.default('test')) || diff --git a/packages/core/test/utils.js b/packages/core/test/utils.js deleted file mode 100644 index e0d105ba..00000000 --- a/packages/core/test/utils.js +++ /dev/null @@ -1,14 +0,0 @@ -import * as Block from 'multiformats/block' -import * as codec from '@ipld/dag-cbor' -import { sha256 as hasher } from 'multiformats/hashes/sha2' - -/** - * @param {any} value - */ -export async function getBlock(value) { - return await Block.encode({ - value, - codec, - hasher - }) -} diff --git a/packages/core/test/variant-schema.spec.js b/packages/core/test/variant-schema.spec.js index c8e76046..7b515dc5 100644 --- a/packages/core/test/variant-schema.spec.js +++ b/packages/core/test/variant-schema.spec.js @@ -10,13 +10,13 @@ const Shape = Schema.variant({ }) test('variant', () => { - assert.deepEqual(Shape.read({ circle: { radius: 1 } }), { + assert.deepEqual(Shape.tryFrom({ circle: { radius: 1 } }), { ok: { circle: { radius: 1 }, }, }) - assert.deepEqual(Shape.read({ rectangle: { width: 1, height: 2 } }), { + assert.deepEqual(Shape.tryFrom({ rectangle: { width: 1, height: 2 } }), { ok: { rectangle: { width: 1, height: 2 }, }, @@ -37,7 +37,10 @@ test('variant', () => { test('variant can not have extra fields', () => { matchError( - Shape.read({ rectangle: { width: 1, height: 2 }, circle: { radius: 3 } }), + Shape.read({ + rectangle: { width: 1, height: 2 }, + circle: { radius: 3 }, + }), /Expected an object with a single key instead got object with keys circle, rectangle/ ) }) @@ -81,11 +84,11 @@ test('variant with default', () => { }), }) - assert.deepEqual(Shapes.read({ circle: { radius: 1 } }), { + assert.deepEqual(Shapes.tryFrom({ circle: { radius: 1 } }), { ok: { circle: { radius: 1 } }, }) - assert.deepEqual(Shapes.read({ rectangle: { width: 10, height: 7 } }), { + assert.deepEqual(Shapes.tryFrom({ rectangle: { width: 10, height: 7 } }), { ok: { rectangle: { width: 10, height: 7 }, }, diff --git a/packages/interface/src/lib.ts b/packages/interface/src/lib.ts index 12c840e6..6f9b4db8 100644 --- a/packages/interface/src/lib.ts +++ b/packages/interface/src/lib.ts @@ -31,6 +31,10 @@ import { Block as IPLDBlock, ToString, BlockEncoder, + BlockDecoder, + BlockCodec, + BaseDecoder, + MultibaseCodec, } from 'multiformats' import * as UCAN from '@ipld/dag-ucan' import { @@ -46,6 +50,7 @@ import { } from './capability.js' import type * as Transport from './transport.js' import type { Tuple, Block } from './transport.js' +import { LegacyLink } from 'multiformats' export * from './capability.js' export * from './transport.js' export type { @@ -73,6 +78,10 @@ export type { MultibaseDecoder, MultibaseEncoder, MulticodecCode, + BaseDecoder, + BlockDecoder, + BlockEncoder, + BlockCodec, Principal, ToJSON, ToString, @@ -82,7 +91,10 @@ export type { } export * as UCAN from '@ipld/dag-ucan' -export type BlockStore = Map, Block> +export type BlockStore = Map< + ToString, + Block +> export type AttachedLinkSet = Set> /** @@ -109,7 +121,6 @@ export interface UCANOptions { facts?: Fact[] proofs?: Proof[] - attachedBlocks?: BlockStore } /** @@ -175,12 +186,19 @@ export interface IPLDViewBuilder { * a generic traversal API. It is useful for encoding (potentially partial) IPLD * DAGs into content archives (e.g. CARs). */ -export interface IPLDView { +export interface IPLDView< + T extends unknown = unknown, + Code extends MulticodecCode = MulticodecCode, + Alg extends MulticodecCode = MulticodecCode, + V extends UnknownLink['version'] = 1 +> { /** * The root block of the IPLD DAG this is the view of. This is the the block * from which all other blocks are linked directly or transitively. */ - root: Block + root: Block + + link(): Link /** * Returns an iterable of all the IPLD blocks that are included in this view. @@ -247,8 +265,6 @@ export interface Delegation delegate(): Await> archive(): Await> - - attach(block: Block): void } /** @@ -551,7 +567,6 @@ export interface IssuedInvocation readonly proofs: Proof[] delegate(): Await> - attach(block: Block): void } export type ServiceInvocation< diff --git a/packages/server/test/server.spec.js b/packages/server/test/server.spec.js index 8451d356..28fb3c48 100644 --- a/packages/server/test/server.spec.js +++ b/packages/server/test/server.spec.js @@ -12,7 +12,7 @@ const storeAdd = Server.capability({ can: 'store/add', with: Server.URI.match({ protocol: 'did:' }), nb: Schema.struct({ - link: Server.Link.match().optional(), + link: Server.Link.optional(), }), derives: (claimed, delegated) => { if (claimed.with !== delegated.with) { @@ -38,7 +38,7 @@ const storeRemove = Server.capability({ can: 'store/remove', with: Server.URI.match({ protocol: 'did:' }), nb: Schema.struct({ - link: Server.Link.match().optional(), + link: Server.Link.optional(), }), derives: (claimed, delegated) => { if (claimed.with !== delegated.with) { diff --git a/packages/server/test/service/store.js b/packages/server/test/service/store.js index 01e5ebd0..15cf0b54 100644 --- a/packages/server/test/service/store.js +++ b/packages/server/test/service/store.js @@ -10,7 +10,7 @@ const addCapability = Server.capability({ can: 'store/add', with: Server.URI.match({ protocol: 'did:' }), nb: Schema.struct({ - link: Server.Link.match().optional(), + link: Server.Link.optional(), }), derives: (claimed, delegated) => { if (claimed.with !== delegated.with) { @@ -36,7 +36,7 @@ const removeCapability = Server.capability({ can: 'store/remove', with: Server.URI.match({ protocol: 'did:' }), nb: Schema.struct({ - link: Server.Link.match().optional(), + link: Server.Link.optional(), }), derives: (claimed, delegated) => { if (claimed.with !== delegated.with) { diff --git a/packages/validator/test/capability.spec.js b/packages/validator/test/capability.spec.js index 9e86f6fa..b40c030c 100644 --- a/packages/validator/test/capability.spec.js +++ b/packages/validator/test/capability.spec.js @@ -790,7 +790,7 @@ test('parse with nb', () => { can: 'store/add', with: URI.match({ protocol: 'did:' }), nb: Schema.struct({ - link: Link.match().optional(), + link: Link.optional(), }), derives: (claimed, delegated) => { if (claimed.with !== delegated.with) { @@ -1499,7 +1499,7 @@ test('capability with optional caveats', async () => { with: URI.match({ protocol: 'did:' }), nb: Schema.struct({ message: URI.match({ protocol: 'data:' }), - meta: Link.match().optional(), + meta: Link.optional(), }), }) diff --git a/packages/validator/test/delegate.spec.js b/packages/validator/test/delegate.spec.js index 8ff926c8..4c9615e6 100644 --- a/packages/validator/test/delegate.spec.js +++ b/packages/validator/test/delegate.spec.js @@ -187,7 +187,7 @@ test('capability with optional caveats', async () => { with: URI.match({ protocol: 'did:' }), nb: Schema.struct({ message: URI.match({ protocol: 'data:' }), - meta: Link.match().optional(), + meta: Link.optional(), }), }) diff --git a/packages/validator/test/inference.spec.js b/packages/validator/test/inference.spec.js index 793faca2..b6905a6e 100644 --- a/packages/validator/test/inference.spec.js +++ b/packages/validator/test/inference.spec.js @@ -262,7 +262,7 @@ test('can create derived capability with dict schema in nb', () => { with: URI, nb: Schema.struct({ delegations: Schema.dictionary({ - value: Schema.Link.match(), + value: Schema.Link, }), }), derives: (claim, proof) => {