Skip to content

Commit

Permalink
feat!: add support for embedded effects (#347)
Browse files Browse the repository at this point in the history
* feat!: add support for embedded effects

* chore: increase code coverage
  • Loading branch information
Gozala authored Apr 4, 2024
1 parent c24a99c commit 58f7c13
Show file tree
Hide file tree
Showing 5 changed files with 166 additions and 20 deletions.
11 changes: 10 additions & 1 deletion packages/core/src/invocation.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import * as API from '@ucanto/interface'
import { delegate, Delegation } from './delegation.js'
import { delegate, Delegation, isDelegation } from './delegation.js'
import * as DAG from './dag.js'

/**
* Takes invocation link or a reference and returns `true` if value
* passed is a reference, returns `false` if value is a link.
*
* @param {API.Invocation | API.Link} value
* @return {value is API.Invocation}
*/
export const isInvocation = value => isDelegation(value)

/**
* @template {API.Capability} Capability
* @param {API.InvocationOptions<Capability>} options
Expand Down
44 changes: 39 additions & 5 deletions packages/core/src/receipt.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ class Receipt {

this.root = root
this._ran = ran

// Field is materialized on demand when `fx` getter is first accessed.
/** @type {API.Effects|undefined} */
this._fx = undefined
this._signature = signature
this._proofs = proofs
this._issuer = issuer
Expand Down Expand Up @@ -123,7 +127,22 @@ class Receipt {
}

get fx() {
return this.root.data.ocm.fx
let fx = this._fx
if (!fx) {
const { store: blocks } = this
const { fork, join } = this.root.data.ocm.fx

fx = {
fork: fork.map(root => Invocation.view({ root, blocks }, root)),
}

if (join) {
fx.join = Invocation.view({ root: join, blocks }, join)
}

this._fx = fx
}
return fx
}

get signature() {
Expand Down Expand Up @@ -192,7 +211,7 @@ class ReceptBuilder {
* @param {API.Signer<API.DID, SigAlg>} options.issuer
* @param {Ran|ReturnType<Ran['link']>} options.ran
* @param {API.Result<Ok, Error>} options.result
* @param {API.EffectsModel} [options.fx]
* @param {API.Effects} [options.fx]
* @param {API.Proof[]} [options.proofs]
* @param {Record<string, unknown>} [options.meta]
*/
Expand All @@ -211,18 +230,33 @@ class ReceptBuilder {
DAG.addEveryInto(DAG.iterate(this.ran), store)

// copy proof blocks into store
const prf = []
for (const proof of this.proofs) {
DAG.addEveryInto(DAG.iterate(proof), store)
prf.push(proof.link())
}

// copy blocks from the embedded fx
/** @type {{fork: API.Run[], join?:API.Run}} */
const fx = { fork: [] }
for (const fork of this.fx.fork) {
DAG.addEveryInto(DAG.iterate(fork), store)
fx.fork.push(fork.link())
}

if (this.fx.join) {
DAG.addEveryInto(DAG.iterate(this.fx.join), store)
fx.join = this.fx.join.link()
}

/** @type {API.OutcomeModel<Ok, Error, Ran>} */
const outcome = {
ran: /** @type {ReturnType<Ran['link']>} */ (this.ran.link()),
out: this.result,
fx: this.fx,
fx,
meta: this.meta,
iss: this.issuer.did(),
prf: this.proofs.map(p => p.link()),
prf,
}

const signature = await this.issuer.sign(CBOR.encode(outcome))
Expand Down Expand Up @@ -260,7 +294,7 @@ const NOFX = Object.freeze({ fork: Object.freeze([]) })
* @param {API.Signer<API.DID, SigAlg>} options.issuer
* @param {Ran|ReturnType<Ran['link']>} options.ran
* @param {API.Result<Ok, Error>} options.result
* @param {API.EffectsModel} [options.fx]
* @param {API.Effects} [options.fx]
* @param {API.Proof[]} [options.proofs]
* @param {Record<string, unknown>} [options.meta]
* @returns {Promise<API.Receipt<Ok, Error, Ran, SigAlg>>}
Expand Down
24 changes: 19 additions & 5 deletions packages/core/test/invocation.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { invoke, UCAN, Invocation } from '../src/lib.js'
import { alice, service as w3 } from './fixtures.js'
import { getBlock } from './utils.js'
import { assert, test } from './test.js'
import { isInvocation } from '../src/invocation.js'

test('encode invocation', async () => {
const add = invoke({
Expand Down Expand Up @@ -42,8 +43,8 @@ test('encode invocation with attached block in capability nb', async () => {
with: alice.did(),
link: 'bafy...stuff',
nb: {
inlineBlock: block.cid.link()
}
inlineBlock: block.cid.link(),
},
},
proofs: [],
})
Expand All @@ -61,12 +62,12 @@ test('encode invocation with attached block in capability nb', async () => {

const reassembledInvocation = Invocation.view({
root: view.root.cid.link(),
blocks: blockStore
blocks: blockStore,
})

/** @type {import('@ucanto/interface').BlockStore<unknown>} */
const reassembledBlockstore = new Map()

for (const b of reassembledInvocation.iterateIPLDBlocks()) {
reassembledBlockstore.set(`${b.cid}`, b)
}
Expand All @@ -75,7 +76,6 @@ test('encode invocation with attached block in capability nb', async () => {
assert.ok(reassembledBlockstore.get(`${block.cid}`))
})


test('expired invocation', async () => {
const expiration = UCAN.now() - 5
const invocation = invoke({
Expand Down Expand Up @@ -226,3 +226,17 @@ test('receipt view fallback', async () => {
'returns fallback'
)
})

test('isInvocation', async () => {
const invocation = await invoke({
issuer: alice,
audience: w3,
capability: {
can: 'test/echo',
with: alice.did(),
},
}).delegate()

assert.equal(Invocation.isInvocation(invocation), true)
assert.equal(Invocation.isInvocation(invocation.link()), false)
})
24 changes: 16 additions & 8 deletions packages/interface/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,10 @@ export interface EffectsModel {
join?: Link<ImpliedInvocationModel>
}

export interface Effects extends EffectsModel {}
export interface Effects {
fork: readonly Effect[]
join?: Effect
}

export interface InstructionModel<
Op extends Ability = Ability,
Expand Down Expand Up @@ -527,6 +530,11 @@ export type InferTransaction<T extends Transaction> = T extends Transaction<

export type Run = Link<ImpliedInvocationModel>

/**
* Effect is either an invocation or a link to one.
*/
export type Effect = Run | Invocation

export interface Do<T = unknown, X extends {} = {}> {
out: Result<T, X>
fx: Effects
Expand All @@ -540,8 +548,8 @@ export interface OkBuilder<T extends unknown = undefined, X extends {} = {}> {
result: Result<T, X>
effects: Effects

fork(fx: Run): ForkBuilder<T, X>
join(fx: Run): JoinBuilder<T, X>
fork(fx: Effect): ForkBuilder<T, X>
join(fx: Effect): JoinBuilder<T, X>
}

export interface ErrorBuilder<
Expand All @@ -555,8 +563,8 @@ export interface ErrorBuilder<
result: Result<T, X>
effects: Effects

fork(fx: Run): ForkBuilder<T, X>
join(fx: Run): JoinBuilder<T, X>
fork(fx: Effect): ForkBuilder<T, X>
join(fx: Effect): JoinBuilder<T, X>
}

export interface ForkBuilder<T extends unknown = undefined, X extends {} = {}> {
Expand All @@ -566,8 +574,8 @@ export interface ForkBuilder<T extends unknown = undefined, X extends {} = {}> {
result: Result<T, X>
effects: Effects

fork(fx: Run): ForkBuilder<T, X>
join(fx: Run): JoinBuilder<T, X>
fork(fx: Effect): ForkBuilder<T, X>
join(fx: Effect): JoinBuilder<T, X>
}

export interface JoinBuilder<T extends unknown = unknown, X extends {} = {}> {
Expand All @@ -577,7 +585,7 @@ export interface JoinBuilder<T extends unknown = unknown, X extends {} = {}> {
result: Result<T, X>
effects: Effects

fork(fx: Run): JoinBuilder<T, X>
fork(fx: Effect): JoinBuilder<T, X>
}

/**
Expand Down
83 changes: 82 additions & 1 deletion packages/server/test/handler.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
} from '@ucanto/validator'
import { Absentee } from '@ucanto/principal'
import { capability } from '../src/server.js'
import { isLink, parseLink, fail } from '../src/lib.js'
import { isLink, parseLink, fail, Invocation } from '../src/lib.js'

const w3 = service.withDID('did:web:web3.storage')

Expand Down Expand Up @@ -467,6 +467,87 @@ test('fx.fork', async () => {
assert.deepEqual(receipt.fx.fork, forks)
})

test('embed fx.fork', async () => {
/** @type {API.Invocation[]} */
const forks = []
const offer = Provider.provideAdvanced({
capability: Offer,
handler: async ({ capability, context }) => {
const fx = await Arrange.invoke({
issuer: context.id,
audience: context.id,
with: context.id.did(),
nb: { commP: capability.nb.commP },
}).delegate()

forks.push(fx)

return Provider.ok({ status: 'pending' }).fork(fx)
},
})

const { provider, consumer } = setup({ aggregate: { offer } })
const receipt = await Offer.invoke({
issuer: alice,
audience: provider.id,
with: alice.did(),
nb: {
commP: 'hello',
},
}).execute(consumer)

assert.deepEqual(receipt.out, { ok: { status: 'pending' } })

assert.deepEqual(receipt.fx.fork, forks)
const fork = receipt.fx.fork[0]

assert.equal(isLink(fork), false)
if (Invocation.isInvocation(fork)) {
assert.deepEqual(fork.issuer.did(), provider.id.did())
assert.deepEqual(fork.audience.did(), provider.id.did())
assert.deepEqual(fork.capabilities, [
{
can: 'offer/arrange',
with: provider.id.did(),
nb: { commP: 'hello' },
},
])
}
})

test('embed fx.join', async () => {
let join = undefined
const offer = Provider.provideAdvanced({
capability: Offer,
handler: async ({ capability, context }) => {
const fx = await Arrange.invoke({
issuer: context.id,
audience: context.id,
with: context.id.did(),
nb: { commP: capability.nb.commP },
}).delegate()

join = fx

return Provider.ok({ status: 'pending' }).join(fx)
},
})

const { provider, consumer } = setup({ aggregate: { offer } })
const receipt = await Offer.invoke({
issuer: alice,
audience: provider.id,
with: alice.did(),
nb: {
commP: 'hello',
},
}).execute(consumer)

assert.deepEqual(receipt.out, { ok: { status: 'pending' } })

assert.deepEqual(receipt.fx.join, join)
})

test('fx.ok API', () => {
const ok = Provider.ok({ x: 1 })
assert.deepEqual(ok.result, { ok: { x: 1 } })
Expand Down

0 comments on commit 58f7c13

Please sign in to comment.