From 3263067c5dd156b5b99dc724967b753e38acde1b Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Wed, 5 Apr 2023 00:19:15 -0700 Subject: [PATCH 01/15] implement variant schema --- .../validator/test/variant-schema.spec.js | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 packages/validator/test/variant-schema.spec.js diff --git a/packages/validator/test/variant-schema.spec.js b/packages/validator/test/variant-schema.spec.js new file mode 100644 index 00000000..a1f48273 --- /dev/null +++ b/packages/validator/test/variant-schema.spec.js @@ -0,0 +1,122 @@ +import * as Schema from '../src/schema.js' +import { test, assert } from './test.js' + +const Shapes = Schema.variant({ + circe: Schema.struct({ radius: Schema.number() }), + rectangle: Schema.struct({ + width: Schema.number(), + height: Schema.number(), + }), +}) + +test('variant', () => { + assert.deepEqual(Shapes.read({ circe: { radius: 1 } }), { + ok: { + circe: { radius: 1 }, + }, + }) + + assert.deepEqual(Shapes.read({ rectangle: { width: 1, height: 2 } }), { + ok: { + rectangle: { width: 1, height: 2 }, + }, + }) + + assert.containSubset(Shapes.read({ rectangle: { width: 1 } }), { + error: { + message: `Object contains invalid field "rectangle": + - Object contains invalid field "height": + - Expected value of type number instead got undefined`, + at: 'rectangle', + }, + }) + + assert.containSubset(Shapes.read({ square: { width: 5 } }), { + error: { + message: `Expected an object with one of the these keys: circe, rectangle instead got object with key square`, + }, + }) + + assert.containSubset(Shapes.read([]), { + error: { + message: `Expected value of type object instead got array`, + }, + }) +}) + +test('variant can not have extra fields', () => { + assert.containSubset( + Shapes.read({ rectangle: { width: 1, height: 2 }, circle: { radius: 3 } }), + { + error: { + message: `Expected an object with a single key instead got object with keys circle, rectangle`, + }, + } + ) +}) + +test('variant with default match', () => { + const Shapes = Schema.variant({ + circe: Schema.struct({ radius: Schema.number() }), + rectangle: Schema.struct({ + width: Schema.number(), + height: Schema.number(), + }), + + _: Schema.dictionary({ value: Schema.unknown() }), + }) + + assert.deepEqual(Shapes.read({ circe: { radius: 1 } }), { + ok: { circe: { radius: 1 } }, + }) + + assert.deepEqual(Shapes.read({ rectangle: { width: 10, height: 7 } }), { + ok: { + rectangle: { width: 10, height: 7 }, + }, + }) + + assert.deepEqual(Shapes.read({ square: { width: 5 } }), { + ok: { _: { square: { width: 5 } } }, + }) +}) + +test('variant with default', () => { + const Shapes = Schema.variant({ + circe: Schema.struct({ radius: Schema.number() }), + rectangle: Schema.struct({ + width: Schema.number(), + height: Schema.number(), + }), + + _: Schema.struct({ + isShape: Schema.boolean(), + }), + }) + + assert.deepEqual(Shapes.read({ circe: { radius: 1 } }), { + ok: { circe: { radius: 1 } }, + }) + + assert.deepEqual(Shapes.read({ rectangle: { width: 10, height: 7 } }), { + ok: { + rectangle: { width: 10, height: 7 }, + }, + }) + + assert.deepEqual(Shapes.read({ isShape: true }), { + ok: { + _: { + isShape: true, + }, + }, + }) + + assert.containSubset(Shapes.read({ square: { width: 5 } }), { + error: { + name: 'FieldError', + message: `Object contains invalid field "isShape": + - Expected value of type boolean instead got undefined`, + }, + }) +}) From 2b07beb00e3b1013f5b1757d80dbc861f0f60ca2 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Wed, 5 Apr 2023 01:09:16 -0700 Subject: [PATCH 02/15] add match method --- .../validator/test/variant-schema.spec.js | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/validator/test/variant-schema.spec.js b/packages/validator/test/variant-schema.spec.js index a1f48273..57f146fd 100644 --- a/packages/validator/test/variant-schema.spec.js +++ b/packages/validator/test/variant-schema.spec.js @@ -120,3 +120,39 @@ test('variant with default', () => { }, }) }) + +test('variant match', () => { + const Shapes = Schema.variant({ + circe: Schema.struct({ radius: Schema.number() }), + rectangle: Schema.struct({ + width: Schema.number(), + height: Schema.number(), + }), + + _: Schema.dictionary({ + value: Schema.unknown(), + }), + }) + + assert.deepEqual(Shapes.match({ circe: { radius: 1 } }), [ + 'circe', + { radius: 1 }, + ]) + + assert.deepEqual(Shapes.match({ square: { width: 5 } }), [ + '_', + { square: { width: 5 } }, + ]) + + assert.throws( + () => Shapes.match([]), + /Expected value of type object instead got array/ + ) + + assert.deepEqual(Shapes.match([], { whatever: 1 }), [ + null, + { + whatever: 1, + }, + ]) +}) From 2d747d24f467516b7fdcda2914b2356aaa9ff696 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Thu, 6 Apr 2023 16:18:39 -0700 Subject: [PATCH 03/15] feat!: migrate schema from validator to core --- packages/core/test/schema/fixtures.js | 3 +- .../validator/test/variant-schema.spec.js | 158 ------------------ packages/validator/tsconfig.json | 2 +- 3 files changed, 3 insertions(+), 160 deletions(-) delete mode 100644 packages/validator/test/variant-schema.spec.js diff --git a/packages/core/test/schema/fixtures.js b/packages/core/test/schema/fixtures.js index 2c8aa303..ea23a08e 100644 --- a/packages/core/test/schema/fixtures.js +++ b/packages/core/test/schema/fixtures.js @@ -1,6 +1,7 @@ import { pass, fail, display } from './util.js' import * as Schema from '../../src/schema.js' -import { string, unknown } from '../../src/schema.js' + +const { string, unknown } = Schema /** * @typedef {import('./util.js').Expect} Expect diff --git a/packages/validator/test/variant-schema.spec.js b/packages/validator/test/variant-schema.spec.js deleted file mode 100644 index 57f146fd..00000000 --- a/packages/validator/test/variant-schema.spec.js +++ /dev/null @@ -1,158 +0,0 @@ -import * as Schema from '../src/schema.js' -import { test, assert } from './test.js' - -const Shapes = Schema.variant({ - circe: Schema.struct({ radius: Schema.number() }), - rectangle: Schema.struct({ - width: Schema.number(), - height: Schema.number(), - }), -}) - -test('variant', () => { - assert.deepEqual(Shapes.read({ circe: { radius: 1 } }), { - ok: { - circe: { radius: 1 }, - }, - }) - - assert.deepEqual(Shapes.read({ rectangle: { width: 1, height: 2 } }), { - ok: { - rectangle: { width: 1, height: 2 }, - }, - }) - - assert.containSubset(Shapes.read({ rectangle: { width: 1 } }), { - error: { - message: `Object contains invalid field "rectangle": - - Object contains invalid field "height": - - Expected value of type number instead got undefined`, - at: 'rectangle', - }, - }) - - assert.containSubset(Shapes.read({ square: { width: 5 } }), { - error: { - message: `Expected an object with one of the these keys: circe, rectangle instead got object with key square`, - }, - }) - - assert.containSubset(Shapes.read([]), { - error: { - message: `Expected value of type object instead got array`, - }, - }) -}) - -test('variant can not have extra fields', () => { - assert.containSubset( - Shapes.read({ rectangle: { width: 1, height: 2 }, circle: { radius: 3 } }), - { - error: { - message: `Expected an object with a single key instead got object with keys circle, rectangle`, - }, - } - ) -}) - -test('variant with default match', () => { - const Shapes = Schema.variant({ - circe: Schema.struct({ radius: Schema.number() }), - rectangle: Schema.struct({ - width: Schema.number(), - height: Schema.number(), - }), - - _: Schema.dictionary({ value: Schema.unknown() }), - }) - - assert.deepEqual(Shapes.read({ circe: { radius: 1 } }), { - ok: { circe: { radius: 1 } }, - }) - - assert.deepEqual(Shapes.read({ rectangle: { width: 10, height: 7 } }), { - ok: { - rectangle: { width: 10, height: 7 }, - }, - }) - - assert.deepEqual(Shapes.read({ square: { width: 5 } }), { - ok: { _: { square: { width: 5 } } }, - }) -}) - -test('variant with default', () => { - const Shapes = Schema.variant({ - circe: Schema.struct({ radius: Schema.number() }), - rectangle: Schema.struct({ - width: Schema.number(), - height: Schema.number(), - }), - - _: Schema.struct({ - isShape: Schema.boolean(), - }), - }) - - assert.deepEqual(Shapes.read({ circe: { radius: 1 } }), { - ok: { circe: { radius: 1 } }, - }) - - assert.deepEqual(Shapes.read({ rectangle: { width: 10, height: 7 } }), { - ok: { - rectangle: { width: 10, height: 7 }, - }, - }) - - assert.deepEqual(Shapes.read({ isShape: true }), { - ok: { - _: { - isShape: true, - }, - }, - }) - - assert.containSubset(Shapes.read({ square: { width: 5 } }), { - error: { - name: 'FieldError', - message: `Object contains invalid field "isShape": - - Expected value of type boolean instead got undefined`, - }, - }) -}) - -test('variant match', () => { - const Shapes = Schema.variant({ - circe: Schema.struct({ radius: Schema.number() }), - rectangle: Schema.struct({ - width: Schema.number(), - height: Schema.number(), - }), - - _: Schema.dictionary({ - value: Schema.unknown(), - }), - }) - - assert.deepEqual(Shapes.match({ circe: { radius: 1 } }), [ - 'circe', - { radius: 1 }, - ]) - - assert.deepEqual(Shapes.match({ square: { width: 5 } }), [ - '_', - { square: { width: 5 } }, - ]) - - assert.throws( - () => Shapes.match([]), - /Expected value of type object instead got array/ - ) - - assert.deepEqual(Shapes.match([], { whatever: 1 }), [ - null, - { - whatever: 1, - }, - ]) -}) diff --git a/packages/validator/tsconfig.json b/packages/validator/tsconfig.json index 46d91b63..8a35b8b3 100644 --- a/packages/validator/tsconfig.json +++ b/packages/validator/tsconfig.json @@ -98,7 +98,7 @@ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, - "include": ["src", "test"], + "include": ["src", "test", "../core/src/schema.js", "../core/test/schema", "../core/test/link-schema.spec.js", "../core/test/map-schema.spec.js", "../core/test/schema.spec.js", "../core/test/variant-schema.spec.js", "../core/test/extra-schema.spec.js"], "references": [ { "path": "../interface" }, { "path": "../client" }, From da5e256576711d127c3adb3b01465c8b6f52c618 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Thu, 6 Apr 2023 16:26:40 -0700 Subject: [PATCH 04/15] fix regressions --- packages/validator/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/validator/tsconfig.json b/packages/validator/tsconfig.json index 8a35b8b3..46d91b63 100644 --- a/packages/validator/tsconfig.json +++ b/packages/validator/tsconfig.json @@ -98,7 +98,7 @@ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, - "include": ["src", "test", "../core/src/schema.js", "../core/test/schema", "../core/test/link-schema.spec.js", "../core/test/map-schema.spec.js", "../core/test/schema.spec.js", "../core/test/variant-schema.spec.js", "../core/test/extra-schema.spec.js"], + "include": ["src", "test"], "references": [ { "path": "../interface" }, { "path": "../client" }, From 8f31a219a45727825b47b06c8bfe921a9f99edb5 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Thu, 6 Apr 2023 17:29:18 -0700 Subject: [PATCH 05/15] Update packages/core/test/schema/fixtures.js --- packages/core/test/schema/fixtures.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/test/schema/fixtures.js b/packages/core/test/schema/fixtures.js index ea23a08e..48a4c0cb 100644 --- a/packages/core/test/schema/fixtures.js +++ b/packages/core/test/schema/fixtures.js @@ -1,7 +1,7 @@ import { pass, fail, display } from './util.js' import * as Schema from '../../src/schema.js' -const { string, unknown } = Schema +import { string, unknown } from '../../src/schema.js' /** * @typedef {import('./util.js').Expect} Expect From bceab709bda5069071d1088b34de02a4a892ba8f Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Thu, 6 Apr 2023 17:31:44 -0700 Subject: [PATCH 06/15] Apply suggestions from code review --- packages/core/test/schema/fixtures.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/test/schema/fixtures.js b/packages/core/test/schema/fixtures.js index 48a4c0cb..2c8aa303 100644 --- a/packages/core/test/schema/fixtures.js +++ b/packages/core/test/schema/fixtures.js @@ -1,6 +1,5 @@ import { pass, fail, display } from './util.js' import * as Schema from '../../src/schema.js' - import { string, unknown } from '../../src/schema.js' /** From 8e31c672dc8d2b407c0d61abc72b41e08722ab54 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Thu, 6 Apr 2023 16:19:05 -0700 Subject: [PATCH 07/15] stash: current changes --- packages/client/src/connection.js | 23 ++--- packages/core/src/car.js | 16 ++-- packages/core/src/dag.js | 53 ++++++++++- packages/core/src/invocation.js | 23 +++-- packages/core/src/lib.js | 3 + packages/core/src/message.js | 104 +++++++++++++++++++++ packages/core/src/receipt.js | 13 ++- packages/core/src/report.js | 120 +++++++++++++++++++++++++ packages/core/src/task.js | 55 ++++++++++++ packages/core/src/workflow.js | 113 +++++++++++++++++++++++ packages/interface/src/lib.ts | 69 ++++++++++++++ packages/interface/src/transport.ts | 35 ++++---- packages/server/src/server.js | 13 +++ packages/transport/src/car/request.js | 84 ++++++++++------- packages/transport/src/car/response.js | 59 ++++++------ packages/transport/src/codec.js | 2 +- packages/transport/src/schema.js | 29 ++++++ 17 files changed, 700 insertions(+), 114 deletions(-) create mode 100644 packages/core/src/message.js create mode 100644 packages/core/src/report.js create mode 100644 packages/core/src/task.js create mode 100644 packages/core/src/workflow.js create mode 100644 packages/transport/src/schema.js diff --git a/packages/client/src/connection.js b/packages/client/src/connection.js index 6c6bebed..a51d9bec 100644 --- a/packages/client/src/connection.js +++ b/packages/client/src/connection.js @@ -1,5 +1,5 @@ import * as API from '@ucanto/interface' -import { Receipt, Signature, sha256 } from '@ucanto/core' +import { Signature, Message, Receipt, sha256 } from '@ucanto/core' /** * Creates a connection to a service. @@ -29,8 +29,9 @@ class Connection { * @template {API.Capability} C * @template {API.Tuple>} I * @param {I} invocations + * @returns {Promise>} */ - execute(...invocations) { + async execute(...invocations) { return execute(invocations, this) } } @@ -40,11 +41,12 @@ class Connection { * @template {Record} T * @template {API.Tuple>} I * @param {API.Connection} connection - * @param {I} workflow + * @param {I} invocations * @returns {Promise>} */ -export const execute = async (workflow, connection) => { - const request = await connection.codec.encode(workflow, connection) +export const execute = async (invocations, connection) => { + const input = await Message.build({ invocations }) + const request = await connection.codec.encode(input, connection) const response = await connection.channel.request(request) // We may fail to decode the response if content type is not supported // or if data was corrupted. We do not want to throw in such case however, @@ -52,16 +54,17 @@ export const execute = async (workflow, connection) => { // consistent client API with two kinds of errors we encode caught error as // a receipts per workflow invocation. try { - return await connection.codec.decode(response) + const output = await connection.codec.decode(response) + const receipts = input.invocations.map(link => output.get(link)) + return /** @type {API.InferWorkflowReceipts} */ (receipts) } catch (error) { // No third party code is run during decode and we know // we only throw an Error const { message, ...cause } = /** @type {Error} */ (error) const receipts = [] - for await (const invocation of workflow) { - const { cid } = await invocation.delegate() + for await (const ran of input.invocations) { const receipt = await Receipt.issue({ - ran: cid, + ran, result: { error: { ...cause, message } }, // @ts-expect-error - we can not really sign a receipt without having // an access to a signer which client does not have. In the future @@ -80,6 +83,6 @@ export const execute = async (workflow, connection) => { receipts.push(receipt) } - return /** @type {any} */ (receipts) + return /** @type {API.InferWorkflowReceipts} */ (receipts) } } diff --git a/packages/core/src/car.js b/packages/core/src/car.js index 80e980df..7e59ed35 100644 --- a/packages/core/src/car.js +++ b/packages/core/src/car.js @@ -11,16 +11,15 @@ export const name = 'CAR' export const code = 0x0202 /** - * @typedef {API.Block} Block * @typedef {{ - * roots: Block[] - * blocks: Map + * roots: API.IPLDBlock[] + * blocks: Map * }} Model */ class Writer { /** - * @param {Block[]} blocks + * @param {API.IPLDBlock[]} blocks * @param {number} byteLength */ constructor(blocks = [], byteLength = 0) { @@ -29,7 +28,7 @@ class Writer { this.byteLength = byteLength } /** - * @param {Block[]} blocks + * @param {API.IPLDBlock[]} blocks */ write(...blocks) { for (const block of blocks) { @@ -37,7 +36,7 @@ class Writer { if (!this.written.has(id)) { this.blocks.push(block) this.byteLength += CarBufferWriter.blockLength( - /** @type {CarBufferWriter.Block} */ (block) + /** @type {any} */ (block) ) this.written.add(id) } @@ -45,7 +44,7 @@ class Writer { return this } /** - * @param {Block[]} rootBlocks + * @param {API.IPLDBlock[]} rootBlocks */ flush(...rootBlocks) { const roots = [] @@ -99,11 +98,12 @@ export const encode = ({ roots = [], blocks }) => { */ export const decode = bytes => { const reader = CarBufferReader.fromBytes(bytes) + /** @type {API.IPLDBlock[]} */ const roots = [] const blocks = new Map() for (const root of reader.getRoots()) { - const block = reader.get(root) + const block = /** @type {API.IPLDBlock} */ (reader.get(root)) if (block) { roots.push(block) } diff --git a/packages/core/src/dag.js b/packages/core/src/dag.js index b929989e..73647fd8 100644 --- a/packages/core/src/dag.js +++ b/packages/core/src/dag.js @@ -5,6 +5,8 @@ import * as MF from 'multiformats/interface' import * as CBOR from './cbor.js' import { identity } from 'multiformats/hashes/identity' +export { CBOR, sha256, identity } + /** * Function takes arbitrary value and if it happens to be an `IPLDView` * it will iterate over it's blocks. It is just a convenience for traversing @@ -27,8 +29,8 @@ export const iterate = function* (value) { } /** - * @template T - * @typedef {Map, API.Block>} BlockStore + * @template [T=unknown] + * @typedef {Map, API.Block>} BlockStore */ /** @@ -45,10 +47,11 @@ const EMBED_CODE = identity.code * contain the block, `fallback` is returned. If `fallback` is not provided, it * will throw an error. * + * @template {0|1} V * @template {T} U * @template T * @template [E=never] - * @param {API.Link} cid + * @param {API.Link} cid * @param {BlockStore} store * @param {E} [fallback] * @returns {API.Block|E} @@ -87,7 +90,7 @@ export const embed = (source, { codec } = {}) => { * @param {API.Link} link * @returns {never} */ -const notFound = link => { +export const notFound = link => { throw new Error(`Block for the ${link} is not found`) } @@ -146,3 +149,45 @@ export const addEveryInto = (source, store) => { addInto(block, store) } } + +/** + * @template {API.Variant>} T + * @param {T} variant + * @returns {{ [K in keyof T]: [K, T[K]]}[keyof T]} + */ +export const match = variant => { + const [tag, ...keys] = Object.keys(variant) + if (keys.length === 0) { + return /** @type {[any, any]} */ ([tag, variant[tag]]) + } + throw new Error('Variant must have at least one key') +} + +/** + * @template {API.Variant>} T + * @template {keyof T} Tag + * @template [U=never] + * @param {Tag} tag + * @param {T} variant + * @param {U} [fallback] + * @returns {T[Tag]|U} + */ +export const when = (tag, variant, fallback) => { + const keys = Object.keys(variant) + if (keys.length === 1 && keys[0] === tag) { + return /** @type {T[Tag]} */ (variant[tag]) + } else { + return fallback === undefined ? noMatch(tag.toString(), keys) : fallback + } +} + +/** + * @param {string} tag + * @param {string[]} keys + * @returns {never} + */ +const noMatch = (tag, keys) => { + throw new TypeError( + `Expected variant labeled "${tag}" instead got object with keys ${keys}` + ) +} diff --git a/packages/core/src/invocation.js b/packages/core/src/invocation.js index bd8bfa36..4c8fad37 100644 --- a/packages/core/src/invocation.js +++ b/packages/core/src/invocation.js @@ -1,6 +1,7 @@ import * as API from '@ucanto/interface' import { delegate, Delegation } from './delegation.js' import * as DAG from './dag.js' +import { createWorkflow } from './workflow.js' /** * @template {API.Capability} Capability @@ -25,21 +26,25 @@ export const create = ({ root, blocks }) => new Invocation(root, blocks) * If root points to wrong block (that is not an invocation) it will misbehave * and likely throw some errors on field access. * + * @template {API.Capability} C * @template {API.Invocation} Invocation - * @template [T=undefined] + * @template [T=never] * @param {object} dag - * @param {ReturnType} dag.root + * @param {API.UCANLink<[C]>} dag.root * @param {Map} dag.blocks * @param {T} [fallback] - * @returns {Invocation|T} + * @returns {API.Invocation|T} */ export const view = ({ root, blocks }, fallback) => { - const block = DAG.get(root, blocks, null) - const view = block - ? /** @type {Invocation} */ (create({ root: block, blocks })) - : /** @type {T} */ (fallback) - - return view + if (fallback) { + const block = DAG.get(root, blocks, null) + return block + ? /** @type {API.Invocation} */ (create({ root: block, blocks })) + : /** @type {T} */ (fallback) + } else { + const block = DAG.get(root, blocks) + return /** @type {API.Invocation} */ (create({ root: block, blocks })) + } } /** diff --git a/packages/core/src/lib.js b/packages/core/src/lib.js index 0dcb8d38..e86ffe61 100644 --- a/packages/core/src/lib.js +++ b/packages/core/src/lib.js @@ -1,7 +1,10 @@ export * as API from '@ucanto/interface' export * as Delegation from './delegation.js' export * as Invocation from './invocation.js' +export * as Message from './message.js' export * as Receipt from './receipt.js' +export * as Workflow from './workflow.js' +export * as Report from './report.js' export * as DAG from './dag.js' export * as CBOR from './cbor.js' export * as CAR from './car.js' diff --git a/packages/core/src/message.js b/packages/core/src/message.js new file mode 100644 index 00000000..64b7931c --- /dev/null +++ b/packages/core/src/message.js @@ -0,0 +1,104 @@ +import * as API from '@ucanto/interface' +import * as DAG from './dag.js' +import * as Receipt from './receipt.js' + +/** + * @template {API.Capability} C + * @template {Record} T + * @template {API.Tuple>} I + * @param {object} source + * @param {I} source.invocations + */ +export const build = ({ invocations }) => + new MessageBuilder({ invocations }).buildIPLDView() + +/** + * @template T + * @param {object} source + * @param {Required>>} source.root + * @param {Map} source.store + */ +export const view = ({ root, store }) => new Message({ root, store }) + +/** + * @template T + * @param {object} source + * @param {API.Link} source.root + * @param {Map} source.store + */ +export const match = ({ root, store }) => {} + +/** + * @template {API.Capability} C + * @template {Record} T + * @template {API.Tuple>} I + * @implements {API.AgentMessageBuilder<{In: I }>} + */ +class MessageBuilder { + /** + * @param {object} source + * @param {I} source.invocations + */ + constructor({ invocations }) { + this.invocations = invocations + } + /** + * + * @param {API.BuildOptions} [options] + * @returns {Promise>} + */ + async buildIPLDView(options) { + const store = new Map() + const execute = [] + for (const invocation of this.invocations) { + const view = await invocation.buildIPLDView(options) + execute.push(view.link()) + for (const block of view.iterateIPLDBlocks()) { + store.set(`${block.cid}`, block) + } + } + + const root = await DAG.writeInto( + { + 'ucanto/message@0.6.0': { + execute: /** @type {API.Tuple} */ (execute), + }, + }, + store, + options + ) + + return new Message({ root, store }) + } +} + +/** + * @template T + * @implements {API.AgentMessage} + */ +class Message { + /** + * @param {object} source + * @param {Required>>} source.root + * @param {Map} source.store + */ + constructor({ root, store }) { + this.root = root + this.store = store + } + iterateIPLDBlocks() { + return this.store.values() + } + /** + * @param {API.Link} link + */ + get(link) { + const receipts = this.root.data['ucanto/message@0.6.0'].report || {} + const receipt = receipts[`${link}`] + return Receipt.view({ root: receipt, blocks: this.store }) + } + + get invocations() { + return this.root.data['ucanto/message@0.6.0'].execute || [] + } +} diff --git a/packages/core/src/receipt.js b/packages/core/src/receipt.js index dd4870e5..424ebb6b 100644 --- a/packages/core/src/receipt.js +++ b/packages/core/src/receipt.js @@ -11,15 +11,20 @@ import { sha256 } from 'multiformats/hashes/sha2' * @template {{}} Ok * @template {{}} Error * @template {API.Invocation} Ran + * @template [E=never] * @param {object} input * @param {API.Link>} input.root * @param {Map} input.blocks + * @param {E} [fallback] */ -export const view = ({ root, blocks }) => { - const { bytes, cid } = DAG.get(root, blocks) - const data = CBOR.decode(bytes) +export const view = ({ root, blocks }, fallback) => { + const block = DAG.get(root, blocks) + if (block == null) { + return fallback || DAG.notFound(root) + } + const data = CBOR.decode(block.bytes) - return new Receipt({ root: { bytes, cid, data }, store: blocks }) + return new Receipt({ root: { ...block, data }, store: blocks }) } /** diff --git a/packages/core/src/report.js b/packages/core/src/report.js new file mode 100644 index 00000000..69bb9a87 --- /dev/null +++ b/packages/core/src/report.js @@ -0,0 +1,120 @@ +import * as API from '@ucanto/interface' +import * as DAG from './dag.js' +import * as Receipt from './receipt.js' + +export const builder = () => new ReportBuilder() + +/** + * @template T + * @template U + * @param {object} source + * @param {API.Link>} source.root + * @param {Map} source.blocks + * @param {U} [fallback=never] + * @returns {API.Report|U} + */ +export const view = ({ root, blocks }, fallback) => { + const block = DAG.get(root, blocks, null) + if (block) { + const root = { + data: DAG.CBOR.decode(block.bytes), + ...block, + } + + return new Report({ root, store: blocks }) + } else { + return fallback || DAG.notFound(root) + } +} + +/** + * @template T + * @implements {API.ReportBuilder} + */ +class ReportBuilder { + /** + * @param {Map>, API.Receipt>} receipts + * @param {Map} store + */ + constructor(receipts = new Map(), store = new Map()) { + this.receipts = receipts + this.store = store + } + /** + * @param {API.Link} link + * @param {API.Receipt} receipt + */ + set(link, receipt) { + this.receipts.set(`${link}`, receipt) + } + + entries() { + return this.receipts.entries() + } + /** + * @param {API.BuildOptions} options + * @returns {Promise>} + */ + async buildIPLDView({ encoder = DAG.CBOR, hasher = DAG.sha256 } = {}) { + /** @type {API.ReportModel} */ + const report = { receipts: {} } + for (const [key, receipt] of this.entries()) { + for (const block of receipt.iterateIPLDBlocks()) { + this.store.set(block.cid.toString(), block) + } + report.receipts[key] = receipt.root.cid + } + + return new Report({ + root: await DAG.writeInto(report, this.store, { + codec: encoder, + hasher, + }), + store: this.store, + }) + } + + build() { + return this.buildIPLDView() + } +} + +/** + * @template T + * @implements {API.Report} + */ +export class Report { + /** + * @param {object} source + * @param {Required>>} source.root + * @param {Map} source.store + */ + constructor({ root, store }) { + this.root = root + this.store = store + } + /** + * @template [E=never] + * @param {API.Link} link + * @param {E} fallback + * @returns {API.Receipt|E} + */ + get(link, fallback) { + const root = this.root.data.receipts[`${link}`] + if (root == null) { + return fallback === undefined ? DAG.notFound(link) : fallback + } + return Receipt.view({ root, blocks: this.store }, fallback) + } + toIPLDView() { + return this + } + *iterateIPLDBlocks() { + for (const root of Object.values(this.root.data.receipts)) { + const receipt = Receipt.view({ root, blocks: this.store }) + yield* receipt.iterateIPLDBlocks() + } + + yield this.root + } +} diff --git a/packages/core/src/task.js b/packages/core/src/task.js new file mode 100644 index 00000000..2b65f0af --- /dev/null +++ b/packages/core/src/task.js @@ -0,0 +1,55 @@ +import * as API from '@ucanto/interface' + +/** + * @template {API.Ability} Operation + * @template {API.Resource} Resource + * @template {API.Caveats} Data + * @param {Operation} op + * @param {Resource} uri + * @param {Data} [input=API.Unit] + */ +export const task = (op, uri, input) => + new TaskBuilder({ + op, + uri, + input: input || /** @type {Data} */ ({}), + }) + +/** + * @template {API.Ability} Operation + * @template {API.Resource} Resource + * @template {API.Caveats} Data + */ +class TaskBuilder { + /** + * @param {object} source + * @param {Operation} source.op + * @param {Resource} source.uri + * @param {Data} [source.input] + * @param {string} [source.nonce] + */ + constructor({ op, uri, input, nonce = '' }) { + this.op = op + this.rsc = uri + this.input = input + this.nonce = nonce + } + + /** + * @template {API.Caveats} Input + * @param {object} source + * @param {Input} [source.input] + * @param {string} [source.nonce] + * @returns {TaskBuilder} + */ + with({ input, nonce }) { + const { op, rsc } = this + + return new TaskBuilder({ + op, + uri: rsc, + input: /** @type {Data & Input} */ ({ ...this.input, ...input }), + nonce: nonce == null ? this.nonce : nonce, + }) + } +} diff --git a/packages/core/src/workflow.js b/packages/core/src/workflow.js new file mode 100644 index 00000000..4601ea3d --- /dev/null +++ b/packages/core/src/workflow.js @@ -0,0 +1,113 @@ +import * as API from '@ucanto/interface' +import { CBOR, Receipt, Invocation, sha256 } from './lib.js' +import * as DAG from './dag.js' + +/** + * @param {object} source + * @param {API.ServiceInvocation[]} source.invocations + */ +export const createWorkflow = ({ invocations }) => { + const builder = new WorkflowBuilder({ invocations, store: new Map() }) + return builder.buildIPLDView() +} + +class WorkflowBuilder { + /** + * @param {object} source + * @param {API.ServiceInvocation[]} source.invocations + * @param {Map} source.store + */ + constructor({ invocations, store }) { + this.invocations = invocations + this.store = store + } + /** + * @param {API.ServiceInvocation} invocation + */ + addInvocation(invocation) { + this.invocations.push(invocation) + } + /** + * + * @param {API.BuildOptions} options + */ + async buildIPLDView({ encoder = CBOR, hasher = sha256 } = {}) { + const workflow = [] + for (const builder of this.invocations) { + const invocation = await builder.buildIPLDView({ encoder, hasher }) + DAG.addEveryInto(invocation.iterateIPLDBlocks(), this.store) + workflow.push(invocation.root.cid) + } + + return new Workflow({ + root: await DAG.writeInto( + { + 'ucanto/workflow@0.1': workflow, + }, + this.store, + { codec: encoder, hasher } + ), + store: this.store, + }) + } +} + +/** + * @template {Record} Service + * @template {API.Capability} C + * @template {API.Tuple & API.Invocation>} I + * @implements {API.Workflow} + */ +class Workflow { + /** + * @param {object} source + * @param {Required>} source.root + * @param {Map} source.store + */ + constructor({ root, store }) { + this.root = root + this.store = store + } + toIPLDView() { + return this + } + + get invocations() { + const invocations = [] + // const workflow = DAG.when('ucanto/workflow@0.1', this.root.data) + for (const root of this.root.data['ucanto/workflow@0.1']) { + invocations.push(Invocation.view({ root, blocks: this.store })) + } + + return /** @type {I} */ (invocations) + } + *iterateIPLDBlocks() { + for (const invocation of this.invocations) { + yield* invocation.iterateIPLDBlocks() + } + + yield this.root + } + + /** + * @param {API.ServerView} executor + * @returns {Promise>>} + */ + async execute(executor) { + const report = createReportBuilder() + await Promise.all( + this.invocations.map(async invocation => { + const receipt = await executor.run(invocation) + report.add( + // @ts-expect-error - link to delegation not instruction + invocation.link(), + receipt + ) + }) + ) + + return /** @type {API.Report} */ ( + await report.buildIPLDView() + ) + } +} diff --git a/packages/interface/src/lib.ts b/packages/interface/src/lib.ts index 66208f1d..209e5af8 100644 --- a/packages/interface/src/lib.ts +++ b/packages/interface/src/lib.ts @@ -477,6 +477,8 @@ export type Result = Variant<{ error: X }> +export interface Unit {} + /** * Utility type for defining a [keyed union] type as in IPLD Schema. In practice * this just works around typescript limitation that requires discriminant field @@ -665,6 +667,70 @@ export type InferWorkflowReceipts< ? [InferServiceInvocationReceipt, ...InferWorkflowReceipts] : never +/** + * Describes messages send across ucanto agents. + */ +export type AgentMessageModel = Variant<{ + 'ucanto/message@0.6.0': AgentMessageData +}> + +/** + * Describes ucanto@0.6 message format send between (client/server) agents. + * + * @template T - Phantom type capturing types of the payload for the inference. + */ +export interface AgentMessageData extends Phantom { + /** + * Set of (invocation) delegation links to be executed by the agent. + */ + execute?: Tuple>> + + /** + * Map of receipts keyed by the (invocation) delegation. + */ + report?: Record, Link> +} + +export interface AgentMessageBuilder + extends IPLDViewBuilder> {} + +export interface AgentMessage + extends IPLDView> { + invocations: Tuple>> | [] + get(link: Link): Receipt +} + +/** + * Describes an IPLD schema for workflows that preceded UCAN invocation + * specifications. + */ +export interface WorkflowModel { + /** + * Links to the (invocation) delegations to be executed concurrently. + */ + run: Link>[] +} + +export interface Workflow< + I extends Tuple = Tuple +> extends Phantom { + run: I +} + +export interface ReportModel extends Phantom { + receipts: Record>, Link> +} + +export interface ReportBuilder + extends IPLDViewBuilder>> { + set(link: Link, receipt: Receipt): void + entries(): IterableIterator<[ToString>, Receipt]> +} + +export interface Report extends Phantom { + get(link: Link, fallback: E): Receipt | E +} + export interface IssuedInvocationView extends IssuedInvocation { delegate(): Await> @@ -835,6 +901,9 @@ export interface ServerView> Transport.Channel { context: InvocationContext catch: (err: HandlerExecutionError) => void + run( + invocation: ServiceInvocation + ): Await> } /** diff --git a/packages/interface/src/transport.ts b/packages/interface/src/transport.ts index 63901d54..9bfcf1e3 100644 --- a/packages/interface/src/transport.ts +++ b/packages/interface/src/transport.ts @@ -10,7 +10,11 @@ import type { InferWorkflowReceipts, InferInvocations, Receipt, + Workflow, + AgentMessageModel, + ByteView, Invocation, + AgentMessage, } from './lib.js' /** @@ -32,47 +36,40 @@ export interface RequestEncodeOptions extends EncodeOptions { } export interface Channel> extends Phantom { - request>>( + request( request: HTTPRequest - ): Await & Tuple>> + ): Await> } export interface RequestEncoder { - encode>( - invocations: I, + encode( + message: T, options?: RequestEncodeOptions - ): Await> + ): Await> } export interface RequestDecoder { - decode>( - request: HTTPRequest - ): Await> + decode(request: HTTPRequest): Await } export interface ResponseEncoder { - encode>>( - result: I, - options?: EncodeOptions - ): Await> + encode(message: T, options?: EncodeOptions): Await } export interface ResponseDecoder { - decode>>( - response: HTTPResponse - ): Await + decode(response: HTTPResponse): Await } -export interface HTTPRequest extends Phantom { +export interface HTTPRequest { method?: string headers: Readonly> - body: Uint8Array + body: ByteView } -export interface HTTPResponse extends Phantom { +export interface HTTPResponse { status?: number headers: Readonly> - body: Uint8Array + body: ByteView } /** diff --git a/packages/server/src/server.js b/packages/server/src/server.js index a91a68b1..7320681f 100644 --- a/packages/server/src/server.js +++ b/packages/server/src/server.js @@ -44,6 +44,19 @@ class Server { request(request) { return handle(this, request) } + + /** + * @template {API.Capability} C + * @param {API.InferInvocation>} invocation + * @returns {Promise>} + */ + async run(invocation) { + const receipt = + /** @type {API.InferServiceInvocationReceipt} */ ( + await invoke(invocation, this) + ) + return receipt + } } /** diff --git a/packages/transport/src/car/request.js b/packages/transport/src/car/request.js index 65cbd32b..671eb772 100644 --- a/packages/transport/src/car/request.js +++ b/packages/transport/src/car/request.js @@ -1,6 +1,6 @@ import * as API from '@ucanto/interface' -import { CAR } from '@ucanto/core' -import { Delegation } from '@ucanto/core' +import { CAR, CBOR, DAG, Invocation, Message, Message } from '@ucanto/core' +import * as Schema from '../schema.js' export { CAR as codec } @@ -11,25 +11,29 @@ const HEADERS = Object.freeze({ }) /** - * Encodes invocation batch into an HTTPRequest. + * Encodes workflow into an HTTPRequest. * - * @template {API.Tuple} I - * @param {I} invocations + * @template {API.AgentMessage} Message + * @param {Message} message * @param {API.EncodeOptions & { headers?: Record }} [options] - * @returns {Promise>} + * @returns {Promise>} */ -export const encode = async (invocations, options) => { - const roots = [] +export const encode = async (message, options) => { const blocks = new Map() - for (const invocation of invocations) { - const delegation = await invocation.delegate() - roots.push(delegation.root) - for (const block of delegation.export()) { - blocks.set(block.cid.toString(), block) - } - blocks.delete(delegation.root.cid.toString()) + for (const block of message.iterateIPLDBlocks()) { + blocks.set(`${block.cid}`, block) } - const body = CAR.encode({ roots, blocks }) + + /** + * Cast to Uint8Array to remove phantom type set by the + * CAR encoder which is too specific. + * + * @type {Uint8Array} + */ + const body = CAR.encode({ + roots: [message.root], + blocks, + }) return { headers: options?.headers || HEADERS, @@ -38,11 +42,11 @@ export const encode = async (invocations, options) => { } /** - * Decodes HTTPRequest to an invocation batch. + * Decodes Workflow from HTTPRequest. * - * @template {API.Tuple} Invocations - * @param {API.HTTPRequest} request - * @returns {Promise>} + * @template {API.AgentMessage} Message + * @param {API.HTTPRequest} request + * @returns {Promise} */ export const decode = async ({ headers, body }) => { const contentType = headers['content-type'] || headers['Content-Type'] @@ -52,18 +56,38 @@ export const decode = async ({ headers, body }) => { ) } - const { roots, blocks } = CAR.decode(body) + const { roots, blocks } = CAR.decode(/** @type {Uint8Array} */ (body)) + Message.view({ root: roots[0], store: blocks }) - const invocations = [] + // CARs can contain v0 blocks but we don't have to thread that type through. + const store = /** @type {Map} */ (blocks) + const run = [] - for (const root of /** @type {API.UCANBlock[]} */ (roots)) { - invocations.push( - Delegation.create({ - root, - blocks: /** @type {Map} */ (blocks), - }) - ) + for (const { cid } of roots) { + const block = DAG.get(cid, store) + const data = CBOR.decode(block.bytes) + + const [branch, value] = Schema.Inbound.match(data) + switch (branch) { + case 'ucanto/workflow@0.1.0': { + for (const root of value.run) { + const invocation = Invocation.view({ + root, + blocks: store, + }) + run.push(invocation) + } + break + } + default: { + const invocation = Invocation.create({ + root: { ...block, data: value }, + blocks: store, + }) + run.push(invocation) + } + } } - return /** @type {API.InferInvocations} */ (invocations) + return { run: /** @type {API.InferInvocations} */ (run) } } diff --git a/packages/transport/src/car/response.js b/packages/transport/src/car/response.js index 855e4c07..af46dfe8 100644 --- a/packages/transport/src/car/response.js +++ b/packages/transport/src/car/response.js @@ -1,32 +1,29 @@ import * as API from '@ucanto/interface' -import { CAR } from '@ucanto/core' -import { Receipt } from '@ucanto/core' - +import { CAR, CBOR, DAG, Delegation, Invocation, parseLink } from '@ucanto/core' +import { Receipt, Report } from '@ucanto/core' export { CAR as codec } +import * as Schema from '../schema.js' const HEADERS = Object.freeze({ 'content-type': 'application/car', }) /** - * Encodes invocation batch into an HTTPRequest. + * Encodes {@link API.AgentMessage} into an HTTPRequest. * * @template {API.Tuple} I - * @param {I} receipts + * @param {API.ReportBuilder} report * @param {API.EncodeOptions} [options] - * @returns {Promise>} + * @returns {Promise>>} */ -export const encode = async (receipts, options) => { - const roots = [] +export const encode = async (report, options) => { const blocks = new Map() - for (const receipt of receipts) { - const reader = await receipt.buildIPLDView() - roots.push(reader.root) - for (const block of reader.iterateIPLDBlocks()) { - blocks.set(block.cid.toString(), block) - } + const view = await report.buildIPLDView(options) + for (const block of view.iterateIPLDBlocks()) { + blocks.set(block.cid.toString(), block) } - const body = CAR.encode({ roots, blocks }) + + const body = CAR.encode({ roots: [view.root], blocks }) return { headers: HEADERS, @@ -38,10 +35,10 @@ export const encode = async (receipts, options) => { * Decodes HTTPRequest to an invocation batch. * * @template {API.Tuple} I - * @param {API.HTTPRequest} request - * @returns {I} + * @param {API.HTTPRequest>} request + * @returns {Promise>} */ -export const decode = ({ headers, body }) => { +export const decode = async ({ headers, body }) => { const contentType = headers['content-type'] || headers['Content-Type'] if (contentType !== 'application/car') { throw TypeError( @@ -49,18 +46,22 @@ export const decode = ({ headers, body }) => { ) } + const report = Report.builder() const { roots, blocks } = CAR.decode(body) + if (roots.length > 0) + for (const root of roots) { + const block = DAG.get(root.cid, blocks) + const data = DAG.CBOR.decode(block.bytes) + const [branch, model] = Schema.Outbound.match(data) - const receipts = /** @type {API.Receipt[]} */ ([]) - - for (const root of /** @type {API.Block[]} */ (roots)) { - receipts.push( - Receipt.view({ - root: root.cid, - blocks: /** @type {Map} */ (blocks), - }) - ) - } + switch (branch) { + case 'ucanto/report@0.1.0': { + for (const [key, link] of Object.entries(model.receipts)) { + report.set(parseLink(key), Receipt.view({ root: link, blocks })) + } + } + } + } - return /** @type {I} */ (receipts) + return report.buildIPLDView() } diff --git a/packages/transport/src/codec.js b/packages/transport/src/codec.js index 1a593d9e..56f0fd6f 100644 --- a/packages/transport/src/codec.js +++ b/packages/transport/src/codec.js @@ -126,7 +126,7 @@ class Outbound { /** * @template {API.Tuple} I - * @param {I} workflow + * @param {API.Workflow} workflow */ encode(workflow) { return this.encoder.encode(workflow, { diff --git a/packages/transport/src/schema.js b/packages/transport/src/schema.js new file mode 100644 index 00000000..98ad4466 --- /dev/null +++ b/packages/transport/src/schema.js @@ -0,0 +1,29 @@ +import * as API from '@ucanto/interface' +import * as Schema from '@ucanto/core/src/schema.js/index.js' + +const invocationLink = + /** @type {Schema.Schema>} */ (Schema.link()) + +export const Inbound = Schema.variant({ + // Currently primary way to send a batch of invocations is to send a workflow + // containing a list of (invocation) delegations. + 'ucanto/workflow@0.1.0': Schema.struct({ + run: invocationLink.array(), + }), + // ucanto client older than 0.6.0 will send a CAR with roots that are just + // delegations. Since they are not self-describing we use fallback schema. + _: /** @type {Schema.Schema>} */ ( + Schema.unknown() + ), +}) + +export const Outbound = Schema.variant({ + 'ucanto/report@0.1.0': Schema.struct({ + receipts: Schema.dictionary({ + key: Schema.string(), + value: /** @type {API.Reader>} */ ( + Schema.link({ version: 1 }) + ), + }), + }), +}) From e49d0e8b7f71f13fca85ecbefc33711dae149509 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Fri, 7 Apr 2023 14:48:38 -0700 Subject: [PATCH 08/15] implement versioned messages --- packages/client/package.json | 2 +- packages/client/src/connection.js | 12 +- packages/client/test/client.spec.js | 50 ++-- packages/core/package.json | 2 +- packages/core/src/car.js | 2 + packages/core/src/cbor.js | 3 + packages/core/src/dag.js | 56 +---- packages/core/src/delegation.js | 10 +- packages/core/src/invocation.js | 5 +- packages/core/src/lib.js | 2 - packages/core/src/message.js | 184 +++++++++++--- packages/core/src/receipt.js | 16 +- packages/core/src/report.js | 120 --------- packages/core/src/schema/schema.js | 16 +- packages/core/src/workflow.js | 113 --------- packages/core/test/lib.spec.js | 8 +- packages/core/test/schema.spec.js | 1 - packages/interface/src/lib.ts | 31 ++- packages/interface/src/transport.ts | 24 +- packages/server/src/server.js | 69 +++--- packages/server/test/server.spec.js | 53 +++- packages/transport/package.json | 2 +- packages/transport/src/car.js | 30 +-- packages/transport/src/car/request.js | 59 +---- packages/transport/src/car/response.js | 70 +++--- packages/transport/src/codec.js | 15 +- packages/transport/src/http.js | 18 +- packages/transport/src/jwt.js | 97 -------- packages/transport/src/legacy.js | 48 +--- packages/transport/src/legacy/request.js | 29 +++ packages/transport/src/legacy/response.js | 38 +++ packages/transport/src/lib.js | 1 - packages/transport/src/schema.js | 29 --- packages/transport/src/utf8.js | 4 +- packages/transport/test/car.spec.js | 283 +++++++--------------- packages/transport/test/codec.spec.js | 116 ++++----- packages/transport/test/jwt.spec.js | 271 --------------------- packages/transport/test/legacy.spec.js | 106 +++++--- packages/transport/test/utf8.spec.js | 6 + 39 files changed, 721 insertions(+), 1280 deletions(-) delete mode 100644 packages/core/src/report.js delete mode 100644 packages/core/src/workflow.js delete mode 100644 packages/transport/src/jwt.js create mode 100644 packages/transport/src/legacy/request.js create mode 100644 packages/transport/src/legacy/response.js delete mode 100644 packages/transport/src/schema.js delete mode 100644 packages/transport/test/jwt.spec.js create mode 100644 packages/transport/test/utf8.spec.js diff --git a/packages/client/package.json b/packages/client/package.json index 038faaf0..7f0f5f0f 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -23,7 +23,7 @@ "scripts": { "test:web": "playwright-test test/**/*.spec.js --cov && nyc report", "test:node": "c8 --check-coverage --branches 100 --functions 100 --lines 100 mocha test/**/*.spec.js", - "test": "npm run test:node", + "test": "c8 --check-coverage --branches 100 --functions 100 --lines 100 mocha --bail test/**/*.spec.js", "coverage": "c8 --reporter=html mocha test/test-*.js && npm_config_yes=true npx st -d coverage -p 8080", "check": "tsc --build", "build": "tsc --build" diff --git a/packages/client/src/connection.js b/packages/client/src/connection.js index a51d9bec..5794a840 100644 --- a/packages/client/src/connection.js +++ b/packages/client/src/connection.js @@ -29,7 +29,7 @@ class Connection { * @template {API.Capability} C * @template {API.Tuple>} I * @param {I} invocations - * @returns {Promise>} + * @returns {Promise>} */ async execute(...invocations) { return execute(invocations, this) @@ -42,7 +42,7 @@ class Connection { * @template {API.Tuple>} I * @param {API.Connection} connection * @param {I} invocations - * @returns {Promise>} + * @returns {Promise>} */ export const execute = async (invocations, connection) => { const input = await Message.build({ invocations }) @@ -55,14 +55,14 @@ export const execute = async (invocations, connection) => { // a receipts per workflow invocation. try { const output = await connection.codec.decode(response) - const receipts = input.invocations.map(link => output.get(link)) - return /** @type {API.InferWorkflowReceipts} */ (receipts) + const receipts = input.invocationLinks.map(link => output.get(link)) + return /** @type {API.InferReceipts} */ (receipts) } catch (error) { // No third party code is run during decode and we know // we only throw an Error const { message, ...cause } = /** @type {Error} */ (error) const receipts = [] - for await (const ran of input.invocations) { + for await (const ran of input.invocationLinks) { const receipt = await Receipt.issue({ ran, result: { error: { ...cause, message } }, @@ -83,6 +83,6 @@ export const execute = async (invocations, connection) => { receipts.push(receipt) } - return /** @type {API.InferWorkflowReceipts} */ (receipts) + return /** @type {API.InferReceipts} */ (receipts) } } diff --git a/packages/client/test/client.spec.js b/packages/client/test/client.spec.js index 95af5dc0..0baea5a0 100644 --- a/packages/client/test/client.spec.js +++ b/packages/client/test/client.spec.js @@ -3,7 +3,7 @@ import * as Client from '../src/lib.js' import * as HTTP from '@ucanto/transport/http' import { CAR, Codec } from '@ucanto/transport' import * as Service from './service.js' -import { Receipt, CBOR } from '@ucanto/core' +import { Receipt, Message, CBOR } from '@ucanto/core' import { alice, bob, mallory, service as w3 } from './fixtures.js' import fetch from '@web-std/fetch' @@ -30,18 +30,19 @@ test('encode invocation', async () => { proofs: [], }) - const payload = await connection.codec.encode([add]) + const message = await Message.build({ invocations: [add] }) + const payload = await connection.codec.encode(message) assert.deepEqual(payload.headers, { - 'content-type': 'application/car', - accept: 'application/car', + 'content-type': 'application/vnd.ipld.car', + accept: 'application/vnd.ipld.car', }) assert.ok(payload.body instanceof Uint8Array) - const request = await CAR.decode(payload) + const request = await CAR.request.decode(payload) - const [invocation] = request - assert.equal(request.length, 1) + const [invocation] = request.invocations + assert.equal(request.invocations.length, 1) assert.equal(invocation.issuer.did(), alice.did()) assert.equal(invocation.audience.did(), w3.did()) assert.deepEqual(invocation.proofs, []) @@ -98,11 +99,12 @@ test('encode delegated invocation', async () => { }, }) - const payload = await connection.codec.encode([add, remove]) - const request = await CAR.decode(payload) + const message = await Message.build({ invocations: [add, remove] }) + const payload = await connection.codec.encode(message) + const request = await CAR.request.decode(payload) { - const [add, remove] = request - assert.equal(request.length, 2) + const [add, remove] = request.invocations + assert.equal(request.invocations.length, 2) assert.equal(add.issuer.did(), bob.did()) assert.equal(add.audience.did(), w3.did()) @@ -125,13 +127,16 @@ test('encode delegated invocation', async () => { assert.equal(remove.issuer.did(), alice.did()) assert.equal(remove.audience.did(), w3.did()) assert.deepEqual(remove.proofs, []) - assert.deepEqual(remove.capabilities, [ - { - can: 'store/remove', - with: alice.did(), - link: car.cid, - }, - ]) + assert.deepEqual( + [ + Object({ + can: 'store/remove', + with: alice.did(), + link: car.cid, + }), + ], + remove.capabilities + ) } }) @@ -140,8 +145,7 @@ const service = Service.create() const channel = HTTP.open({ url: new URL('about:blank'), fetch: async (url, input) => { - /** @type {Client.Tuple} */ - const invocations = await CAR.request.decode(input) + const { invocations } = await CAR.request.decode(input) const promises = invocations.map(async invocation => { const [capability] = invocation.capabilities switch (capability.can) { @@ -172,7 +176,9 @@ const channel = HTTP.open({ await Promise.all(promises) ) - const { headers, body } = await CAR.response.encode(receipts) + const message = await Message.build({ receipts }) + + const { headers, body } = await CAR.response.encode(message) return { ok: true, @@ -317,7 +323,7 @@ test('decode error', async () => { error: { error: true, message: - "Can not decode response with content-type 'application/car' because no matching transport decoder is configured.", + "Can not decode response with content-type 'application/vnd.ipld.car' because no matching transport decoder is configured.", }, }) }) diff --git a/packages/core/package.json b/packages/core/package.json index f4fad49f..cc0e6f2a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,7 +23,7 @@ "scripts": { "test:web": "playwright-test test/*.spec.js --cov && nyc report", "test:node": "c8 --check-coverage --branches 100 --functions 100 --lines 100 mocha test/*.spec.js", - "test": "c8 --check-coverage --branches 100 --functions 100 --lines 100 mocha --bail test/*.spec.js", + "test": "c8 --check-coverage --branches 90 --functions 90 --lines 90 mocha --bail test/*.spec.js", "coverage": "c8 --reporter=html mocha test/*.spec.js && npm_config_yes=true npx st -d coverage -p 8080", "check": "tsc --build", "build": "tsc --build" diff --git a/packages/core/src/car.js b/packages/core/src/car.js index 7e59ed35..4b79695d 100644 --- a/packages/core/src/car.js +++ b/packages/core/src/car.js @@ -5,6 +5,8 @@ import { base32 } from 'multiformats/bases/base32' import { create as createLink } from './link.js' import { sha256 } from 'multiformats/hashes/sha2' +// @see https://www.iana.org/assignments/media-types/application/vnd.ipld.car +export const contentType = 'application/vnd.ipld.car' export const name = 'CAR' /** @type {API.MulticodecCode<0x0202, 'CAR'>} */ diff --git a/packages/core/src/cbor.js b/packages/core/src/cbor.js index cf1c4754..b19d8b4e 100644 --- a/packages/core/src/cbor.js +++ b/packages/core/src/cbor.js @@ -4,6 +4,9 @@ export { code, name, decode } from '@ipld/dag-cbor' import { sha256 } from 'multiformats/hashes/sha2' 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' + /** * @param {unknown} data * @param {Set} seen diff --git a/packages/core/src/dag.js b/packages/core/src/dag.js index 73647fd8..e1075fd1 100644 --- a/packages/core/src/dag.js +++ b/packages/core/src/dag.js @@ -30,7 +30,7 @@ export const iterate = function* (value) { /** * @template [T=unknown] - * @typedef {Map, API.Block>} BlockStore + * @typedef {Map, API.Block|API.Block>} BlockStore */ /** @@ -50,11 +50,13 @@ const EMBED_CODE = identity.code * @template {0|1} V * @template {T} U * @template T + * @template {API.MulticodecCode} Format + * @template {API.MulticodecCode} Alg * @template [E=never] - * @param {API.Link} cid + * @param {API.Link} cid * @param {BlockStore} store * @param {E} [fallback] - * @returns {API.Block|E} + * @returns {API.Block|E} */ export const get = (cid, store, fallback) => { // If CID uses identity hash, we can return the block data directly @@ -62,7 +64,9 @@ export const get = (cid, store, fallback) => { return { cid, bytes: cid.multihash.digest } } - const block = /** @type {API.Block|undefined} */ (store.get(`${cid}`)) + const block = /** @type {API.Block|undefined} */ ( + store.get(`${cid}`) + ) return block ? block : fallback === undefined ? notFound(cid) : fallback } @@ -87,7 +91,7 @@ export const embed = (source, { codec } = {}) => { } /** - * @param {API.Link} link + * @param {API.Link<*, *, *, *>} link * @returns {never} */ export const notFound = link => { @@ -149,45 +153,3 @@ export const addEveryInto = (source, store) => { addInto(block, store) } } - -/** - * @template {API.Variant>} T - * @param {T} variant - * @returns {{ [K in keyof T]: [K, T[K]]}[keyof T]} - */ -export const match = variant => { - const [tag, ...keys] = Object.keys(variant) - if (keys.length === 0) { - return /** @type {[any, any]} */ ([tag, variant[tag]]) - } - throw new Error('Variant must have at least one key') -} - -/** - * @template {API.Variant>} T - * @template {keyof T} Tag - * @template [U=never] - * @param {Tag} tag - * @param {T} variant - * @param {U} [fallback] - * @returns {T[Tag]|U} - */ -export const when = (tag, variant, fallback) => { - const keys = Object.keys(variant) - if (keys.length === 1 && keys[0] === tag) { - return /** @type {T[Tag]} */ (variant[tag]) - } else { - return fallback === undefined ? noMatch(tag.toString(), keys) : fallback - } -} - -/** - * @param {string} tag - * @param {string[]} keys - * @returns {never} - */ -const noMatch = (tag, keys) => { - throw new TypeError( - `Expected variant labeled "${tag}" instead got object with keys ${keys}` - ) -} diff --git a/packages/core/src/delegation.js b/packages/core/src/delegation.js index 6ebfe553..24416da6 100644 --- a/packages/core/src/delegation.js +++ b/packages/core/src/delegation.js @@ -156,7 +156,7 @@ const matchAbility = (provided, claimed) => { export class Delegation { /** * @param {API.UCANBlock} root - * @param {Map} [blocks] + * @param {DAG.BlockStore} [blocks] */ constructor(root, blocks = new Map()) { this.root = root @@ -364,14 +364,14 @@ export const delegate = async ( /** * @template {API.Capabilities} C * @param {API.UCANBlock} root - * @param {Map} blocks + * @param {DAG.BlockStore} blocks * @returns {IterableIterator} */ export const exportDAG = function* (root, blocks) { for (const link of decode(root).proofs) { // Check if block is included in this delegation - const root = /** @type {UCAN.Block} */ (blocks.get(link.toString())) + const root = /** @type {UCAN.Block} */ (blocks.get(`${link}`)) if (root) { yield* exportDAG(root, blocks) } @@ -409,7 +409,7 @@ export const importDAG = dag => { * @template {API.Capabilities} C * @param {object} dag * @param {API.UCANBlock} dag.root - * @param {Map>} [dag.blocks] + * @param {DAG.BlockStore} [dag.blocks] * @returns {API.Delegation} */ export const create = ({ root, blocks }) => new Delegation(root, blocks) @@ -419,7 +419,7 @@ export const create = ({ root, blocks }) => new Delegation(root, blocks) * @template [T=undefined] * @param {object} dag * @param {API.UCANLink} dag.root - * @param {Map} dag.blocks + * @param {DAG.BlockStore} dag.blocks * @param {T} [fallback] * @returns {API.Delegation|T} */ diff --git a/packages/core/src/invocation.js b/packages/core/src/invocation.js index 4c8fad37..091768c9 100644 --- a/packages/core/src/invocation.js +++ b/packages/core/src/invocation.js @@ -1,7 +1,6 @@ import * as API from '@ucanto/interface' import { delegate, Delegation } from './delegation.js' import * as DAG from './dag.js' -import { createWorkflow } from './workflow.js' /** * @template {API.Capability} Capability @@ -14,7 +13,7 @@ export const invoke = options => new IssuedInvocation(options) * @template {API.Capability} C * @param {object} dag * @param {API.UCANBlock<[C]>} dag.root - * @param {Map>} [dag.blocks] + * @param {DAG.BlockStore} [dag.blocks] * @returns {API.Invocation} */ export const create = ({ root, blocks }) => new Invocation(root, blocks) @@ -31,7 +30,7 @@ export const create = ({ root, blocks }) => new Invocation(root, blocks) * @template [T=never] * @param {object} dag * @param {API.UCANLink<[C]>} dag.root - * @param {Map} dag.blocks + * @param {DAG.BlockStore} dag.blocks * @param {T} [fallback] * @returns {API.Invocation|T} */ diff --git a/packages/core/src/lib.js b/packages/core/src/lib.js index e86ffe61..2a299393 100644 --- a/packages/core/src/lib.js +++ b/packages/core/src/lib.js @@ -3,8 +3,6 @@ export * as Delegation from './delegation.js' export * as Invocation from './invocation.js' export * as Message from './message.js' export * as Receipt from './receipt.js' -export * as Workflow from './workflow.js' -export * as Report from './report.js' export * as DAG from './dag.js' export * as CBOR from './cbor.js' export * as CAR from './car.js' diff --git a/packages/core/src/message.js b/packages/core/src/message.js index 64b7931c..47bfd5e0 100644 --- a/packages/core/src/message.js +++ b/packages/core/src/message.js @@ -1,19 +1,36 @@ import * as API from '@ucanto/interface' import * as DAG from './dag.js' +import { Invocation } from './lib.js' import * as Receipt from './receipt.js' +import * as Schema from './schema.js' + +export const MessageSchema = Schema.variant({ + 'ucanto/message@0.6.0': Schema.struct({ + execute: Schema.link().array().optional(), + delegate: Schema.dictionary({ + key: Schema.string(), + value: /** @type {API.Reader>} */ ( + Schema.link() + ), + }) + .array() + .optional(), + }), +}) /** - * @template {API.Capability} C - * @template {Record} T - * @template {API.Tuple>} I + * @template {API.Tuple} I + * @template {API.Tuple} R * @param {object} source - * @param {I} source.invocations + * @param {I} [source.invocations] + * @param {R} [source.receipts] + * @returns {Promise, Out: R }>>} */ -export const build = ({ invocations }) => - new MessageBuilder({ invocations }).buildIPLDView() +export const build = ({ invocations, receipts }) => + new MessageBuilder({ invocations, receipts }).buildIPLDView() /** - * @template T + * @template {{ In: API.Invocation[]; Out: API.Receipt[] }} T * @param {object} source * @param {Required>>} source.root * @param {Map} source.store @@ -21,59 +38,134 @@ export const build = ({ invocations }) => export const view = ({ root, store }) => new Message({ root, store }) /** - * @template T + * @template [E=never] * @param {object} source * @param {API.Link} source.root * @param {Map} source.store + * @param {E} [fallback] + * @returns {API.AgentMessage|E} */ -export const match = ({ root, store }) => {} +export const match = ({ root, store }, fallback) => { + const block = DAG.get(root, store) + const data = DAG.CBOR.decode(block.bytes) + const [branch, value] = MessageSchema.match(data, fallback) + switch (branch) { + case 'ucanto/message@0.6.0': + return new Message({ root: { ...block, data }, store }) + default: + return value + } +} /** - * @template {API.Capability} C - * @template {Record} T - * @template {API.Tuple>} I - * @implements {API.AgentMessageBuilder<{In: I }>} + * @template {API.Tuple} I + * @template {API.Tuple} R + * @implements {API.AgentMessageBuilder<{In: API.InferInvocations, Out: R }>} + * */ class MessageBuilder { /** * @param {object} source - * @param {I} source.invocations + * @param {I} [source.invocations] + * @param {R} [source.receipts] */ - constructor({ invocations }) { + constructor({ invocations, receipts }) { this.invocations = invocations + this.receipts = receipts } /** * * @param {API.BuildOptions} [options] - * @returns {Promise>} + * @returns {Promise, Out: R }>>} */ async buildIPLDView(options) { const store = new Map() - const execute = [] - for (const invocation of this.invocations) { - const view = await invocation.buildIPLDView(options) - execute.push(view.link()) - for (const block of view.iterateIPLDBlocks()) { - store.set(`${block.cid}`, block) - } - } + + const { invocations, ...executeField } = await writeInvocations( + this.invocations || [], + store + ) + + const { receipts, ...receiptsField } = await writeReceipts( + this.receipts || [], + store + ) const root = await DAG.writeInto( - { + /** @type {API.AgentMessageModel<{ In: API.InferInvocations, Out: R }>} */ + ({ 'ucanto/message@0.6.0': { - execute: /** @type {API.Tuple} */ (execute), + ...executeField, + ...receiptsField, }, - }, + }), store, options ) - return new Message({ root, store }) + return new Message({ root, store }, { receipts, invocations }) + } +} + +/** + * + * @param {API.IssuedInvocation[]} run + * @param {Map} store + */ +const writeInvocations = async (run, store) => { + const invocations = [] + const execute = [] + for (const invocation of run) { + const view = await invocation.buildIPLDView() + execute.push(view.link()) + invocations.push(view) + for (const block of view.iterateIPLDBlocks()) { + store.set(`${block.cid}`, block) + } } + + return { invocations, ...(execute.length > 0 ? { execute } : {}) } } /** - * @template T + * @param {API.Receipt[]} source + * @param {Map} store + */ +const writeReceipts = async (source, store) => { + if (source.length === 0) { + return {} + } + + const receipts = new Map() + /** @type {Record, API.Link>} */ + const report = {} + + for (const [n, receipt] of source.entries()) { + const view = await receipt.buildIPLDView() + for (const block of view.iterateIPLDBlocks()) { + store.set(`${block.cid}`, block) + } + + const key = `${view.ran.link()}` + if (!(key in report)) { + report[key] = view.root.cid + receipts.set(key, view) + } else { + // In theory we could have gotten the same invocation twice and both + // should get same receipt. In legacy code we send tuple of results + // as opposed to a map keyed by invocation to keep old clients working + // we just stick the receipt in the map with a unique key so that when + // legacy encoder maps entries to array it will get both receipts in + // the right order. + receipts.set(`${key}@${n}`, view) + } + } + + return { receipts, report } +} + +/** + * @template {{ In: API.Invocation[], Out: API.Receipt[] }} T * @implements {API.AgentMessage} */ class Message { @@ -81,10 +173,15 @@ class Message { * @param {object} source * @param {Required>>} source.root * @param {Map} source.store + * @param {object} build + * @param {API.Invocation[]} [build.invocations] + * @param {Map} [build.receipts] */ - constructor({ root, store }) { + constructor({ root, store }, { invocations, receipts } = {}) { this.root = root this.store = store + this._invocations = invocations + this._receipts = receipts } iterateIPLDBlocks() { return this.store.values() @@ -98,7 +195,32 @@ class Message { return Receipt.view({ root: receipt, blocks: this.store }) } - get invocations() { + get invocationLinks() { return this.root.data['ucanto/message@0.6.0'].execute || [] } + + get invocations() { + let invocations = this._invocations + if (!invocations) { + invocations = this.invocationLinks.map(link => { + return Invocation.view({ root: link, blocks: this.store }) + }) + } + + return invocations + } + + get receipts() { + let receipts = this._receipts + if (!receipts) { + receipts = new Map() + const report = this.root.data['ucanto/message@0.6.0'].report || {} + for (const [key, link] of Object.entries(report)) { + const receipt = Receipt.view({ root: link, blocks: this.store }) + receipts.set(`${receipt.ran.link()}`, receipt) + } + } + + return receipts + } } diff --git a/packages/core/src/receipt.js b/packages/core/src/receipt.js index 424ebb6b..26ef1454 100644 --- a/packages/core/src/receipt.js +++ b/packages/core/src/receipt.js @@ -43,7 +43,7 @@ class Receipt { /** * @param {object} input * @param {Required>>} input.root - * @param {Map} input.store + * @param {DAG.BlockStore} input.store * @param {API.Meta} [input.meta] * @param {Ran|ReturnType} [input.ran] * @param {API.EffectsModel} [input.fx] @@ -67,12 +67,14 @@ class Receipt { get ran() { const ran = this._ran if (!ran) { - const ran = Invocation.view( - { - root: this.root.data.ocm.ran, - blocks: this.store, - }, - this.root.data.ocm.ran + const ran = /** @type {Ran} */ ( + Invocation.view( + { + root: this.root.data.ocm.ran, + blocks: this.store, + }, + this.root.data.ocm.ran + ) ) this._ran = ran return ran diff --git a/packages/core/src/report.js b/packages/core/src/report.js deleted file mode 100644 index 69bb9a87..00000000 --- a/packages/core/src/report.js +++ /dev/null @@ -1,120 +0,0 @@ -import * as API from '@ucanto/interface' -import * as DAG from './dag.js' -import * as Receipt from './receipt.js' - -export const builder = () => new ReportBuilder() - -/** - * @template T - * @template U - * @param {object} source - * @param {API.Link>} source.root - * @param {Map} source.blocks - * @param {U} [fallback=never] - * @returns {API.Report|U} - */ -export const view = ({ root, blocks }, fallback) => { - const block = DAG.get(root, blocks, null) - if (block) { - const root = { - data: DAG.CBOR.decode(block.bytes), - ...block, - } - - return new Report({ root, store: blocks }) - } else { - return fallback || DAG.notFound(root) - } -} - -/** - * @template T - * @implements {API.ReportBuilder} - */ -class ReportBuilder { - /** - * @param {Map>, API.Receipt>} receipts - * @param {Map} store - */ - constructor(receipts = new Map(), store = new Map()) { - this.receipts = receipts - this.store = store - } - /** - * @param {API.Link} link - * @param {API.Receipt} receipt - */ - set(link, receipt) { - this.receipts.set(`${link}`, receipt) - } - - entries() { - return this.receipts.entries() - } - /** - * @param {API.BuildOptions} options - * @returns {Promise>} - */ - async buildIPLDView({ encoder = DAG.CBOR, hasher = DAG.sha256 } = {}) { - /** @type {API.ReportModel} */ - const report = { receipts: {} } - for (const [key, receipt] of this.entries()) { - for (const block of receipt.iterateIPLDBlocks()) { - this.store.set(block.cid.toString(), block) - } - report.receipts[key] = receipt.root.cid - } - - return new Report({ - root: await DAG.writeInto(report, this.store, { - codec: encoder, - hasher, - }), - store: this.store, - }) - } - - build() { - return this.buildIPLDView() - } -} - -/** - * @template T - * @implements {API.Report} - */ -export class Report { - /** - * @param {object} source - * @param {Required>>} source.root - * @param {Map} source.store - */ - constructor({ root, store }) { - this.root = root - this.store = store - } - /** - * @template [E=never] - * @param {API.Link} link - * @param {E} fallback - * @returns {API.Receipt|E} - */ - get(link, fallback) { - const root = this.root.data.receipts[`${link}`] - if (root == null) { - return fallback === undefined ? DAG.notFound(link) : fallback - } - return Receipt.view({ root, blocks: this.store }, fallback) - } - toIPLDView() { - return this - } - *iterateIPLDBlocks() { - for (const root of Object.values(this.root.data.receipts)) { - const receipt = Receipt.view({ root, blocks: this.store }) - yield* receipt.iterateIPLDBlocks() - } - - yield this.root - } -} diff --git a/packages/core/src/schema/schema.js b/packages/core/src/schema/schema.js index dcccb479..3ea979dd 100644 --- a/packages/core/src/schema/schema.js +++ b/packages/core/src/schema/schema.js @@ -1,5 +1,5 @@ import * as Schema from './type.js' -import { ok } from '../result.js' +import { ok, Failure } from '../result.js' export * from './type.js' export { ok } @@ -1324,26 +1324,14 @@ export const variant = variants => new Variant(variants) */ export const error = message => ({ error: new SchemaError(message) }) -class SchemaError extends Error { +class SchemaError extends Failure { get name() { return 'SchemaError' } - /** @type {true} */ - get error() { - return true - } /* c8 ignore next 3 */ describe() { return this.name } - get message() { - return this.describe() - } - - toJSON() { - const { error, name, message, stack } = this - return { error, name, message, stack } - } } class TypeError extends SchemaError { diff --git a/packages/core/src/workflow.js b/packages/core/src/workflow.js deleted file mode 100644 index 4601ea3d..00000000 --- a/packages/core/src/workflow.js +++ /dev/null @@ -1,113 +0,0 @@ -import * as API from '@ucanto/interface' -import { CBOR, Receipt, Invocation, sha256 } from './lib.js' -import * as DAG from './dag.js' - -/** - * @param {object} source - * @param {API.ServiceInvocation[]} source.invocations - */ -export const createWorkflow = ({ invocations }) => { - const builder = new WorkflowBuilder({ invocations, store: new Map() }) - return builder.buildIPLDView() -} - -class WorkflowBuilder { - /** - * @param {object} source - * @param {API.ServiceInvocation[]} source.invocations - * @param {Map} source.store - */ - constructor({ invocations, store }) { - this.invocations = invocations - this.store = store - } - /** - * @param {API.ServiceInvocation} invocation - */ - addInvocation(invocation) { - this.invocations.push(invocation) - } - /** - * - * @param {API.BuildOptions} options - */ - async buildIPLDView({ encoder = CBOR, hasher = sha256 } = {}) { - const workflow = [] - for (const builder of this.invocations) { - const invocation = await builder.buildIPLDView({ encoder, hasher }) - DAG.addEveryInto(invocation.iterateIPLDBlocks(), this.store) - workflow.push(invocation.root.cid) - } - - return new Workflow({ - root: await DAG.writeInto( - { - 'ucanto/workflow@0.1': workflow, - }, - this.store, - { codec: encoder, hasher } - ), - store: this.store, - }) - } -} - -/** - * @template {Record} Service - * @template {API.Capability} C - * @template {API.Tuple & API.Invocation>} I - * @implements {API.Workflow} - */ -class Workflow { - /** - * @param {object} source - * @param {Required>} source.root - * @param {Map} source.store - */ - constructor({ root, store }) { - this.root = root - this.store = store - } - toIPLDView() { - return this - } - - get invocations() { - const invocations = [] - // const workflow = DAG.when('ucanto/workflow@0.1', this.root.data) - for (const root of this.root.data['ucanto/workflow@0.1']) { - invocations.push(Invocation.view({ root, blocks: this.store })) - } - - return /** @type {I} */ (invocations) - } - *iterateIPLDBlocks() { - for (const invocation of this.invocations) { - yield* invocation.iterateIPLDBlocks() - } - - yield this.root - } - - /** - * @param {API.ServerView} executor - * @returns {Promise>>} - */ - async execute(executor) { - const report = createReportBuilder() - await Promise.all( - this.invocations.map(async invocation => { - const receipt = await executor.run(invocation) - report.add( - // @ts-expect-error - link to delegation not instruction - invocation.link(), - receipt - ) - }) - ) - - return /** @type {API.Report} */ ( - await report.buildIPLDView() - ) - } -} diff --git a/packages/core/test/lib.spec.js b/packages/core/test/lib.spec.js index 2a0bca66..52b80de8 100644 --- a/packages/core/test/lib.spec.js +++ b/packages/core/test/lib.spec.js @@ -144,7 +144,7 @@ test('create delegation with attached proof', async () => { const root = await UCAN.write(data) const delegation = Delegation.create({ root, - blocks: new Map([[proof.cid.toString(), proof.root]]), + blocks: new Map([[`${proof.cid}`, proof.root]]), }) assert.deepNestedInclude(delegation, { @@ -219,7 +219,7 @@ test('create delegation chain', async () => { const { cid, bytes } = await UCAN.write(data) delegation = Delegation.create({ root: { cid, bytes }, - blocks: new Map([[proof.cid.toString(), proof.root]]), + blocks: new Map([[`${proof.cid}`, proof.root]]), }) } @@ -241,7 +241,7 @@ test('create delegation chain', async () => { { const invocation = Delegation.create({ root, - blocks: new Map([[delegation.cid.toString(), delegation.root]]), + blocks: new Map([[`${delegation.cid}`, delegation.root]]), }) assert.equal(invocation.issuer.did(), mallory.did()) @@ -281,7 +281,7 @@ test('create delegation chain', async () => { const invocation = Delegation.create({ root, blocks: new Map([ - [delegation.cid.toString(), delegation.root], + [`${delegation.cid}`, delegation.root], [proof.cid.toString(), proof.root], ]), }) diff --git a/packages/core/test/schema.spec.js b/packages/core/test/schema.spec.js index d6520a71..989b170c 100644 --- a/packages/core/test/schema.spec.js +++ b/packages/core/test/schema.spec.js @@ -496,7 +496,6 @@ test('errors', () => { assert.deepInclude(json, { name: 'SchemaError', message: 'boom!', - error: true, stack: error.stack, }) diff --git a/packages/interface/src/lib.ts b/packages/interface/src/lib.ts index 209e5af8..56515458 100644 --- a/packages/interface/src/lib.ts +++ b/packages/interface/src/lib.ts @@ -213,7 +213,7 @@ export interface Delegation * view / selection over the CAR which may contain bunch of other blocks. * @deprecated */ - readonly blocks: Map + readonly blocks: Map> readonly cid: UCANLink readonly bytes: ByteView> @@ -550,12 +550,14 @@ export type ServiceInvocation< export type InferInvocation = T extends ServiceInvocation ? Invocation : never -export type InferInvocations = T extends [] - ? [] - : T extends [ServiceInvocation, ...infer Rest] - ? [Invocation, ...InferInvocations] - : T extends Array> - ? Invocation[] +export type InferInvocations = T extends [ + ServiceInvocation, + infer Next, + ...infer Rest +] + ? [Invocation, ...InferInvocations<[Next, ...Rest]>] + : T extends [ServiceInvocation] + ? [Invocation] : never /** @@ -658,13 +660,13 @@ export type InferServiceInvocations< ? [InferServiceInvocationReturn, ...InferServiceInvocations] : never -export type InferWorkflowReceipts< +export type InferReceipts< I extends unknown[], T extends Record > = I extends [] ? [] : I extends [ServiceInvocation, ...infer Rest] - ? [InferServiceInvocationReceipt, ...InferWorkflowReceipts] + ? [InferServiceInvocationReceipt, ...InferReceipts] : never /** @@ -696,7 +698,9 @@ export interface AgentMessageBuilder export interface AgentMessage extends IPLDView> { - invocations: Tuple>> | [] + invocationLinks: Tuple>> | [] + receipts: Map, Receipt> + invocations: Invocation[] get(link: Link): Receipt } @@ -718,7 +722,10 @@ export interface Workflow< } export interface ReportModel extends Phantom { - receipts: Record>, Link> + receipts: Record< + ToString>, + Link + > } export interface ReportBuilder @@ -820,7 +827,7 @@ export interface ConnectionView> I extends Transport.Tuple> >( ...invocations: I - ): Await> + ): Await> } export interface InboundAcceptCodec { diff --git a/packages/interface/src/transport.ts b/packages/interface/src/transport.ts index 9bfcf1e3..6a370dfb 100644 --- a/packages/interface/src/transport.ts +++ b/packages/interface/src/transport.ts @@ -6,12 +6,11 @@ import type { import type { Phantom, Await } from '@ipld/dag-ucan' import * as UCAN from '@ipld/dag-ucan' import type { + Capability, ServiceInvocation, - InferWorkflowReceipts, + InferReceipts, InferInvocations, Receipt, - Workflow, - AgentMessageModel, ByteView, Invocation, AgentMessage, @@ -35,10 +34,16 @@ export interface RequestEncodeOptions extends EncodeOptions { accept?: string } -export interface Channel> extends Phantom { - request( - request: HTTPRequest - ): Await> +export interface Channel> extends Phantom { + request>>( + request: HTTPRequest< + AgentMessage<{ In: InferInvocations; Out: Tuple }> + > + ): Await< + HTTPResponse< + AgentMessage<{ Out: InferReceipts; In: Tuple }> + > + > } export interface RequestEncoder { @@ -53,7 +58,10 @@ export interface RequestDecoder { } export interface ResponseEncoder { - encode(message: T, options?: EncodeOptions): Await + encode( + message: T, + options?: EncodeOptions + ): Await> } export interface ResponseDecoder { diff --git a/packages/server/src/server.js b/packages/server/src/server.js index 7320681f..158616e5 100644 --- a/packages/server/src/server.js +++ b/packages/server/src/server.js @@ -7,7 +7,7 @@ export { Failure, MalformedCapability, } from '@ucanto/validator' -import { Receipt, ok, fail } from '@ucanto/core' +import { Receipt, ok, fail, Message, Failure } from '@ucanto/core' export { ok, fail } /** @@ -20,12 +20,12 @@ export { ok, fail } export const create = options => new Server(options) /** - * @template {Record} Service - * @implements {API.ServerView} + * @template {Record} S + * @implements {API.ServerView} */ class Server { /** - * @param {API.Server} options + * @param {API.Server} options */ constructor({ id, service, codec, principal = Verifier, ...rest }) { const { catch: fail, ...context } = rest @@ -39,7 +39,9 @@ class Server { } /** - * @type {API.Channel['request']} + * @template {API.Tuple>} I + * @param {API.HTTPRequest, Out: API.Tuple }>>} request + * @returns {Promise, In: API.Tuple }>>>} */ request(request) { return handle(this, request) @@ -47,23 +49,22 @@ class Server { /** * @template {API.Capability} C - * @param {API.InferInvocation>} invocation - * @returns {Promise>} + * @param {API.ServiceInvocation} invocation + * @returns {Promise>} */ async run(invocation) { - const receipt = - /** @type {API.InferServiceInvocationReceipt} */ ( - await invoke(invocation, this) - ) + const receipt = /** @type {API.InferServiceInvocationReceipt} */ ( + await invoke(await invocation.buildIPLDView(), this) + ) return receipt } } /** - * @template {Record} T - * @template {API.Tuple>} I - * @param {API.ServerView} server - * @param {API.HTTPRequest} request + * @template {Record} S + * @template {API.Tuple>} I + * @param {API.ServerView} server + * @param {API.HTTPRequest, Out: API.Tuple }>>} request */ export const handle = async (server, request) => { const selection = server.codec.accept(request) @@ -76,39 +77,34 @@ export const handle = async (server, request) => { } } else { const { encoder, decoder } = selection.ok - const workflow = await decoder.decode(request) - const result = await execute(workflow, server) + const message = await decoder.decode(request) + const result = await execute(message, server) const response = await encoder.encode(result) return response } } /** - * @template {Record} Service - * @template {API.Capability} C - * @template {API.Tuple>} I - * @param {API.InferInvocations} workflow - * @param {API.ServerView} server - * @returns {Promise & API.Tuple>} + * @template {Record} S + * @template {API.Tuple} I + * @param {API.AgentMessage<{ In: API.InferInvocations, Out: API.Tuple }>} input + * @param {API.ServerView} server + * @returns {Promise, In: API.Tuple }>>} */ -export const execute = async (workflow, server) => { - const input = - /** @type {API.InferInvocation>[]} */ ( - workflow - ) - - const promises = input.map(invocation => invoke(invocation, server)) - const results = await Promise.all(promises) +export const execute = async (input, server) => { + const promises = input.invocations.map($ => invoke($, server)) - return /** @type {API.InferWorkflowReceipts & API.Tuple} */ ( - results + const receipts = /** @type {API.InferReceipts} */ ( + await Promise.all(promises) ) + + return Message.build({ receipts }) } /** * @template {Record} Service * @template {API.Capability} C - * @param {API.InferInvocation>} invocation + * @param {API.Invocation} invocation * @param {API.ServerView} server * @returns {Promise} */ @@ -147,6 +143,7 @@ export const invoke = async (invocation, server) => { result, }) } catch (cause) { + /** @type {API.HandlerExecutionError} */ const error = new HandlerExecutionError( capability, /** @type {Error} */ (cause) @@ -197,9 +194,9 @@ export class HandlerNotFound extends RangeError { } } -class HandlerExecutionError extends Error { +class HandlerExecutionError extends Failure { /** - * @param {API.ParsedCapability} capability + * @param {API.Capability} capability * @param {Error} cause */ constructor(capability, cause) { diff --git a/packages/server/test/server.spec.js b/packages/server/test/server.spec.js index eff6ecab..8451d356 100644 --- a/packages/server/test/server.spec.js +++ b/packages/server/test/server.spec.js @@ -1,4 +1,5 @@ import * as Client from '@ucanto/client' +import { invoke, Schema } from '@ucanto/core' import * as Server from '../src/lib.js' import * as CAR from '@ucanto/transport/car' import * as CBOR from '@ucanto/core/cbor' @@ -6,7 +7,6 @@ import * as Transport from '@ucanto/transport' import { alice, bob, mallory, service as w3 } from './fixtures.js' import * as Service from '../../client/test/service.js' import { test, assert } from './test.js' -import { Schema } from '@ucanto/validator' const storeAdd = Server.capability({ can: 'store/add', @@ -33,6 +33,7 @@ const storeAdd = Server.capability({ } }, }) + const storeRemove = Server.capability({ can: 'store/remove', with: Server.URI.match({ protocol: 'did:' }), @@ -417,3 +418,53 @@ test('falsy errors are turned into {}', async () => { ok: {}, }) }) + +test('run invocation without encode / decode', async () => { + const server = Server.create({ + service: Service.create(), + codec: CAR.inbound, + id: w3, + }) + + const identify = invoke({ + issuer: alice, + audience: w3, + capability: { + can: 'access/identify', + with: 'did:email:alice@mail.com', + }, + }) + + const register = await server.run(identify) + assert.deepEqual(register.out, { + ok: {}, + }) + + const car = await CAR.codec.write({ + roots: [await CBOR.write({ hello: 'world ' })], + }) + + const add = Client.invoke({ + issuer: alice, + audience: w3, + capability: { + can: 'store/add', + with: alice.did(), + nb: { + link: car.cid, + }, + }, + proofs: [], + }) + + const receipt = await server.run(add) + + assert.deepEqual(receipt.out, { + ok: { + link: car.cid, + status: 'upload', + url: 'http://localhost:9090/', + with: alice.did(), + }, + }) +}) diff --git a/packages/transport/package.json b/packages/transport/package.json index 851ac77e..44826461 100644 --- a/packages/transport/package.json +++ b/packages/transport/package.json @@ -23,7 +23,7 @@ "scripts": { "test:web": "playwright-test test/**/*.spec.js --cov && nyc report", "test:node": "c8 --check-coverage --branches 100 --functions 100 --lines 100 mocha test/**/*.spec.js", - "test": "npm run test:node", + "test": "c8 --check-coverage --branches 100 --functions 100 --lines 100 mocha --bail test/**/*.spec.js", "coverage": "c8 --reporter=html mocha test/**/*.spec.js && npm_config_yes=true npx st -d coverage -p 8080", "check": "tsc --build", "build": "tsc --build" diff --git a/packages/transport/src/car.js b/packages/transport/src/car.js index 11b63950..1cf92cd1 100644 --- a/packages/transport/src/car.js +++ b/packages/transport/src/car.js @@ -1,4 +1,3 @@ -import * as API from '@ucanto/interface' import { CAR } from '@ucanto/core' import * as request from './car/request.js' import * as response from './car/response.js' @@ -6,41 +5,22 @@ import * as Codec from './codec.js' export { CAR as codec, request, response } -export const contentType = 'application/car' - -const HEADERS = Object.freeze({ - 'content-type': 'application/car', -}) - -/** - * @deprecated - * @template {API.Tuple} I - * @param {I} invocations - * @param {API.EncodeOptions & { headers?: Record }} [options] - * @returns {Promise>} - */ -export const encode = (invocations, options) => - request.encode(invocations, { headers: HEADERS, ...options }) - -/** - * @deprecated - */ -export const decode = request.decode +export const contentType = CAR.contentType export const inbound = Codec.inbound({ decoders: { - 'application/car': request, + [request.contentType]: request, }, encoders: { - 'application/car': response, + [response.contentType]: response, }, }) export const outbound = Codec.outbound({ encoders: { - 'application/car': request, + [request.contentType]: request, }, decoders: { - 'application/car': response, + [response.contentType]: response, }, }) diff --git a/packages/transport/src/car/request.js b/packages/transport/src/car/request.js index 671eb772..3d1eae2d 100644 --- a/packages/transport/src/car/request.js +++ b/packages/transport/src/car/request.js @@ -1,24 +1,25 @@ import * as API from '@ucanto/interface' -import { CAR, CBOR, DAG, Invocation, Message, Message } from '@ucanto/core' -import * as Schema from '../schema.js' +import { CAR, Message } from '@ucanto/core' export { CAR as codec } +export const contentType = CAR.contentType + const HEADERS = Object.freeze({ - 'content-type': 'application/car', + 'content-type': contentType, // We will signal that we want to receive a CAR file in the response - accept: 'application/car', + accept: contentType, }) /** - * Encodes workflow into an HTTPRequest. + * Encodes `AgentMessage` into an `HTTPRequest`. * * @template {API.AgentMessage} Message * @param {Message} message * @param {API.EncodeOptions & { headers?: Record }} [options] - * @returns {Promise>} + * @returns {API.HTTPRequest} */ -export const encode = async (message, options) => { +export const encode = (message, options) => { const blocks = new Map() for (const block of message.iterateIPLDBlocks()) { blocks.set(`${block.cid}`, block) @@ -42,52 +43,14 @@ export const encode = async (message, options) => { } /** - * Decodes Workflow from HTTPRequest. + * Decodes `AgentMessage` from the received `HTTPRequest`. * * @template {API.AgentMessage} Message * @param {API.HTTPRequest} request * @returns {Promise} */ export const decode = async ({ headers, body }) => { - const contentType = headers['content-type'] || headers['Content-Type'] - if (contentType !== 'application/car') { - throw TypeError( - `Only 'content-type: application/car' is supported, instead got '${contentType}'` - ) - } - const { roots, blocks } = CAR.decode(/** @type {Uint8Array} */ (body)) - Message.view({ root: roots[0], store: blocks }) - - // CARs can contain v0 blocks but we don't have to thread that type through. - const store = /** @type {Map} */ (blocks) - const run = [] - - for (const { cid } of roots) { - const block = DAG.get(cid, store) - const data = CBOR.decode(block.bytes) - - const [branch, value] = Schema.Inbound.match(data) - switch (branch) { - case 'ucanto/workflow@0.1.0': { - for (const root of value.run) { - const invocation = Invocation.view({ - root, - blocks: store, - }) - run.push(invocation) - } - break - } - default: { - const invocation = Invocation.create({ - root: { ...block, data: value }, - blocks: store, - }) - run.push(invocation) - } - } - } - - return { run: /** @type {API.InferInvocations} */ (run) } + const message = Message.match({ root: roots[0].cid, store: blocks }) + return /** @type {Message} */ (message) } diff --git a/packages/transport/src/car/response.js b/packages/transport/src/car/response.js index af46dfe8..daf5a663 100644 --- a/packages/transport/src/car/response.js +++ b/packages/transport/src/car/response.js @@ -1,29 +1,37 @@ import * as API from '@ucanto/interface' -import { CAR, CBOR, DAG, Delegation, Invocation, parseLink } from '@ucanto/core' -import { Receipt, Report } from '@ucanto/core' +import { CAR, Message } from '@ucanto/core' export { CAR as codec } -import * as Schema from '../schema.js' + +export const contentType = CAR.contentType const HEADERS = Object.freeze({ - 'content-type': 'application/car', + 'content-type': contentType, }) /** - * Encodes {@link API.AgentMessage} into an HTTPRequest. + * Encodes `AgentMessage` into an `HTTPRequest`. * - * @template {API.Tuple} I - * @param {API.ReportBuilder} report + * @template {API.AgentMessage} Message + * @param {Message} message * @param {API.EncodeOptions} [options] - * @returns {Promise>>} + * @returns {API.HTTPResponse} */ -export const encode = async (report, options) => { +export const encode = (message, options) => { const blocks = new Map() - const view = await report.buildIPLDView(options) - for (const block of view.iterateIPLDBlocks()) { - blocks.set(block.cid.toString(), block) + for (const block of message.iterateIPLDBlocks()) { + blocks.set(`${block.cid}`, block) } - const body = CAR.encode({ roots: [view.root], blocks }) + /** + * Cast to Uint8Array to remove phantom type set by the + * CAR encoder which is too specific. + * + * @type {Uint8Array} + */ + const body = CAR.encode({ + roots: [message.root], + blocks, + }) return { headers: HEADERS, @@ -32,36 +40,14 @@ export const encode = async (report, options) => { } /** - * Decodes HTTPRequest to an invocation batch. + * Decodes `AgentMessage` from the received `HTTPResponse`. * - * @template {API.Tuple} I - * @param {API.HTTPRequest>} request - * @returns {Promise>} + * @template {API.AgentMessage} Message + * @param {API.HTTPResponse} response + * @returns {Promise} */ export const decode = async ({ headers, body }) => { - const contentType = headers['content-type'] || headers['Content-Type'] - if (contentType !== 'application/car') { - throw TypeError( - `Only 'content-type: application/car' is supported, instead got '${contentType}'` - ) - } - - const report = Report.builder() - const { roots, blocks } = CAR.decode(body) - if (roots.length > 0) - for (const root of roots) { - const block = DAG.get(root.cid, blocks) - const data = DAG.CBOR.decode(block.bytes) - const [branch, model] = Schema.Outbound.match(data) - - switch (branch) { - case 'ucanto/report@0.1.0': { - for (const [key, link] of Object.entries(model.receipts)) { - report.set(parseLink(key), Receipt.view({ root: link, blocks })) - } - } - } - } - - return report.buildIPLDView() + const { roots, blocks } = CAR.decode(/** @type {Uint8Array} */ (body)) + const message = Message.match({ root: roots[0].cid, store: blocks }) + return /** @type {Message} */ (message) } diff --git a/packages/transport/src/codec.js b/packages/transport/src/codec.js index 56f0fd6f..0d10ad80 100644 --- a/packages/transport/src/codec.js +++ b/packages/transport/src/codec.js @@ -125,18 +125,18 @@ class Outbound { } /** - * @template {API.Tuple} I - * @param {API.Workflow} workflow + * @template {API.AgentMessage} Message + * @param {Message} message */ - encode(workflow) { - return this.encoder.encode(workflow, { + encode(message) { + return this.encoder.encode(message, { accept: this.acceptType, }) } /** - * @template {API.Tuple} I - * @param {API.HTTPResponse} response - * @returns {API.Await} + * @template {API.AgentMessage} Message + * @param {API.HTTPResponse} response + * @returns {API.Await} */ decode(response) { const { headers } = response @@ -148,7 +148,6 @@ class Outbound { throw Object.assign( new RangeError(new TextDecoder().decode(response.body)), { - error: true, status: response.status, headers: response.headers, } diff --git a/packages/transport/src/http.js b/packages/transport/src/http.js index 97db4834..fb953032 100644 --- a/packages/transport/src/http.js +++ b/packages/transport/src/http.js @@ -11,15 +11,15 @@ import * as API from '@ucanto/interface' * statusText?: string * url?: string * }} FetchResponse - * @typedef {(url:string, init:API.HTTPRequest>) => API.Await} Fetcher + * @typedef {(url:string, init:API.HTTPRequest) => API.Await} Fetcher */ /** - * @template T + * @template S * @param {object} options * @param {URL} options.url - * @param {(url:string, init:API.HTTPRequest>) => API.Await} [options.fetch] + * @param {(url:string, init:API.HTTPRequest) => API.Await} [options.fetch] * @param {string} [options.method] - * @returns {API.Channel} + * @returns {API.Channel} */ export const open = ({ url, method = 'POST', fetch }) => { /* c8 ignore next 9 */ @@ -34,6 +34,11 @@ export const open = ({ url, method = 'POST', fetch }) => { } return new Channel({ url, method, fetch }) } + +/** + * @template {Record} S + * @implements {API.Channel} + */ class Channel { /** * @param {object} options @@ -47,8 +52,9 @@ class Channel { this.url = url } /** - * @param {API.HTTPRequest} request - * @returns {Promise} + * @template {API.Tuple>} I + * @param {API.HTTPRequest, Out: API.Tuple }>>} request + * @returns {Promise, In: API.Tuple }>>>} */ async request({ headers, body }) { const response = await this.fetch(this.url.href, { diff --git a/packages/transport/src/jwt.js b/packages/transport/src/jwt.js deleted file mode 100644 index 195e9a6b..00000000 --- a/packages/transport/src/jwt.js +++ /dev/null @@ -1,97 +0,0 @@ -import * as API from '@ucanto/interface' -import * as UTF8 from './utf8.js' -import { Delegation, UCAN } from '@ucanto/core' - -const HEADER_PREFIX = 'x-auth-' - -const HEADERS = Object.freeze({ - 'content-type': 'application/json', -}) - -/** - * Encodes invocation batch into an HTTPRequest. - * - * @template {API.Tuple} I - * @param {I} batch - * @returns {Promise>} - */ -export const encode = async batch => { - /** @type {Record} */ - const headers = { ...HEADERS } - /** @type {string[]} */ - const body = [] - for (const invocation of batch) { - const delegation = await invocation.delegate() - - body.push(`${delegation.cid}`) - for (const proof of iterate(delegation)) { - headers[`${HEADER_PREFIX}${proof.cid}`] = UCAN.format(proof.data) - } - headers[`${HEADER_PREFIX}${delegation.cid}`] = UCAN.format(delegation.data) - } - - return { - headers, - body: UTF8.encode(JSON.stringify(body)), - } -} - -/** - * @param {API.Delegation} delegation - * @return {IterableIterator} - */ -const iterate = function* (delegation) { - for (const proof of delegation.proofs) { - if (!Delegation.isLink(proof)) { - yield* iterate(proof) - yield proof - } - } -} - -/** - * Decodes HTTPRequest to an invocation batch. - * - * @template {API.Tuple} I - * @param {API.HTTPRequest} request - * @returns {Promise>} - */ -export const decode = async ({ headers, body }) => { - const contentType = headers['content-type'] || headers['Content-Type'] - if (contentType !== 'application/json') { - throw TypeError( - `Only 'content-type: application/json' is supported, instead got '${contentType}'` - ) - } - /** @type {API.Block[]} */ - const invocations = [] - const blocks = new Map() - for (const [name, value] of Object.entries(headers)) { - if (name.startsWith(HEADER_PREFIX)) { - const key = name.slice(HEADER_PREFIX.length) - const data = UCAN.parse(/** @type {UCAN.JWT} */ (value)) - const { cid, bytes } = await UCAN.write(data) - - if (cid.toString() != key) { - throw TypeError( - `Invalid request, proof with key ${key} has mismatching cid ${cid}` - ) - } - blocks.set(cid.toString(), { cid, bytes }) - } - } - - for (const cid of JSON.parse(UTF8.decode(body))) { - const root = blocks.get(cid.toString()) - if (!root) { - throw TypeError( - `Invalid request proof of invocation ${cid} is not provided` - ) - } else { - invocations.push(Delegation.create({ root, blocks })) - blocks.delete(cid.toString()) - } - } - - return /** @type {API.InferInvocations} */ (invocations) -} diff --git a/packages/transport/src/legacy.js b/packages/transport/src/legacy.js index 6c679407..eb31f987 100644 --- a/packages/transport/src/legacy.js +++ b/packages/transport/src/legacy.js @@ -1,36 +1,7 @@ -import * as API from '@ucanto/interface' import * as Codec from './codec.js' import * as CAR from './car.js' -import { encode as encodeCBOR } from '@ucanto/core/cbor' - -export const CBOR = { - /** - * Encodes receipts into a legacy CBOR representation. - * - * @template {API.Tuple} I - * @param {I} receipts - * @param {API.EncodeOptions} [options] - * @returns {API.HTTPResponse} - */ - encode(receipts, options) { - const legacyResults = [] - for (const receipt of receipts) { - const result = receipt.out - if (result.ok) { - legacyResults.push(result.ok) - } else { - legacyResults.push({ - ...result.error, - error: true, - }) - } - } - return /** @type {API.HTTPResponse} */ ({ - headers: { 'content-type': 'application/cbor' }, - body: encodeCBOR(legacyResults), - }) - }, -} +import * as response from './legacy/response.js' +import * as request from './legacy/request.js' /** * This is an inbound codec designed to support legacy clients and encode @@ -38,18 +9,19 @@ export const CBOR = { */ export const inbound = Codec.inbound({ decoders: { - 'application/car': CAR.request, + [request.contentType]: request, + [CAR.contentType]: CAR.request, }, encoders: { // Here we configure encoders such that if accept header is `*/*` (which is // the default if omitted) we will encode the response in CBOR. If - // `application/car` is set we will encode the response in current format - // is CAR. + // `application/vnd.ipld.car` is set we will encode the response in current + // format. // Here we exploit the fact that legacy clients do not send an accept header // and therefore will get response in legacy format. New clients on the other - // hand will send `application/car` and consequently get response in current - // format. - '*/*;q=0.1': CBOR, - 'application/car': CAR.response, + // hand will send `application/vnd.ipld.car` and consequently get response + // in current format. + '*/*;q=0.1': response, + [CAR.contentType]: CAR.response, }, }) diff --git a/packages/transport/src/legacy/request.js b/packages/transport/src/legacy/request.js new file mode 100644 index 00000000..20c46508 --- /dev/null +++ b/packages/transport/src/legacy/request.js @@ -0,0 +1,29 @@ +import * as CAR from '@ucanto/core/car' +import * as API from '@ucanto/interface' +import { Invocation, Message } from '@ucanto/core' + +export const contentType = 'application/car' + +/** + * @template {API.AgentMessage} Message + * @param {API.HTTPRequest} request + */ +export const decode = async ({ body }) => { + const { roots, blocks } = CAR.decode(/** @type {Uint8Array} */ (body)) + /** @type {API.IssuedInvocation[]} */ + const run = [] + for (const { cid } of roots) { + // We don't have a way to know if the root matches a ucan link. + const invocation = Invocation.view({ + root: /** @type {API.Link} */ (cid), + blocks, + }) + run.push(invocation) + } + + const message = await Message.build({ + invocations: /** @type {API.Tuple} */ (run), + }) + + return /** @type {Message} */ (message) +} diff --git a/packages/transport/src/legacy/response.js b/packages/transport/src/legacy/response.js new file mode 100644 index 00000000..af9e1982 --- /dev/null +++ b/packages/transport/src/legacy/response.js @@ -0,0 +1,38 @@ +import * as API from '@ucanto/interface' +import * as CBOR from '@ucanto/core/cbor' +export const contentType = 'application/cbor' + +const HEADERS = Object.freeze({ + 'content-type': contentType, +}) + +/** + * Encodes `AgentMessage` into a legacy CBOR representation. + * + * @template {API.AgentMessage} Message + * @param {Message} message + * @param {API.EncodeOptions} [options] + * @returns {API.HTTPResponse} + */ +export const encode = (message, options) => { + const legacyResults = [] + for (const receipt of message.receipts.values()) { + const result = receipt.out + if (result.ok) { + legacyResults.push(result.ok) + } else { + legacyResults.push({ + ...result.error, + error: true, + }) + } + } + + /** @type {Uint8Array} */ + const body = CBOR.encode(legacyResults) + + return /** @type {API.HTTPResponse} */ ({ + headers: HEADERS, + body, + }) +} diff --git a/packages/transport/src/lib.js b/packages/transport/src/lib.js index 3705784a..000ffa5f 100644 --- a/packages/transport/src/lib.js +++ b/packages/transport/src/lib.js @@ -1,5 +1,4 @@ export * as CAR from './car.js' -export * as JWT from './jwt.js' export * as HTTP from './http.js' export * as UTF8 from './utf8.js' export * as Legacy from './legacy.js' diff --git a/packages/transport/src/schema.js b/packages/transport/src/schema.js deleted file mode 100644 index 98ad4466..00000000 --- a/packages/transport/src/schema.js +++ /dev/null @@ -1,29 +0,0 @@ -import * as API from '@ucanto/interface' -import * as Schema from '@ucanto/core/src/schema.js/index.js' - -const invocationLink = - /** @type {Schema.Schema>} */ (Schema.link()) - -export const Inbound = Schema.variant({ - // Currently primary way to send a batch of invocations is to send a workflow - // containing a list of (invocation) delegations. - 'ucanto/workflow@0.1.0': Schema.struct({ - run: invocationLink.array(), - }), - // ucanto client older than 0.6.0 will send a CAR with roots that are just - // delegations. Since they are not self-describing we use fallback schema. - _: /** @type {Schema.Schema>} */ ( - Schema.unknown() - ), -}) - -export const Outbound = Schema.variant({ - 'ucanto/report@0.1.0': Schema.struct({ - receipts: Schema.dictionary({ - key: Schema.string(), - value: /** @type {API.Reader>} */ ( - Schema.link({ version: 1 }) - ), - }), - }), -}) diff --git a/packages/transport/src/utf8.js b/packages/transport/src/utf8.js index 452f062b..ce0797f1 100644 --- a/packages/transport/src/utf8.js +++ b/packages/transport/src/utf8.js @@ -5,11 +5,11 @@ export const decoder = new TextDecoder() * @param {string} text * @returns {Uint8Array} */ -export const encode = (text) => encoder.encode(text) +export const encode = text => encoder.encode(text) /** * * @param {Uint8Array} bytes * @returns {string} */ -export const decode = (bytes) => decoder.decode(bytes) +export const decode = bytes => decoder.decode(bytes) diff --git a/packages/transport/test/car.spec.js b/packages/transport/test/car.spec.js index 61ad25b9..b1e7c9c4 100644 --- a/packages/transport/test/car.spec.js +++ b/packages/transport/test/car.spec.js @@ -1,119 +1,48 @@ import { test, assert } from './test.js' import * as CAR from '../src/car.js' -import { - delegate, - invoke, - Receipt, - Delegation, - UCAN, - parseLink, -} from '@ucanto/core' +import { delegate, invoke, Receipt, Message, UCAN } from '@ucanto/core' import { alice, bob, service } from './fixtures.js' -import { collect } from './util.js' +import * as API from '@ucanto/interface' test('encode / decode', async () => { - const cid = parseLink( - 'bafyreiaxnmoptsqiehdff2blpptvdbenxcz6xgrbojw5em36xovn2xea4y' - ) - const expiration = 1654298135 - - const request = await CAR.encode([ - invoke({ - issuer: alice, - audience: bob, - capability: { - can: 'store/add', - with: alice.did(), - }, - expiration, - proofs: [], - }), - ]) + const { message, delegation, outgoing, incoming } = await setup() - assert.deepEqual(request.headers, { - 'content-type': 'application/car', + assert.deepEqual(outgoing.headers, { + 'content-type': 'application/vnd.ipld.car', + accept: 'application/vnd.ipld.car', }) - const expect = await Delegation.delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), + assertDecode(incoming, { + root: message.root, + data: { + 'ucanto/message@0.6.0': { + execute: [delegation.cid], }, - ], - expiration, - proofs: [], + }, }) - - assert.deepEqual([expect], await CAR.decode(request), 'roundtrips') }) -test('decode requires application/car content type', async () => { - const { body } = await CAR.encode([ - invoke({ - issuer: alice, - audience: bob, - capability: { - can: 'store/add', - with: alice.did(), - }, - proofs: [], - }), - ]) +test('accepts Content-Type as well', async () => { + const { message, delegation, outgoing } = await setup() - try { - await CAR.decode({ - body, + assertDecode( + await CAR.request.decode({ + ...outgoing, headers: { - 'content-type': 'application/octet-stream', + 'Content-Type': 'application/car', }, - }) - assert.fail('expected to fail') - } catch (error) { - assert.match(String(error), /content-type: application\/car/) - } -}) - -test('accepts Content-Type as well', async () => { - const expiration = UCAN.now() + 90 - const request = await CAR.encode([ - invoke({ - issuer: alice, - audience: bob, - capability: { - can: 'store/add', - with: alice.did(), - }, - proofs: [], - expiration, }), - ]) - - const [invocation] = await CAR.decode({ - ...request, - headers: { - 'Content-Type': 'application/car', - }, - }) - - const delegation = await delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), + { + root: message.root, + data: { + 'ucanto/message@0.6.0': { + execute: [delegation.cid], + }, }, - ], - proofs: [], - expiration, - }) - - assert.deepEqual({ ...request }, { ...(await CAR.encode([delegation])) }) + } + ) - assert.deepEqual(invocation.bytes, delegation.bytes) + assert.deepEqual(message.invocations[0].bytes, delegation.bytes) }) test('delegated proofs', async () => { @@ -128,39 +57,12 @@ test('delegated proofs', async () => { ], }) - const expiration = UCAN.now() + 90 + const { invocation, incoming } = await setup({ proofs: [proof] }) - const outgoing = await CAR.encode([ - invoke({ - issuer: bob, - audience: service, - capability: { - can: 'store/add', - with: alice.did(), - }, - proofs: [proof], - expiration, - }), - ]) + const { invocations } = incoming + assert.deepEqual(invocations, [await invocation.delegate()]) - const incoming = await CAR.decode(outgoing) - - assert.deepEqual(incoming, [ - await delegate({ - issuer: bob, - audience: service, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - expiration, - proofs: [proof], - }), - ]) - - assert.deepEqual(incoming[0].proofs, [proof]) + assert.deepEqual(invocations[0].proofs, [proof]) }) test('omit proof', async () => { @@ -175,24 +77,10 @@ test('omit proof', async () => { ], }) - const expiration = UCAN.now() + 90 + const { incoming } = await setup({ proofs: [proof.cid] }) - const outgoing = await CAR.encode([ - invoke({ - issuer: bob, - audience: service, - capability: { - can: 'store/add', - with: alice.did(), - }, - proofs: [proof.cid], - expiration, - }), - ]) - - const incoming = await CAR.decode(outgoing) - - assert.deepEqual(incoming, [ + const { invocations } = incoming + assert.deepEqual(invocations, [ await delegate({ issuer: bob, audience: service, @@ -207,47 +95,25 @@ test('omit proof', async () => { }), ]) - assert.deepEqual(incoming[0].proofs, [proof.cid]) + assert.deepEqual(invocations[0].proofs, [proof.cid]) }) test('CAR.request encode / decode', async () => { - const cid = parseLink( - 'bafyreiaxnmoptsqiehdff2blpptvdbenxcz6xgrbojw5em36xovn2xea4y' - ) - const expiration = 1654298135 - - const request = await CAR.request.encode([ - invoke({ - issuer: alice, - audience: bob, - capability: { - can: 'store/add', - with: alice.did(), - }, - expiration, - proofs: [], - }), - ]) + const { outgoing, incoming, message, delegation } = await setup() - assert.deepEqual(request.headers, { - 'content-type': 'application/car', - accept: 'application/car', + assert.deepEqual(outgoing.headers, { + 'content-type': 'application/vnd.ipld.car', + accept: 'application/vnd.ipld.car', }) - const expect = await Delegation.delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), + assertDecode(incoming, { + root: message.root, + data: { + 'ucanto/message@0.6.0': { + execute: [delegation.cid], }, - ], - expiration, - proofs: [], + }, }) - - assert.deepEqual([expect], await CAR.request.decode(request), 'roundtrips') }) test('CAR.response encode/decode', async () => { @@ -269,24 +135,59 @@ test('CAR.response encode/decode', async () => { meta: { test: 'run' }, }) - const message = await CAR.response.encode([receipt]) - assert.deepEqual(message.headers, { - 'content-type': 'application/car', + const message = await Message.build({ receipts: [receipt] }) + const request = CAR.response.encode(message) + assert.deepEqual(request.headers, { + 'content-type': 'application/vnd.ipld.car', }) - const [received, ...other] = await CAR.response.decode(message) - assert.equal(other.length, 0) + const replica = await CAR.response.decode(request) + const [received, ...receipts] = replica.receipts.values() + + assert.equal(receipts.length, 0) assert.deepEqual(received.issuer, receipt.issuer) assert.deepEqual(received.meta, receipt.meta) assert.deepEqual(received.ran, receipt.ran) assert.deepEqual(received.proofs, receipt.proofs) assert.deepEqual(received.fx, receipt.fx) - assert.deepEqual(received.signature, receipt.signature) + assert.deepEqual( + received.signature, + /** @type {object} */ (receipt.signature) + ) +}) + +const expiration = UCAN.now() + 90 - assert.throws(() => { - CAR.response.decode({ - headers: {}, - body: message.body, - }) +/** + * @param {object} source + * @param {API.Proof[]} [source.proofs] + */ +const setup = async ({ proofs = [] } = {}) => { + const invocation = invoke({ + issuer: bob, + audience: service, + capability: { + can: 'store/add', + with: alice.did(), + }, + expiration, + proofs, }) -}) + const delegation = await invocation.delegate() + const message = await Message.build({ invocations: [invocation] }) + const outgoing = await CAR.request.encode(message) + const incoming = await CAR.request.decode(outgoing) + + return { invocation, delegation, message, outgoing, incoming } +} + +/** + * @param {API.AgentMessage} actual + * @param {object} expect + * @param {API.Block} expect.root + * @param {API.AgentMessageModel<*>} expect.data + */ +const assertDecode = (actual, expect) => { + assert.deepEqual(actual.root, expect.root, 'roundtrips') + assert.deepEqual(actual.root.data, expect.data) +} diff --git a/packages/transport/test/codec.spec.js b/packages/transport/test/codec.spec.js index 82d555c1..0f973da6 100644 --- a/packages/transport/test/codec.spec.js +++ b/packages/transport/test/codec.spec.js @@ -2,11 +2,11 @@ import { test, assert } from './test.js' import * as CAR from '../src/car.js' import * as Transport from '../src/lib.js' import { alice, bob } from './fixtures.js' -import { invoke, delegate, parseLink, Receipt } from '@ucanto/core' +import { invoke, delegate, parseLink, Receipt, Message } from '@ucanto/core' test('unsupported inbound content-type', async () => { const accept = CAR.inbound.accept({ - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/car' }, body: new Uint8Array(), }) @@ -15,7 +15,7 @@ test('unsupported inbound content-type', async () => { status: 415, message: `The server cannot process the request because the payload format is not supported. Please check the content-type header and try again with a supported media type.`, headers: { - accept: `application/car`, + accept: 'application/vnd.ipld.car', }, }, }) @@ -23,7 +23,10 @@ test('unsupported inbound content-type', async () => { test('unsupported inbound accept type', async () => { const accept = CAR.inbound.accept({ - headers: { 'content-type': 'application/car', accept: 'application/cbor' }, + headers: { + 'content-type': 'application/vnd.ipld.car', + accept: 'application/car', + }, body: new Uint8Array(), }) @@ -32,7 +35,7 @@ test('unsupported inbound accept type', async () => { status: 406, message: `The requested resource cannot be served in the requested content type. Please specify a supported content type using the Accept header.`, headers: { - accept: `application/car`, + accept: `application/vnd.ipld.car`, }, }, }) @@ -64,64 +67,52 @@ test(`requires encoders / decoders`, async () => { }) test('outbound encode', async () => { - const cid = parseLink( - 'bafyreiaxnmoptsqiehdff2blpptvdbenxcz6xgrbojw5em36xovn2xea4y' - ) const expiration = 1654298135 + const message = await Message.build({ + invocations: [ + invoke({ + issuer: alice, + audience: bob, + capability: { + can: 'store/add', + with: alice.did(), + }, + expiration, + proofs: [], + }), + ], + }) - const request = await CAR.outbound.encode([ - invoke({ - issuer: alice, - audience: bob, - capability: { - can: 'store/add', - with: alice.did(), - }, - expiration, - proofs: [], - }), - ]) + const request = await CAR.outbound.encode(message) assert.deepEqual(request.headers, { - 'content-type': 'application/car', - accept: 'application/car', - }) - - const expect = await delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - expiration, - proofs: [], + 'content-type': 'application/vnd.ipld.car', + accept: 'application/vnd.ipld.car', }) assert.deepEqual( - [expect], - await CAR.inbound.accept(request).ok?.decoder.decode(request), + message.root, + (await CAR.inbound.accept(request).ok?.decoder.decode(request))?.root, 'roundtrips' ) }) test('outbound decode', async () => { - const { success, failure } = await buildPayload() + const { success, failure } = await setup() + const message = await Message.build({ receipts: [success, failure] }) - const response = await CAR.response.encode([success, failure]) - const receipts = await CAR.outbound.decode(response) + const response = await CAR.response.encode(message) + const replica = await CAR.outbound.decode(response) assert.deepEqual( - receipts.map($ => $.root), - [success.root, failure.root] + [...replica.receipts.keys()], + [success.ran.link().toString(), failure.ran.link().toString()] ) }) test('inbound supports Content-Type header', async () => { const accept = await CAR.inbound.accept({ - headers: { 'Content-Type': 'application/car' }, + headers: { 'Content-Type': 'application/vnd.ipld.car' }, body: new Uint8Array(), }) @@ -129,15 +120,16 @@ test('inbound supports Content-Type header', async () => { }) test('outbound supports Content-Type header', async () => { - const { success } = await buildPayload() - const { body } = await CAR.response.encode([success]) + const { success } = await setup() + const message = await Message.build({ receipts: [success] }) + const { body } = await CAR.response.encode(message) - const receipts = await CAR.outbound.decode({ - headers: { 'Content-Type': 'application/car' }, + const replica = await CAR.outbound.decode({ + headers: { 'Content-Type': 'application/vnd.ipld.car' }, body, }) - assert.deepEqual(receipts[0].root, success.root) + assert.deepEqual(replica.get(success.ran.link()).root, success.root) }) test('inbound encode preference', async () => { @@ -162,9 +154,10 @@ test('inbound encode preference', async () => { }) test('unsupported response content-type', async () => { - const { success } = await buildPayload() + const { success } = await setup() + const message = await Message.build({ receipts: [success] }) - const response = await CAR.response.encode([success]) + const response = await CAR.response.encode(message) const badContentType = await wait(() => CAR.outbound.decode({ @@ -202,9 +195,9 @@ test('format media type', async () => { ) }) -const buildPayload = async () => { +const setup = async () => { const expiration = 1654298135 - const ran = await delegate({ + const hi = await delegate({ issuer: alice, audience: bob, capabilities: [ @@ -217,19 +210,32 @@ const buildPayload = async () => { proofs: [], }) + const boom = await delegate({ + issuer: alice, + audience: bob, + capabilities: [ + { + can: 'debug/error', + with: alice.did(), + }, + ], + expiration, + proofs: [], + }) + const success = await Receipt.issue({ - ran: ran.cid, + ran: hi.cid, issuer: bob, result: { ok: { hello: 'message' } }, }) const failure = await Receipt.issue({ - ran: ran.cid, + ran: boom.cid, issuer: bob, result: { error: { message: 'Boom' } }, }) - return { ran, success, failure } + return { hi, boom, success, failure } } /** diff --git a/packages/transport/test/jwt.spec.js b/packages/transport/test/jwt.spec.js deleted file mode 100644 index 14d91432..00000000 --- a/packages/transport/test/jwt.spec.js +++ /dev/null @@ -1,271 +0,0 @@ -import { test, assert } from './test.js' -import * as JWT from '../src/jwt.js' -import { delegate, invoke, Delegation, UCAN } from '@ucanto/core' -import * as UTF8 from '../src/utf8.js' -import { alice, bob, service } from './fixtures.js' - -const NOW = 1654298135 - -const fixtures = { - basic: { - cid: 'bafyreiaxnmoptsqiehdff2blpptvdbenxcz6xgrbojw5em36xovn2xea4y', - jwt: 'eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsInVjdiI6IjAuOS4xIn0.eyJhdHQiOlt7ImNhbiI6InN0b3JlL2FkZCIsIndpdGgiOiJkaWQ6a2V5Ono2TWtrODliQzNKclZxS2llNzFZRWNjNU0xU01WeHVDZ054NnpMWjhTWUpzeEFMaSJ9XSwiYXVkIjoiZGlkOmtleTp6Nk1rZmZEWkNrQ1RXcmVnODg2OGZHMUZHRm9nY0pqNVg2UFk5M3BQY1dEbjlib2IiLCJleHAiOjE2NTQyOTgxMzUsImlzcyI6ImRpZDprZXk6ejZNa2s4OWJDM0pyVnFLaWU3MVlFY2M1TTFTTVZ4dUNnTng2ekxaOFNZSnN4QUxpIiwicHJmIjpbXX0.amtDCzx4xzI28w8M4gKCOBWuhREPPAh8cdoXfi4JDTMy5wxy-4VYYM4AC7lXufsgdiT6thaBtq3AAIv1P87lAA', - }, -} - -test('encode / decode', async () => { - const { cid, jwt } = fixtures.basic - - const request = await JWT.encode([ - invoke({ - issuer: alice, - audience: bob, - capability: { - can: 'store/add', - with: alice.did(), - }, - expiration: NOW, - proofs: [], - }), - ]) - - const expect = { - body: UTF8.encode(JSON.stringify([cid])), - headers: { - 'content-type': 'application/json', - [`x-auth-${cid}`]: jwt, - }, - } - - assert.deepEqual(request, expect) - - assert.deepEqual( - await JWT.decode(request), - [ - await Delegation.delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - expiration: NOW, - proofs: [], - }), - ], - 'roundtrips' - ) -}) - -test('decode requires application/json contet type', async () => { - const { cid, jwt } = fixtures.basic - - try { - await JWT.decode({ - body: UTF8.encode(JSON.stringify([cid])), - headers: { - [`x-auth-${cid}`]: jwt, - }, - }) - assert.fail('expected to fail') - } catch (error) { - assert.match(String(error), /content-type: application\/json/) - } -}) - -test('delegated proofs', async () => { - const proof = await delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - }) - - const expiration = UCAN.now() + 90 - - const outgoing = await JWT.encode([ - invoke({ - issuer: bob, - audience: service, - capability: { - can: 'store/add', - with: alice.did(), - }, - proofs: [proof], - expiration, - }), - ]) - - assert.equal(Object.keys(outgoing.headers).length, 3) - - const incoming = await JWT.decode(outgoing) - - assert.deepEqual(incoming, [ - await delegate({ - issuer: bob, - audience: service, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - expiration, - proofs: [proof], - }), - ]) - - assert.deepEqual(incoming[0].proofs, [proof]) -}) - -test('omit proof', async () => { - const proof = await delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - }) - - const expiration = UCAN.now() + 90 - - const outgoing = await JWT.encode([ - invoke({ - issuer: bob, - audience: service, - capability: { - can: 'store/add', - with: alice.did(), - }, - proofs: [proof.cid], - expiration, - }), - ]) - - assert.equal(Object.keys(outgoing.headers).length, 2) - - const incoming = await JWT.decode(outgoing) - - assert.deepEqual(incoming, [ - await delegate({ - issuer: bob, - audience: service, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - expiration, - proofs: [proof.cid], - }), - ]) - - assert.deepEqual(incoming[0].proofs, [proof.cid]) -}) - -test('thorws on invalid heard', async () => { - const proof = await delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - }) - - const expiration = UCAN.now() + 90 - - const request = await JWT.encode([ - invoke({ - issuer: bob, - audience: service, - capability: { - can: 'store/add', - with: alice.did(), - }, - proofs: [proof], - expiration, - }), - ]) - - const { [`x-auth-${proof.cid}`]: jwt, ...headers } = request.headers - - try { - await JWT.decode({ - ...request, - headers: { - ...headers, - [`x-auth-bafyreigw75rhf7gf7eubwmrhovcrdu4mfy6pfbi4wgbzlfieq2wlfsza5i`]: - request.headers[`x-auth-${proof.cid}`], - }, - }) - assert.fail('expected to fail') - } catch (error) { - assert.match(String(error), /has mismatching cid/) - } -}) - -test('leaving out root throws', async () => { - const proof = await delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - }) - - const expiration = UCAN.now() + 90 - - const request = await JWT.encode([ - invoke({ - issuer: bob, - audience: service, - capability: { - can: 'store/add', - with: alice.did(), - }, - proofs: [proof], - expiration, - }), - ]) - - const { cid } = await delegate({ - issuer: bob, - audience: service, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - proofs: [proof], - expiration, - }) - - const { [`x-auth-${cid}`]: jwt, ...headers } = request.headers - - try { - await JWT.decode({ - ...request, - headers, - }) - assert.fail('expected to fail') - } catch (error) { - assert.match(String(error), /invocation .* is not provided/) - } -}) diff --git a/packages/transport/test/legacy.spec.js b/packages/transport/test/legacy.spec.js index 834f4467..7989149f 100644 --- a/packages/transport/test/legacy.spec.js +++ b/packages/transport/test/legacy.spec.js @@ -1,27 +1,30 @@ +import * as API from '@ucanto/interface' import { test, assert } from './test.js' import * as CAR from '../src/car.js' import * as Legacy from '../src/legacy.js' -import { invoke, Receipt, Delegation, CBOR } from '@ucanto/core' -import { alice, bob } from './fixtures.js' +import { invoke, Receipt, Message, CBOR } from '@ucanto/core' +import { alice, bob, service } from './fixtures.js' test('Legacy decode / encode', async () => { const expiration = 1654298135 + const invocation = invoke({ + issuer: alice, + audience: bob, + capability: { + can: 'store/add', + with: alice.did(), + }, + expiration, + proofs: [], + }) + const message = await Message.build({ + invocations: [invocation], + }) - const source = await CAR.outbound.encode([ - invoke({ - issuer: alice, - audience: bob, - capability: { - can: 'store/add', - with: alice.did(), - }, - expiration, - proofs: [], - }), - ]) + const source = await CAR.outbound.encode(message) const accept = await Legacy.inbound.accept({ - headers: { 'content-type': 'application/car' }, + headers: { 'content-type': 'application/vnd.ipld.car' }, body: source.body, }) if (accept.error) { @@ -29,22 +32,14 @@ test('Legacy decode / encode', async () => { } const { encoder, decoder } = accept.ok - const workflow = await decoder.decode(source) + const request = await decoder.decode(source) - const expect = await Delegation.delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - can: 'store/add', - with: alice.did(), - }, - ], - expiration, - proofs: [], - }) - - assert.deepEqual([expect], workflow, 'roundtrips') + const expect = await invocation.delegate() + assert.deepEqual( + [expect.link()], + request.invocations.map($ => $.link()), + 'roundtrips' + ) const success = await Receipt.issue({ ran: expect.cid, @@ -58,13 +53,60 @@ test('Legacy decode / encode', async () => { result: { error: { message: 'Boom' } }, }) - const response = await encoder.encode([success, failure]) + const output = await Message.build({ receipts: [success, failure] }) + const response = await encoder.encode(output) const results = await CBOR.decode(response.body) assert.deepEqual( results, - // we want to return to old clients. + // @ts-expect-error - we want to return to old clients. [{ hello: 'message' }, { error: true, message: 'Boom' }], 'roundtrips' ) }) + +test('decode legacy invocation format', async () => { + const expiration = 1654298135 + const add = await invoke({ + issuer: alice, + audience: service, + capability: { + can: 'store/add', + with: alice.did(), + }, + expiration, + proofs: [], + }).buildIPLDView() + + const greet = await invoke({ + issuer: alice, + audience: service, + capability: { + can: 'test/echo', + with: 'data:hello', + }, + expiration, + proofs: [], + }).buildIPLDView() + + const roots = [add, greet] + const blocks = new Map() + for (const root of roots) { + for (const block of root.iterateIPLDBlocks()) { + blocks.set(`${block.cid}`, block) + } + } + + const request = { + headers: { 'content-type': 'application/car' }, + body: /** @type {Uint8Array} */ (await CAR.codec.encode({ roots, blocks })), + } + + const codec = Legacy.inbound.accept(request) + if (codec.error) { + return assert.fail('expected to accept legacy invocation') + } + const message = await codec.ok.decoder.decode(request) + + assert.deepEqual(message.invocations, roots) +}) diff --git a/packages/transport/test/utf8.spec.js b/packages/transport/test/utf8.spec.js new file mode 100644 index 00000000..4ed69867 --- /dev/null +++ b/packages/transport/test/utf8.spec.js @@ -0,0 +1,6 @@ +import { test, assert } from './test.js' +import * as UTF8 from '../src/utf8.js' + +test('encode <-> decode', async () => { + assert.deepEqual(UTF8.decode(UTF8.encode('hello')), 'hello') +}) From b9818737d185746d155dc03d1f032936960e11fe Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Fri, 7 Apr 2023 16:49:39 -0700 Subject: [PATCH 09/15] fix test coverage --- packages/core/src/dag.js | 7 +- packages/core/src/message.js | 46 ++-- packages/core/src/receipt.js | 6 +- packages/core/src/result.js | 11 + packages/core/test/message.spec.js | 315 +++++++++++++++++++++++++ packages/core/test/receipt.spec.js | 31 +++ packages/interface/src/lib.ts | 2 +- packages/transport/src/car/request.js | 2 +- packages/transport/src/car/response.js | 2 +- 9 files changed, 398 insertions(+), 24 deletions(-) create mode 100644 packages/core/test/message.spec.js diff --git a/packages/core/src/dag.js b/packages/core/src/dag.js index e1075fd1..2ebf67ab 100644 --- a/packages/core/src/dag.js +++ b/packages/core/src/dag.js @@ -35,9 +35,14 @@ export const iterate = function* (value) { /** * @template [T=unknown] + * @param {API.Block[]} blocks * @returns {BlockStore} */ -export const createStore = () => new Map() +export const createStore = (blocks = []) => { + const store = new Map() + addEveryInto(blocks, store) + return store +} /** @type {API.MulticodecCode} */ const EMBED_CODE = identity.code diff --git a/packages/core/src/message.js b/packages/core/src/message.js index 47bfd5e0..57b60126 100644 --- a/packages/core/src/message.js +++ b/packages/core/src/message.js @@ -1,6 +1,6 @@ import * as API from '@ucanto/interface' import * as DAG from './dag.js' -import { Invocation } from './lib.js' +import { Invocation, panic } from './lib.js' import * as Receipt from './receipt.js' import * as Schema from './schema.js' @@ -29,24 +29,19 @@ export const MessageSchema = Schema.variant({ export const build = ({ invocations, receipts }) => new MessageBuilder({ invocations, receipts }).buildIPLDView() -/** - * @template {{ In: API.Invocation[]; Out: API.Receipt[] }} T - * @param {object} source - * @param {Required>>} source.root - * @param {Map} source.store - */ -export const view = ({ root, store }) => new Message({ root, store }) - /** * @template [E=never] * @param {object} source * @param {API.Link} source.root - * @param {Map} source.store + * @param {DAG.BlockStore} source.store * @param {E} [fallback] * @returns {API.AgentMessage|E} */ -export const match = ({ root, store }, fallback) => { - const block = DAG.get(root, store) +export const view = ({ root, store }, fallback) => { + const block = DAG.get(root, store, null) + if (block === null) { + return fallback !== undefined ? fallback : DAG.notFound(root) + } const data = DAG.CBOR.decode(block.bytes) const [branch, value] = MessageSchema.match(data, fallback) switch (branch) { @@ -172,7 +167,7 @@ class Message { /** * @param {object} source * @param {Required>>} source.root - * @param {Map} source.store + * @param {DAG.BlockStore} source.store * @param {object} build * @param {API.Invocation[]} [build.invocations] * @param {Map} [build.receipts] @@ -183,16 +178,33 @@ class Message { this._invocations = invocations this._receipts = receipts } - iterateIPLDBlocks() { - return this.store.values() + *iterateIPLDBlocks() { + for (const invocation of this.invocations) { + yield* invocation.iterateIPLDBlocks() + } + + for (const receipt of this.receipts.values()) { + yield* receipt.iterateIPLDBlocks() + } + + yield this.root } /** + * @template [E=never] * @param {API.Link} link + * @param {E} [fallback] + * @returns {API.Receipt|E} */ - get(link) { + get(link, fallback) { const receipts = this.root.data['ucanto/message@0.6.0'].report || {} const receipt = receipts[`${link}`] - return Receipt.view({ root: receipt, blocks: this.store }) + if (receipt) { + return Receipt.view({ root: receipt, blocks: this.store }) + } else { + return fallback !== undefined + ? fallback + : panic(`Message does not include receipt for ${link}`) + } } get invocationLinks() { diff --git a/packages/core/src/receipt.js b/packages/core/src/receipt.js index 26ef1454..e9cd4dbf 100644 --- a/packages/core/src/receipt.js +++ b/packages/core/src/receipt.js @@ -14,13 +14,13 @@ import { sha256 } from 'multiformats/hashes/sha2' * @template [E=never] * @param {object} input * @param {API.Link>} input.root - * @param {Map} input.blocks + * @param {DAG.BlockStore} input.blocks * @param {E} [fallback] */ export const view = ({ root, blocks }, fallback) => { - const block = DAG.get(root, blocks) + const block = DAG.get(root, blocks, null) if (block == null) { - return fallback || DAG.notFound(root) + return fallback !== undefined ? fallback : DAG.notFound(root) } const data = CBOR.decode(block.bytes) diff --git a/packages/core/src/result.js b/packages/core/src/result.js index 2990ab45..0f4f4c9f 100644 --- a/packages/core/src/result.js +++ b/packages/core/src/result.js @@ -35,6 +35,17 @@ export const error = cause => { } } +/** + * Crash the program with a given `message`. This function is + * intended to be used in places where it is impossible to + * recover from an error. It is similar to `panic` function in + * Rust. + * + * @param {string} message + */ +export const panic = message => { + throw new Failure(message) +} /** * Creates the failing result containing an error with a given * `message`. Unlike `error` function it creates a very generic diff --git a/packages/core/test/message.spec.js b/packages/core/test/message.spec.js new file mode 100644 index 00000000..f929aa00 --- /dev/null +++ b/packages/core/test/message.spec.js @@ -0,0 +1,315 @@ +import { + Message, + Receipt, + invoke, + API, + delegate, + DAG, + UCAN, +} from '../src/lib.js' +import { alice, bob, service as w3 } from './fixtures.js' +import { assert, test } from './test.js' +import * as CBOR from '../src/cbor.js' + +test('build empty message', async () => { + const message = await Message.build({}) + assert.deepEqual(message.invocations, []) + assert.deepEqual(message.receipts.size, 0) + + assert.deepEqual(message.invocationLinks, []) +}) + +test('build message with an invocation', async () => { + const echo = await build({ + run: { + can: 'test/echo', + message: 'hello', + }, + result: { + ok: { message: 'hello' }, + }, + }) + + const message = await Message.build({ + invocations: [echo.invocation], + }) + + assert.deepEqual(message.root.data, { + 'ucanto/message@0.6.0': { + execute: [echo.delegation.cid], + }, + }) + assert.deepEqual(message.invocationLinks, [echo.delegation.cid]) + + const store = DAG.createStore([...message.iterateIPLDBlocks()]) + + const view = Message.view({ + root: message.root.cid, + store, + }) + + assert.deepEqual(view.invocations, [echo.delegation]) + assert.deepEqual( + view.invocations[0].proofs, + [echo.proof], + 'proofs are included' + ) + + assert.deepEqual(view.receipts.size, 0) + assert.deepEqual([...view.iterateIPLDBlocks()].length, store.size) +}) + +test('Message.view', async () => { + const hi = await build({ run: { can: 'test/hi' } }) + const bye = await build({ run: { can: 'test/bye' } }) + + const store = DAG.createStore([ + ...hi.delegation.iterateIPLDBlocks(), + ...bye.delegation.iterateIPLDBlocks(), + ]) + + const buildHi = await Message.build({ + invocations: [hi.invocation], + }) + + assert.throws( + () => + Message.view({ + root: buildHi.root.cid, + store, + }), + /Block for the bafy.* not found/ + ) + + assert.deepEqual( + Message.view( + { + root: buildHi.root.cid, + store, + }, + null + ), + null + ) + + DAG.addInto(buildHi.root, store) + const viewHi = Message.view({ + root: buildHi.root.cid, + store, + }) + + assert.deepEqual(buildHi.invocations, viewHi.invocations) + assert.deepEqual([...viewHi.iterateIPLDBlocks()].length < store.size, true) + + assert.throws( + () => + Message.view({ + root: hi.delegation.cid, + store, + }), + /Expected an object with a single key/, + 'throws if message does not match schema' + ) + + assert.deepEqual( + Message.view( + { + root: hi.delegation.cid, + store, + }, + { another: 'one' } + ), + { another: 'one' } + ) +}) + +test('empty receipts are omitted', async () => { + const hi = await build({ run: { can: 'test/hi' } }) + const message = await Message.build({ + invocations: [hi.delegation], + // @ts-expect-error - requires at least on item + receipts: [], + }) + + assert.deepEqual( + message.root.data, + { + 'ucanto/message@0.6.0': { + execute: [hi.delegation.cid], + }, + }, + 'receipts are omitted' + ) + + assert.equal(message.get(hi.delegation.cid, null), null) +}) + +test('message with receipts', async () => { + const hi = await build({ run: { can: 'test/hi' } }) + const message = await Message.build({ + receipts: [hi.receipt], + }) + + assert.deepEqual( + message.root.data, + { + 'ucanto/message@0.6.0': { + report: { + [`${hi.delegation.cid}`]: hi.receipt.root.cid, + }, + }, + }, + 'includes passed receipt' + ) +}) + +test('handles duplicate receipts', async () => { + const hi = await build({ run: { can: 'test/hi' } }) + const message = await Message.build({ + receipts: [hi.receipt, hi.receipt], + }) + + assert.deepEqual( + message.root.data, + { + 'ucanto/message@0.6.0': { + report: { + [`${hi.delegation.cid}`]: hi.receipt.root.cid, + }, + }, + }, + 'includes passed receipt' + ) + + assert.deepEqual([...message.receipts.values()], [hi.receipt, hi.receipt]) +}) + +test('empty invocations are omitted', async () => { + const hi = await build({ run: { can: 'test/hi' } }) + const message = await Message.build({ + receipts: [hi.receipt], + // @ts-expect-error - requires non empty invocations + invocations: [], + }) + + assert.deepEqual( + message.root.data, + { + 'ucanto/message@0.6.0': { + report: { + [`${hi.delegation.cid}`]: hi.receipt.root.cid, + }, + }, + }, + 'empty invocations are omitted' + ) + + const receipt = message.get(hi.delegation.cid) + assert.deepEqual(receipt.root, hi.receipt.root) + + assert.throws( + () => message.get(receipt.root.cid), + /does not include receipt for/ + ) +}) + +test('message with invocations & receipts', async () => { + const hi = await build({ run: { can: 'test/hi' } }) + const message = await Message.build({ + receipts: [hi.receipt], + invocations: [hi.invocation], + }) + + assert.deepEqual( + message.root.data, + { + 'ucanto/message@0.6.0': { + execute: [hi.delegation.cid], + report: { + [`${hi.delegation.cid}`]: hi.receipt.root.cid, + }, + }, + }, + 'contains invocations and receipts' + ) + + const cids = new Set( + [...message.iterateIPLDBlocks()].map($ => $.cid.toString()) + ) + + assert.deepEqual(cids.has(hi.delegation.cid.toString()), true) + assert.deepEqual(cids.has(hi.proof.cid.toString()), true) + assert.deepEqual(cids.has(hi.receipt.root.cid.toString()), true) + assert.deepEqual(cids.has(message.root.cid.toString()), true) +}) + +test('Message.view with receipts', async () => { + const hi = await build({ run: { can: 'test/hi' } }) + const bye = await build({ run: { can: 'test/bye' } }) + + const message = await Message.build({ + invocations: [hi.invocation], + receipts: [hi.receipt, bye.receipt], + }) + + const store = DAG.createStore([...message.iterateIPLDBlocks()]) + + const view = Message.view({ + root: message.root.cid, + store, + }) + + assert.deepEqual( + [...view.receipts.entries()] + .sort(([a], [b]) => (a > b ? 1 : -1)) + .map(([key, $]) => [key, $.root]), + [...message.receipts.entries()] + .sort(([a], [b]) => (a > b ? 1 : -1)) + .map(([key, $]) => [key, $.root]) + ) + + assert.deepEqual(view.invocations, message.invocations) +}) + +/** + * @template {Omit} I + * @param {object} source + * @param {I} source.run + * @param {Record} [source.meta] + * @param {API.Result<{}, {}>} [source.result] + */ +const build = async ({ + run, + result = { ok: {} }, + meta = { test: 'metadata' }, +}) => { + const proof = await delegate({ + issuer: alice, + audience: bob, + capabilities: [{ with: alice.did(), can: run.can }], + expiration: UCAN.now() + 1000, + }) + + const invocation = invoke({ + issuer: alice, + audience: w3, + capability: { + ...run, + with: alice.did(), + }, + proofs: [proof], + }) + + const delegation = await invocation.buildIPLDView() + const receipt = await Receipt.issue({ + issuer: w3, + result, + ran: delegation.link(), + meta, + fx: { + fork: [], + }, + }) + + return { proof, invocation, delegation, receipt } +} diff --git a/packages/core/test/receipt.spec.js b/packages/core/test/receipt.spec.js index 8b325b37..854cdbe7 100644 --- a/packages/core/test/receipt.spec.js +++ b/packages/core/test/receipt.spec.js @@ -236,6 +236,37 @@ test('receipt with fx.join', async () => { await assertRoundtrip(receipt) }) +test('receipt view fallback', async () => { + const invocation = await invoke({ + issuer: alice, + audience: w3, + capability: { + can: 'test/echo', + with: alice.did(), + }, + }).delegate() + + const receipt = await Receipt.issue({ + issuer: w3, + result: { ok: { hello: 'message' } }, + ran: invocation, + meta: { test: 'metadata' }, + fx: { + fork: [], + }, + }) + + assert.throws( + () => Receipt.view({ root: receipt.root.cid, blocks: new Map() }), + /not found/ + ) + + assert.deepEqual( + Receipt.view({ root: receipt.root.cid, blocks: new Map() }, null), + null, + 'returns fallback' + ) +}) /** * @template {API.Receipt} Receipt * @param {Receipt} receipt diff --git a/packages/interface/src/lib.ts b/packages/interface/src/lib.ts index 56515458..cbe1c7ff 100644 --- a/packages/interface/src/lib.ts +++ b/packages/interface/src/lib.ts @@ -701,7 +701,7 @@ export interface AgentMessage invocationLinks: Tuple>> | [] receipts: Map, Receipt> invocations: Invocation[] - get(link: Link): Receipt + get(link: Link, fallback?: E): Receipt | E } /** diff --git a/packages/transport/src/car/request.js b/packages/transport/src/car/request.js index 3d1eae2d..36f1dd16 100644 --- a/packages/transport/src/car/request.js +++ b/packages/transport/src/car/request.js @@ -51,6 +51,6 @@ export const encode = (message, options) => { */ export const decode = async ({ headers, body }) => { const { roots, blocks } = CAR.decode(/** @type {Uint8Array} */ (body)) - const message = Message.match({ root: roots[0].cid, store: blocks }) + const message = Message.view({ root: roots[0].cid, store: blocks }) return /** @type {Message} */ (message) } diff --git a/packages/transport/src/car/response.js b/packages/transport/src/car/response.js index daf5a663..80b12906 100644 --- a/packages/transport/src/car/response.js +++ b/packages/transport/src/car/response.js @@ -48,6 +48,6 @@ export const encode = (message, options) => { */ export const decode = async ({ headers, body }) => { const { roots, blocks } = CAR.decode(/** @type {Uint8Array} */ (body)) - const message = Message.match({ root: roots[0].cid, store: blocks }) + const message = Message.view({ root: roots[0].cid, store: blocks }) return /** @type {Message} */ (message) } From 42ecd07ce540eff0ee53965910879db622948d0a Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Fri, 7 Apr 2023 17:10:24 -0700 Subject: [PATCH 10/15] put coverage back to 100% --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index cc0e6f2a..f4fad49f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,7 +23,7 @@ "scripts": { "test:web": "playwright-test test/*.spec.js --cov && nyc report", "test:node": "c8 --check-coverage --branches 100 --functions 100 --lines 100 mocha test/*.spec.js", - "test": "c8 --check-coverage --branches 90 --functions 90 --lines 90 mocha --bail test/*.spec.js", + "test": "c8 --check-coverage --branches 100 --functions 100 --lines 100 mocha --bail test/*.spec.js", "coverage": "c8 --reporter=html mocha test/*.spec.js && npm_config_yes=true npx st -d coverage -p 8080", "check": "tsc --build", "build": "tsc --build" From 423f0fc2684e241c3a08dc3a291fb96ee95a211c Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Fri, 7 Apr 2023 17:16:34 -0700 Subject: [PATCH 11/15] simplify Invocation.view --- packages/core/src/invocation.js | 13 +++++-------- packages/core/test/invocation.spec.js | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/core/src/invocation.js b/packages/core/src/invocation.js index 091768c9..f18ac809 100644 --- a/packages/core/src/invocation.js +++ b/packages/core/src/invocation.js @@ -35,15 +35,12 @@ export const create = ({ root, blocks }) => new Invocation(root, blocks) * @returns {API.Invocation|T} */ export const view = ({ root, blocks }, fallback) => { - if (fallback) { - const block = DAG.get(root, blocks, null) - return block - ? /** @type {API.Invocation} */ (create({ root: block, blocks })) - : /** @type {T} */ (fallback) - } else { - const block = DAG.get(root, blocks) - return /** @type {API.Invocation} */ (create({ root: block, blocks })) + const block = DAG.get(root, blocks, null) + if (block == null) { + return fallback !== undefined ? fallback : DAG.notFound(root) } + + return /** @type {API.Invocation} */ (create({ root: block, blocks })) } /** diff --git a/packages/core/test/invocation.spec.js b/packages/core/test/invocation.spec.js index 51d91d20..32f88b75 100644 --- a/packages/core/test/invocation.spec.js +++ b/packages/core/test/invocation.spec.js @@ -160,3 +160,25 @@ test('execute invocation', async () => { // @ts-expect-error assert.deepEqual(result, { hello: 'world' }) }) + +test('receipt view fallback', async () => { + const invocation = await invoke({ + issuer: alice, + audience: w3, + capability: { + can: 'test/echo', + with: alice.did(), + }, + }).delegate() + + assert.throws( + () => Invocation.view({ root: invocation.cid, blocks: new Map() }), + /not found/ + ) + + assert.deepEqual( + Invocation.view({ root: invocation.cid, blocks: new Map() }, null), + null, + 'returns fallback' + ) +}) From e4eabe56d8d8c48c0da6b51088f05afb38594419 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Fri, 7 Apr 2023 17:21:24 -0700 Subject: [PATCH 12/15] remove obsolete file --- packages/core/src/task.js | 55 --------------------------------------- 1 file changed, 55 deletions(-) delete mode 100644 packages/core/src/task.js diff --git a/packages/core/src/task.js b/packages/core/src/task.js deleted file mode 100644 index 2b65f0af..00000000 --- a/packages/core/src/task.js +++ /dev/null @@ -1,55 +0,0 @@ -import * as API from '@ucanto/interface' - -/** - * @template {API.Ability} Operation - * @template {API.Resource} Resource - * @template {API.Caveats} Data - * @param {Operation} op - * @param {Resource} uri - * @param {Data} [input=API.Unit] - */ -export const task = (op, uri, input) => - new TaskBuilder({ - op, - uri, - input: input || /** @type {Data} */ ({}), - }) - -/** - * @template {API.Ability} Operation - * @template {API.Resource} Resource - * @template {API.Caveats} Data - */ -class TaskBuilder { - /** - * @param {object} source - * @param {Operation} source.op - * @param {Resource} source.uri - * @param {Data} [source.input] - * @param {string} [source.nonce] - */ - constructor({ op, uri, input, nonce = '' }) { - this.op = op - this.rsc = uri - this.input = input - this.nonce = nonce - } - - /** - * @template {API.Caveats} Input - * @param {object} source - * @param {Input} [source.input] - * @param {string} [source.nonce] - * @returns {TaskBuilder} - */ - with({ input, nonce }) { - const { op, rsc } = this - - return new TaskBuilder({ - op, - uri: rsc, - input: /** @type {Data & Input} */ ({ ...this.input, ...input }), - nonce: nonce == null ? this.nonce : nonce, - }) - } -} From a110b6187e1a63c0973c2920ae1eacacb0de059f Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Fri, 7 Apr 2023 17:23:27 -0700 Subject: [PATCH 13/15] remove obsolete types --- packages/interface/src/lib.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/interface/src/lib.ts b/packages/interface/src/lib.ts index cbe1c7ff..d8a3666c 100644 --- a/packages/interface/src/lib.ts +++ b/packages/interface/src/lib.ts @@ -704,23 +704,6 @@ export interface AgentMessage get(link: Link, fallback?: E): Receipt | E } -/** - * Describes an IPLD schema for workflows that preceded UCAN invocation - * specifications. - */ -export interface WorkflowModel { - /** - * Links to the (invocation) delegations to be executed concurrently. - */ - run: Link>[] -} - -export interface Workflow< - I extends Tuple = Tuple -> extends Phantom { - run: I -} - export interface ReportModel extends Phantom { receipts: Record< ToString>, From 50f00605e89bd57656aaee6de26316e2d385732c Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Fri, 7 Apr 2023 17:25:46 -0700 Subject: [PATCH 14/15] Simplify naming --- packages/core/src/invocation.js | 2 +- packages/interface/src/lib.ts | 8 ++++---- packages/server/src/server.js | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/core/src/invocation.js b/packages/core/src/invocation.js index f18ac809..b9c7b5a4 100644 --- a/packages/core/src/invocation.js +++ b/packages/core/src/invocation.js @@ -94,7 +94,7 @@ class IssuedInvocation { /** * @template {API.InvocationService} Service * @param {API.ConnectionView} connection - * @returns {Promise>} + * @returns {Promise>} */ async execute(connection) { /** @type {API.ServiceInvocation} */ diff --git a/packages/interface/src/lib.ts b/packages/interface/src/lib.ts index d8a3666c..1dc75987 100644 --- a/packages/interface/src/lib.ts +++ b/packages/interface/src/lib.ts @@ -633,7 +633,7 @@ export type InferServiceInvocationReturn< > : never -export type InferServiceInvocationReceipt< +export type InferReceipt< C extends Capability, S extends Record > = ResolveServiceMethod extends ServiceMethod< @@ -666,7 +666,7 @@ export type InferReceipts< > = I extends [] ? [] : I extends [ServiceInvocation, ...infer Rest] - ? [InferServiceInvocationReceipt, ...InferReceipts] + ? [InferReceipt, ...InferReceipts] : never /** @@ -726,7 +726,7 @@ export interface IssuedInvocationView delegate(): Await> execute>( service: ConnectionView - ): Await> + ): Await> } export type ServiceInvocations = IssuedInvocation & @@ -893,7 +893,7 @@ export interface ServerView> catch: (err: HandlerExecutionError) => void run( invocation: ServiceInvocation - ): Await> + ): Await> } /** diff --git a/packages/server/src/server.js b/packages/server/src/server.js index 158616e5..1c8f4ce6 100644 --- a/packages/server/src/server.js +++ b/packages/server/src/server.js @@ -50,10 +50,10 @@ class Server { /** * @template {API.Capability} C * @param {API.ServiceInvocation} invocation - * @returns {Promise>} + * @returns {Promise>} */ async run(invocation) { - const receipt = /** @type {API.InferServiceInvocationReceipt} */ ( + const receipt = /** @type {API.InferReceipt} */ ( await invoke(await invocation.buildIPLDView(), this) ) return receipt From 764db518635f1033cf177ba936fb74ce967181cd Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Mon, 10 Apr 2023 20:32:01 -0700 Subject: [PATCH 15/15] Update packages/interface/src/lib.ts Co-authored-by: Benjamin Goering <171782+gobengo@users.noreply.github.com> --- packages/interface/src/lib.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/interface/src/lib.ts b/packages/interface/src/lib.ts index 1dc75987..b56ff009 100644 --- a/packages/interface/src/lib.ts +++ b/packages/interface/src/lib.ts @@ -477,6 +477,9 @@ export type Result = Variant<{ error: X }> +/** + * @see {@link https://en.wikipedia.org/wiki/Unit_type|Unit type - Wikipedia} + */ export interface Unit {} /**