From abb2bfda0ac1f0c92062c393e6c8156842a5d14b Mon Sep 17 00:00:00 2001 From: BL Date: Tue, 8 Sep 2026 16:43:47 +0000 Subject: [PATCH 1/5] feat(effect): add @misofm/effect, the shared Effect v4 foundation for the SDKs One SuiClient/SuiGraphQL service tag and one error vocabulary (Schema.TaggedError: ObjectNotFoundError, ObjectTypeMismatchError, SuiRpcError, BcsDecodeError, TransactionFailedError, GraphQLUnavailableError, DeploymentError), the shared object-read and dynamic-field primitives (getObjectContent, getOptionalObjectContent, getObjectsContent, listDynamicFields as a Stream, decodeBcs through Schema, assertObjectType), and the execute helpers (buildTx, signAndExecute, execThunks) with the pure effect extractors. Every SDK migrates onto this package next so consumers provide exactly one layer. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QGmuA3eMr4iaRTdz3J8JZJ --- .github/workflows/publish.yml | 14 +- bun.lock | 21 ++- package.json | 2 +- packages/effect/LICENSE | 201 +++++++++++++++++++++++++ packages/effect/README.md | 71 +++++++++ packages/effect/package.json | 82 ++++++++++ packages/effect/src/errors.ts | 59 ++++++++ packages/effect/src/execute.ts | 177 ++++++++++++++++++++++ packages/effect/src/index.ts | 10 ++ packages/effect/src/reads.ts | 165 ++++++++++++++++++++ packages/effect/src/sui-client.ts | 27 ++++ packages/effect/tests/execute.test.ts | 188 +++++++++++++++++++++++ packages/effect/tests/reads.test.ts | 209 ++++++++++++++++++++++++++ packages/effect/tsconfig.build.json | 29 ++++ packages/effect/tsconfig.json | 4 + packages/effect/typedoc.json | 20 +++ 16 files changed, 1272 insertions(+), 7 deletions(-) create mode 100644 packages/effect/LICENSE create mode 100644 packages/effect/README.md create mode 100644 packages/effect/package.json create mode 100644 packages/effect/src/errors.ts create mode 100644 packages/effect/src/execute.ts create mode 100644 packages/effect/src/index.ts create mode 100644 packages/effect/src/reads.ts create mode 100644 packages/effect/src/sui-client.ts create mode 100644 packages/effect/tests/execute.test.ts create mode 100644 packages/effect/tests/reads.test.ts create mode 100644 packages/effect/tsconfig.build.json create mode 100644 packages/effect/tsconfig.json create mode 100644 packages/effect/typedoc.json diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bff8fb2..ca485d7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,13 +1,13 @@ name: Publish to npm -# Publishes on a per-package version tag (musicos-v0.1.0, partyos-v0.1.0, platform-v0.25.0, -# streaming-v0.1.0, transcoding-v0.3.0, …), so a release is a deliberate act rather -# than a side effect of merging, and releasing one workspace package never +# Publishes on a per-package version tag (effect-v0.1.0, musicos-v0.1.0, partyos-v0.1.0, +# platform-v0.25.0, streaming-v0.1.0, transcoding-v0.3.0, …), so a release is a deliberate act +# rather than a side effect of merging, and releasing one workspace package never # implies another. Failed tag jobs # can be re-run from Actions without permitting a branch to publish a package. on: push: - tags: ["musicos-v*", "partyos-v*", "platform-v*", "streaming-v*", "transcoding-v*"] + tags: ["effect-v*", "musicos-v*", "partyos-v*", "platform-v*", "streaming-v*", "transcoding-v*"] concurrency: group: publish-${{ github.ref }} @@ -53,6 +53,10 @@ jobs: run: | TAG="${GITHUB_REF_NAME}" case "$TAG" in + effect-v*) + DIR="packages/effect" + VERSION="${TAG#effect-v}" + ;; musicos-v*) DIR="packages/musicos" VERSION="${TAG#musicos-v}" @@ -74,7 +78,7 @@ jobs: VERSION="${TAG#transcoding-v}" ;; *) - echo "::error::Tag '$TAG' does not match musicos-v*, partyos-v*, platform-v*, streaming-v*, or transcoding-v*" + echo "::error::Tag '$TAG' does not match effect-v*, musicos-v*, partyos-v*, platform-v*, streaming-v*, or transcoding-v*" exit 1 ;; esac diff --git a/bun.lock b/bun.lock index 7ddc70d..1767fb3 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,23 @@ "typescript": "^7.0.2", }, }, + "packages/effect": { + "name": "@misofm/effect", + "version": "0.1.0", + "devDependencies": { + "@mysten/sui": "2.29.0", + "@types/bun": "^1.4.0", + "effect": "4.0.0-rc.112", + "typedoc": "^0.28.20", + "typedoc-plugin-markdown": "^4.13.0", + "typescript": "^7.0.2", + }, + "peerDependencies": { + "@mysten/sui": "2.29.0", + "effect": "4.0.0-rc.112", + "typescript": "^5.7 || ^6 || ^7", + }, + }, "packages/musicos": { "name": "@misofm/musicos", "version": "0.2.0", @@ -85,7 +102,7 @@ }, "packages/transcoding": { "name": "@misofm/transcoding", - "version": "0.3.0", + "version": "0.3.1", "dependencies": { "@misofm/streaming": "0.2.1", "m3u8-parser": "7.2.0", @@ -125,6 +142,8 @@ "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], + "@misofm/effect": ["@misofm/effect@workspace:packages/effect"], + "@misofm/musicos": ["@misofm/musicos@workspace:packages/musicos"], "@misofm/partyos": ["@misofm/partyos@workspace:packages/partyos"], diff --git a/package.json b/package.json index 7b727d8..7ac6a9e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "scripts": { "typecheck": "bun run --filter '*' typecheck", "test": "bun run --filter '*' test", - "build": "bun run --filter @misofm/streaming build && bun run --filter @misofm/musicos build && bun run --filter @misofm/partyos build && bun run --filter @misofm/platform build && bun run --filter @misofm/transcoding build", + "build": "bun run --filter @misofm/streaming build && bun run --filter @misofm/effect build && bun run --filter @misofm/musicos build && bun run --filter @misofm/partyos build && bun run --filter @misofm/platform build && bun run --filter @misofm/transcoding build", "codegen": "bun scripts/codegen.ts", "codegen:check": "bun run codegen && git diff --exit-code -- packages/musicos/src/contracts packages/partyos/src/contracts packages/platform/src/contracts", "test:consumer": "bun scripts/check-consumer.ts" diff --git a/packages/effect/LICENSE b/packages/effect/LICENSE new file mode 100644 index 0000000..34ce76b --- /dev/null +++ b/packages/effect/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Miso Labs, Inc. (build@miso.network) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/effect/README.md b/packages/effect/README.md new file mode 100644 index 0000000..92323e2 --- /dev/null +++ b/packages/effect/README.md @@ -0,0 +1,71 @@ +# @misofm/effect + +The shared Effect foundation for the Miso SDKs: one `SuiClient` service, one error +vocabulary, and the read/execute primitives every `@misofm/*` package's queries and +transaction builders are composed from. + +Every other `@misofm/*` package depends on `@misofm/effect`, so there is exactly one +`SuiClient` service tag and one set of tagged errors across the SDKs — a program that +mixes primitives from `@misofm/musicos`, `@misofm/partyos`, and `@misofm/platform` still +needs only one `Effect.provide(SuiClient.layer(client))` at its boundary. + +## Use + +```ts +import { Effect } from "effect"; +import { SuiGrpcClient } from "@mysten/sui/grpc"; +import { SuiClient, getObjectContent } from "@misofm/effect"; + +const client = new SuiGrpcClient({ network: "testnet" }); + +const program = Effect.gen(function* () { + const object = yield* getObjectContent("0x..."); + return object.type; +}).pipe( + // `ObjectNotFoundError` is a tagged failure, not a bare throw — recover + // from it without inspecting a message. + Effect.catchTag("ObjectNotFoundError", (error) => + Effect.succeed(`missing: ${error.objectId}`), + ), +); + +const type = await Effect.runPromise( + program.pipe(Effect.provide(SuiClient.layer(client))), +); +``` + +`getObjectContent`, `getObjectsContent`, `getOptionalObjectContent`, `listDynamicFields`, +and `decodeBcs` all require the `SuiClient` service; `signAndExecute` and `execThunks` +additionally need a `Signer`. Provide `SuiClient.layer(client)` once at the edge of your +program — every primitive composed from these requires the service only once in `R`. + +## Errors + +Every failure is a `Schema.TaggedError` carrying the fields a caller needs to act: +`ObjectNotFoundError { objectId }`, `ObjectTypeMismatchError { objectId, expected, actual }`, +`SuiRpcError { operation, cause }`, `BcsDecodeError { type, objectId?, cause }`, +`TransactionFailedError { digest, status }`, `GraphQLUnavailableError {}`, and +`DeploymentError { message }`. Recover from one with `Effect.catchTag("ObjectNotFoundError", ...)` +rather than an `isNotFound(error)` predicate. + +## Subpaths + +| Subpath | Purpose | +| --------------------- | --------------------------------------------------------------- | +| `@misofm/effect` | Everything below, re-exported | +| `@misofm/effect/errors` | The `Schema.TaggedError` classes and the `SuiReadError` union | +| `@misofm/effect/sui-client` | The `SuiClient` and `SuiGraphQL` services + their layers | +| `@misofm/effect/reads` | `getObjectContent`, `getObjectsContent`, `getOptionalObjectContent`, `listDynamicFields`, `decodeBcs`, `assertObjectType` | +| `@misofm/effect/execute` | `buildTx`, `signAndExecute`, `execThunks`, and the pure `ExecResult` extractors | + +## Install + +```sh +bun add @misofm/effect effect @mysten/sui +``` + +Peer dependencies: `effect@4.0.0-rc.112` and `@mysten/sui@2.29.0` (both exact). + +## License + +Apache-2.0 diff --git a/packages/effect/package.json b/packages/effect/package.json new file mode 100644 index 0000000..d34e5d6 --- /dev/null +++ b/packages/effect/package.json @@ -0,0 +1,82 @@ +{ + "name": "@misofm/effect", + "version": "0.1.0", + "packageManager": "bun@1.4.0", + "description": "Shared Effect foundation for the Miso SDKs: one SuiClient service, one error vocabulary, and the read/execute primitives every package builds on.", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/misofm/sdks.git", + "directory": "packages/effect" + }, + "homepage": "https://github.com/misofm/sdks#readme", + "bugs": { + "url": "https://github.com/misofm/sdks/issues" + }, + "keywords": [ + "sui", + "effect", + "miso", + "blockchain", + "sdk" + ], + "publishConfig": { + "access": "public" + }, + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "type": "module", + "sideEffects": false, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test", + "docs": "typedoc", + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "prepack": "rm -rf dist && tsc -p tsconfig.build.json", + "prepare": "rm -rf dist && tsc -p tsconfig.build.json --noCheck" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./errors": { + "types": "./dist/errors.d.ts", + "default": "./dist/errors.js" + }, + "./sui-client": { + "types": "./dist/sui-client.d.ts", + "default": "./dist/sui-client.js" + }, + "./reads": { + "types": "./dist/reads.d.ts", + "default": "./dist/reads.js" + }, + "./execute": { + "types": "./dist/execute.d.ts", + "default": "./dist/execute.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "src", + "README.md", + "LICENSE", + "package.json" + ], + "devDependencies": { + "@mysten/sui": "2.29.0", + "@types/bun": "^1.4.0", + "effect": "4.0.0-rc.112", + "typedoc": "^0.28.20", + "typedoc-plugin-markdown": "^4.13.0", + "typescript": "^7.0.2" + }, + "peerDependencies": { + "@mysten/sui": "2.29.0", + "effect": "4.0.0-rc.112", + "typescript": "^5.7 || ^6 || ^7" + }, + "main": "./dist/index.js" +} diff --git a/packages/effect/src/errors.ts b/packages/effect/src/errors.ts new file mode 100644 index 0000000..5d7071c --- /dev/null +++ b/packages/effect/src/errors.ts @@ -0,0 +1,59 @@ +// Copyright (c) Miso Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// The shared error vocabulary for every `@misofm/*` SDK. Every failure a caller +// might need to act on is a `Schema.TaggedError`, carrying the fields needed to +// act (object id, type, package, cause) rather than a bare message. Foreign +// failures (RPC rejections, thrown parsers) are wrapped with `cause: Schema.Defect()` +// so the original error is preserved but not part of the typed contract. + +import { Schema } from "effect"; +import type { SuiClientTypes } from "@mysten/sui/client"; + +/** The requested object does not exist on-chain (never existed, was deleted, or has no such dynamic field). */ +export class ObjectNotFoundError extends Schema.TaggedError()("ObjectNotFoundError", { + objectId: Schema.String, +}) {} + +/** An object's on-chain type does not match the type the caller expected. */ +export class ObjectTypeMismatchError extends Schema.TaggedError()( + "ObjectTypeMismatchError", + { + objectId: Schema.String, + expected: Schema.String, + actual: Schema.String, + }, +) {} + +/** A Core API call threw or rejected for a reason other than a missing object. */ +export class SuiRpcError extends Schema.TaggedError()("SuiRpcError", { + operation: Schema.String, + cause: Schema.Defect(), +}) {} + +/** A generated BCS codec's `.parse` threw, or the parsed value failed `Schema.decodeUnknown` into the domain type. */ +export class BcsDecodeError extends Schema.TaggedError()("BcsDecodeError", { + type: Schema.String, + objectId: Schema.optional(Schema.String), + cause: Schema.Defect(), +}) {} + +/** A submitted transaction executed but its on-chain effects reported failure. */ +export class TransactionFailedError extends Schema.TaggedError()("TransactionFailedError", { + digest: Schema.String, + status: Schema.declare((u): u is SuiClientTypes.ExecutionStatus => true), +}) {} + +/** A `SuiGraphQL` read was requested but no GraphQL client is configured for this context. */ +export class GraphQLUnavailableError extends Schema.TaggedError()( + "GraphQLUnavailableError", + {}, +) {} + +/** A deployment manifest failed validation (wrong shape, missing package id, unverified network, ...). */ +export class DeploymentError extends Schema.TaggedError()("DeploymentError", { + message: Schema.String, +}) {} + +/** The error union every `reads.ts` primitive can fail with. */ +export type SuiReadError = ObjectNotFoundError | ObjectTypeMismatchError | SuiRpcError | BcsDecodeError; diff --git a/packages/effect/src/execute.ts b/packages/effect/src/execute.ts new file mode 100644 index 0000000..392c776 --- /dev/null +++ b/packages/effect/src/execute.ts @@ -0,0 +1,177 @@ +// Copyright (c) Miso Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Transaction building + execution + effect extraction (the Signer-parameter +// pattern). Builders elsewhere only *append* to a caller-owned Transaction; +// this module is where a transaction is actually built and submitted. +// `signAndExecute` submits over the unified Core API, unwraps the +// `{ $kind: "Transaction", Transaction }` envelope, waits for finality, and +// returns changed objects, the objectId→type map, balance changes, and net gas +// for downstream extraction. Everything here is transport-agnostic — it +// requires only the `SuiClient` service, so gRPC / JSON-RPC / GraphQL clients +// all work. + +import { Effect } from "effect"; +import { Transaction } from "@mysten/sui/transactions"; +import type { SuiClientTypes } from "@mysten/sui/client"; +import type { Signer } from "@mysten/sui/cryptography"; + +import { SuiRpcError, TransactionFailedError } from "./errors.ts"; +import { SuiClient } from "./sui-client.ts"; + +/** A function that appends commands to a caller-owned `Transaction`; may be async. */ +export type TxThunk = (tx: Transaction) => void | Promise; + +/** The effect fields every submit path requests, so extraction is uniform. */ +export const FULL_INCLUDE = { effects: true, objectTypes: true, balanceChanges: true } as const; +type FullInclude = typeof FULL_INCLUDE; + +/** The normalized outcome of a successfully-executed transaction. */ +export interface ExecResult { + digest: string; + /** Objects created/mutated/deleted by the transaction. */ + changedObjects: SuiClientTypes.ChangedObject[]; + /** Map of changed objectId → fully-qualified type. */ + objectTypes: Record; + /** Net coin balance deltas by address. */ + balanceChanges: SuiClientTypes.BalanceChange[]; + /** Net gas cost in MIST (computation + storage − rebate). */ + gasUsed: number; +} + +/** Builds a fresh `Transaction` from one or more thunks (awaiting async ones); never fails. */ +export const buildTx = Effect.fn("buildTx")(function* (...thunks: readonly TxThunk[]): Effect.fn.Return { + const tx = new Transaction(); + for (const thunk of thunks) { + yield* Effect.promise(() => Promise.resolve(thunk(tx))); + } + return tx; +}); + +/** Normalizes the `{ $kind }` transaction-result envelope into an {@link ExecResult}; throws on a failed status. */ +export function toExecResult(res: SuiClientTypes.TransactionResult): ExecResult { + if (res.$kind !== "Transaction") { + const failed = res.FailedTransaction; + const status = failed.effects?.status; + const err = status && !status.success ? JSON.stringify(status.error) : "unknown error"; + throw new Error(`Transaction failed: ${err} (digest ${failed.digest})`); + } + + const t = res.Transaction; + const effects = t.effects as SuiClientTypes.TransactionEffects; + if (!effects.status.success) { + throw new Error(`Transaction reverted: ${JSON.stringify(effects.status.error)} (digest ${t.digest})`); + } + + return { + digest: t.digest, + changedObjects: effects.changedObjects, + objectTypes: (t.objectTypes as Record) ?? {}, + balanceChanges: (t.balanceChanges as SuiClientTypes.BalanceChange[]) ?? [], + gasUsed: netGas(effects.gasUsed), + }; +} + +/** Signs, executes, and waits for a transaction; fails with `TransactionFailedError` if its effects report failure. */ +export const signAndExecute = Effect.fn("signAndExecute")(function* ( + signer: Signer, + tx: Transaction, +): Effect.fn.Return { + const client = yield* SuiClient; + const res = yield* Effect.tryPromise({ + try: (signal) => client.core.signAndExecuteTransaction({ transaction: tx, signer, include: FULL_INCLUDE, signal }), + catch: (cause) => new SuiRpcError({ operation: "signAndExecuteTransaction", cause }), + }); + + const { digest, status } = statusOf(res); + if (!status.success) { + return yield* new TransactionFailedError({ digest, status }); + } + + const result = toExecResult(res); + + yield* Effect.tryPromise({ + try: (signal) => client.core.waitForTransaction({ digest: result.digest, signal }), + catch: (cause) => new SuiRpcError({ operation: "waitForTransaction", cause }), + }); + + return result; +}); + +/** Convenience: build from thunks, then sign+execute in one call. */ +export const execThunks = Effect.fn("execThunks")(function* ( + signer: Signer, + ...thunks: readonly TxThunk[] +): Effect.fn.Return { + const tx = yield* buildTx(...thunks); + return yield* signAndExecute(signer, tx); +}); + +// ── Object-change extractors (pure) ───────────────────────────────────────── + +/** The package id from the (single) newly-published package. */ +export function publishedPackageId(r: ExecResult): string { + const pkg = r.changedObjects.find((c) => c.idOperation === "Created" && c.outputState === "PackageWrite"); + if (!pkg) throw new Error("No published package found in object changes."); + return pkg.objectId; +} + +/** All package ids newly published by the transaction (up to 5 per PTB). */ +export function allPublishedPackageIds(r: ExecResult): string[] { + return r.changedObjects + .filter((c) => c.idOperation === "Created" && c.outputState === "PackageWrite") + .map((c) => c.objectId); +} + +/** The first newly-created object whose type contains `substr`. */ +export function createdByType(r: ExecResult, substr: string): string { + const id = maybeCreatedByType(r, substr); + if (!id) throw new Error(`No created object with type containing "${substr}" found in object changes.`); + return id; +} + +/** Like {@link createdByType} but returns undefined instead of throwing. */ +export function maybeCreatedByType(r: ExecResult, substr: string): string | undefined { + for (const c of r.changedObjects) { + if (c.idOperation === "Created" && (r.objectTypes[c.objectId] ?? "").includes(substr)) return c.objectId; + } + return undefined; +} + +/** The first newly-created object whose type is EXACTLY `type` (for non-generic types). */ +export function createdByExactType(r: ExecResult, type: string): string { + for (const c of r.changedObjects) { + if (c.idOperation === "Created" && r.objectTypes[c.objectId] === type) return c.objectId; + } + throw new Error(`No created object of exact type "${type}" found in object changes.`); +} + +/** All newly-created objects whose type contains `substr`. */ +export function allCreatedByType(r: ExecResult, substr: string): { objectId: string; objectType: string }[] { + const out: { objectId: string; objectType: string }[] = []; + for (const c of r.changedObjects) { + const type = r.objectTypes[c.objectId] ?? ""; + if (c.idOperation === "Created" && type.includes(substr)) out.push({ objectId: c.objectId, objectType: type }); + } + return out; +} + +/** The signed balance delta for `address` in `coinType`, or "0" if absent. */ +export function balanceDelta(r: ExecResult, address: string, coinType: string): string { + const change = r.balanceChanges.find((b) => b.address === address && b.coinType === coinType); + return change?.amount ?? "0"; +} + +// ── Private ────────────────────────────────────────────────────────────────── + +/** The digest and top-level execution status of a `TransactionResult`, whichever `$kind` it is. */ +function statusOf( + res: SuiClientTypes.TransactionResult, +): { digest: string; status: SuiClientTypes.ExecutionStatus } { + const t = res.$kind === "Transaction" ? res.Transaction : res.FailedTransaction; + return { digest: t.digest, status: t.status }; +} + +function netGas(gasUsed: SuiClientTypes.GasCostSummary): number { + return Number(gasUsed.computationCost) + Number(gasUsed.storageCost) - Number(gasUsed.storageRebate); +} diff --git a/packages/effect/src/index.ts b/packages/effect/src/index.ts new file mode 100644 index 0000000..b82cc18 --- /dev/null +++ b/packages/effect/src/index.ts @@ -0,0 +1,10 @@ +// Copyright (c) Miso Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// `@misofm/effect` — the shared Effect foundation for the Miso SDKs: one +// `SuiClient` service, one error vocabulary, and the read/execute primitives +// every package's queries and transaction builders are composed from. +export * from "./errors.ts"; +export * from "./sui-client.ts"; +export * from "./reads.ts"; +export * from "./execute.ts"; diff --git a/packages/effect/src/reads.ts b/packages/effect/src/reads.ts new file mode 100644 index 0000000..1bf8ef4 --- /dev/null +++ b/packages/effect/src/reads.ts @@ -0,0 +1,165 @@ +// Copyright (c) Miso Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// The shared read primitives every `@misofm/*` package's queries are built +// from. Every primitive requires the `SuiClient` service instead of taking a +// client parameter, and every not-found path is classified into a typed +// `ObjectNotFoundError` rather than a bare thrown error — see `isNotFound` +// below, ported from the transport-sniffing heuristic each package used to +// hand-roll. + +import { Effect, Option, Schema, Stream } from "effect"; +import type { SuiClientTypes } from "@mysten/sui/client"; + +import { BcsDecodeError, ObjectNotFoundError, ObjectTypeMismatchError, SuiRpcError } from "./errors.ts"; +import { SuiClient } from "./sui-client.ts"; + +/** One object's BCS content, on-chain type, and version. */ +export interface ObjectContent { + readonly content: Uint8Array; + readonly type: string; + readonly version: string; +} + +/** A dynamic field entry as returned by `listDynamicFields` (name + value type, not the decoded value). */ +export type DynamicField = SuiClientTypes.DynamicFieldEntry; + +/** Any generated BCS codec with a `parse` method — the shape every `src/contracts/**` struct exports. */ +export interface BcsParser { + parse(bytes: Uint8Array): T; +} + +/** Identifies the object/type a `decodeBcs` failure is reported against. */ +export interface DecodeBcsContext { + /** Fully-qualified Move type (or domain type name) being decoded. */ + readonly type: string; + /** Object id being decoded, when decoding a specific object's content. */ + readonly objectId?: string; +} + +/** Fetches one object's BCS content by id; fails with `ObjectNotFoundError` if it does not exist. */ +export const getObjectContent = Effect.fn("getObjectContent")(function* ( + objectId: string, +): Effect.fn.Return { + const client = yield* SuiClient; + const { object } = yield* Effect.tryPromise({ + try: (signal) => client.core.getObject({ objectId, include: { content: true }, signal }), + catch: (cause) => classifyObjectError(objectId, "getObject", cause), + }); + if (!object.content) { + return yield* new ObjectNotFoundError({ objectId }); + } + return { content: object.content, type: object.type, version: object.version }; +}); + +/** Like {@link getObjectContent}, but a missing object resolves to `Option.none()` instead of failing. */ +export const getOptionalObjectContent = Effect.fn("getOptionalObjectContent")(function* ( + objectId: string, +): Effect.fn.Return, SuiRpcError, SuiClient> { + return yield* getObjectContent(objectId).pipe( + Effect.map(Option.some), + Effect.catchTag("ObjectNotFoundError", () => Effect.succeed(Option.none())), + ); +}); + +/** Fetches many objects' BCS content in one Core request; ids that are missing or errored are omitted from the map. */ +export const getObjectsContent = Effect.fn("getObjectsContent")(function* ( + objectIds: readonly string[], +): Effect.fn.Return, SuiRpcError, SuiClient> { + const out = new Map(); + if (objectIds.length === 0) return out; + + const client = yield* SuiClient; + const { objects } = yield* Effect.tryPromise({ + try: (signal) => client.core.getObjects({ objectIds: [...objectIds], include: { content: true }, signal }), + catch: (cause) => new SuiRpcError({ operation: "getObjects", cause }), + }); + for (const obj of objects) { + if (obj instanceof Error || !obj.content) continue; + out.set(obj.objectId, { content: obj.content, type: obj.type }); + } + return out; +}); + +/** Pages every dynamic field under `parentId` through `client.core.listDynamicFields`, following the cursor. */ +export function listDynamicFields(parentId: string): Stream.Stream { + return Stream.paginate(null, (cursor) => + Effect.gen(function* () { + const client = yield* SuiClient; + const page = yield* Effect.tryPromise({ + try: (signal) => client.core.listDynamicFields({ parentId, cursor, signal }), + catch: (cause) => new SuiRpcError({ operation: "listDynamicFields", cause }), + }); + const next = page.hasNextPage ? Option.some(page.cursor) : Option.none(); + return [page.dynamicFields, next] as const; + }), + ); +} + +/** Runs a generated BCS codec's `.parse`, then decodes the result into a domain `Schema` type; both failure paths become `BcsDecodeError`. */ +export const decodeBcs = Effect.fn("decodeBcs")(function* ( + codec: BcsParser, + schema: Schema.Codec, + bytes: Uint8Array, + context: DecodeBcsContext, +): Effect.fn.Return { + const parsed = yield* Effect.try({ + try: () => codec.parse(bytes), + catch: (cause) => new BcsDecodeError({ type: context.type, objectId: context.objectId, cause }), + }); + return yield* Schema.decodeUnknownEffect(schema)(parsed).pipe( + Effect.mapError((cause) => new BcsDecodeError({ type: context.type, objectId: context.objectId, cause })), + ); +}); + +/** Fails with `ObjectTypeMismatchError` unless `actual` is exactly `expected`. */ +export const assertObjectType = Effect.fn("assertObjectType")(function* ( + objectId: string, + actual: string, + expected: string, +): Effect.fn.Return { + if (actual !== expected) { + return yield* new ObjectTypeMismatchError({ objectId, expected, actual }); + } +}); + +// ── Private ────────────────────────────────────────────────────────────────── + +/** Classifies a Core API rejection: a missing object becomes `ObjectNotFoundError`, anything else `SuiRpcError`. */ +function classifyObjectError(objectId: string, operation: string, cause: unknown): ObjectNotFoundError | SuiRpcError { + return isNotFound(cause) ? new ObjectNotFoundError({ objectId }) : new SuiRpcError({ operation, cause }); +} + +/** + * True when `e` is a "this object does not exist" error from any of the Sui + * client transports. Matches, in order of preference: + * + * 1. Structured `ObjectError.code` values thrown by the JSON-RPC core client + * (`notExists`, `deleted`, `dynamicFieldNotFound`) and the GraphQL core + * client (`notFound`). The class itself is not exported by `@mysten/sui`, + * so we duck-type on `code`. + * 2. The message shapes those clients (and the gRPC core client, which wraps + * the server's per-object status message in a plain `Error`) produce: + * "Object 0x… does not exist" / "Object 0x… not found" / "Object 0x… has + * been deleted" / "Dynamic field not found for object 0x…" / "No object + * found for id 0x…". + * + * Transport/protocol errors must NOT match: every message pattern requires + * object-ish context ("object" / "dynamic field"), so e.g. a JSON-RPC + * "Method not found" or a gRPC "peer not found" is never treated as a missing + * object and is reported as a `SuiRpcError` instead. + */ +function isNotFound(e: unknown): boolean { + if (typeof e === "object" && e !== null && "code" in e) { + const code = (e as { code: unknown }).code; + if (code === "notExists" || code === "deleted" || code === "dynamicFieldNotFound" || code === "notFound") { + return true; + } + } + const msg = e instanceof Error ? e.message : String(e); + return ( + /\bobject\b[\s\S]*\b(?:not\s?found|does not exist|has been deleted)\b/i.test(msg) || + /\bdynamic field\b[\s\S]*\bnot\s?found\b/i.test(msg) || + /\bno object\b/i.test(msg) + ); +} diff --git a/packages/effect/src/sui-client.ts b/packages/effect/src/sui-client.ts new file mode 100644 index 0000000..9a25c49 --- /dev/null +++ b/packages/effect/src/sui-client.ts @@ -0,0 +1,27 @@ +// Copyright (c) Miso Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// The Sui client services every `@misofm/*` SDK reaches through the Requirements +// channel instead of a `client` parameter. There is exactly one `SuiClient` +// service tag (and one `SuiGraphQL` tag) across the SDKs, so a program built +// from primitives in different packages still needs only one `Layer.provide`. + +import { Context, Layer } from "effect"; +import type { ClientWithCoreApi } from "@mysten/sui/client"; +import type { SuiGraphQLClient } from "@mysten/sui/graphql"; + +/** The unified Core API client (gRPC / JSON-RPC / GraphQL transports all satisfy it). */ +export class SuiClient extends Context.Service()("@misofm/effect/SuiClient") { + /** Provides a concrete `ClientWithCoreApi` as the `SuiClient` service. */ + static layer(client: ClientWithCoreApi): Layer.Layer { + return Layer.succeed(SuiClient, client); + } +} + +/** The optional GraphQL client, required only by type-discovery reads. */ +export class SuiGraphQL extends Context.Service()("@misofm/effect/SuiGraphQL") { + /** Provides a concrete `SuiGraphQLClient` as the `SuiGraphQL` service. */ + static layer(client: SuiGraphQLClient): Layer.Layer { + return Layer.succeed(SuiGraphQL, client); + } +} diff --git a/packages/effect/tests/execute.test.ts b/packages/effect/tests/execute.test.ts new file mode 100644 index 0000000..d3c44f7 --- /dev/null +++ b/packages/effect/tests/execute.test.ts @@ -0,0 +1,188 @@ +// Copyright (c) Miso Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "bun:test"; +import { Effect } from "effect"; +import { Transaction } from "@mysten/sui/transactions"; +import type { ClientWithCoreApi } from "@mysten/sui/client"; +import type { Signer } from "@mysten/sui/cryptography"; + +import { + allCreatedByType, + balanceDelta, + buildTx, + createdByExactType, + createdByType, + execThunks, + publishedPackageId, + signAndExecute, + toExecResult, + type ExecResult, +} from "../src/execute.ts"; +import { SuiRpcError, TransactionFailedError } from "../src/errors.ts"; +import { SuiClient } from "../src/sui-client.ts"; + +function fakeClient(core: Record): ClientWithCoreApi { + return { core } as unknown as ClientWithCoreApi; +} + +const fakeSigner = {} as Signer; + +const successResult = (digest: string) => ({ + $kind: "Transaction" as const, + Transaction: { + digest, + status: { success: true as const, error: null }, + effects: { + status: { success: true as const, error: null }, + changedObjects: [ + { objectId: "0xcreated", idOperation: "Created" as const, outputState: "ObjectWrite" as const }, + ], + gasUsed: { computationCost: "10", storageCost: "5", storageRebate: "2", nonRefundableStorageFee: "0" }, + }, + objectTypes: { "0xcreated": "0x1::mod::Thing" }, + balanceChanges: [{ address: "0xowner", coinType: "0x2::sui::SUI", amount: "-15" }], + }, +}); + +const failedResult = (digest: string) => ({ + $kind: "Transaction" as const, + Transaction: { + digest, + status: { success: false as const, error: { code: "InsufficientGas" } }, + effects: { + status: { success: false as const, error: { code: "InsufficientGas" } }, + changedObjects: [], + gasUsed: { computationCost: "0", storageCost: "0", storageRebate: "0", nonRefundableStorageFee: "0" }, + }, + objectTypes: {}, + balanceChanges: [], + }, +}); + +// ── buildTx ────────────────────────────────────────────────────────────────── + +test("buildTx runs sync and async thunks against one fresh Transaction", async () => { + const seen: boolean[] = []; + const tx = await Effect.runPromise( + buildTx( + (t) => { + seen.push(t instanceof Transaction); + }, + async (t) => { + await Promise.resolve(); + seen.push(t instanceof Transaction); + }, + ), + ); + + expect(tx).toBeInstanceOf(Transaction); + expect(seen).toEqual([true, true]); +}); + +// ── signAndExecute ─────────────────────────────────────────────────────────── + +test("signAndExecute submits, waits, and normalizes a successful transaction", async () => { + const calls: string[] = []; + const client = fakeClient({ + signAndExecuteTransaction: async () => { + calls.push("sign"); + return successResult("digest-ok"); + }, + waitForTransaction: async () => { + calls.push("wait"); + return successResult("digest-ok"); + }, + }); + + const result = await Effect.runPromise( + signAndExecute(fakeSigner, new Transaction()).pipe(Effect.provide(SuiClient.layer(client))), + ); + + expect(result.digest).toBe("digest-ok"); + expect(result.gasUsed).toBe(13); + expect(result.objectTypes).toEqual({ "0xcreated": "0x1::mod::Thing" }); + expect(calls).toEqual(["sign", "wait"]); +}); + +test("signAndExecute fails with TransactionFailedError when the execution status reports failure", async () => { + const client = fakeClient({ + signAndExecuteTransaction: async () => failedResult("digest-fail"), + waitForTransaction: async () => { + throw new Error("waitForTransaction must not be called for a failed transaction"); + }, + }); + + const err = await Effect.runPromise( + signAndExecute(fakeSigner, new Transaction()).pipe(Effect.provide(SuiClient.layer(client)), Effect.flip), + ); + + expect(err).toBeInstanceOf(TransactionFailedError); + expect(err).toMatchObject({ _tag: "TransactionFailedError", digest: "digest-fail" }); +}); + +test("signAndExecute fails with SuiRpcError when the Core API call rejects", async () => { + const client = fakeClient({ + signAndExecuteTransaction: async () => { + throw new Error("network down"); + }, + }); + + const err = await Effect.runPromise( + signAndExecute(fakeSigner, new Transaction()).pipe(Effect.provide(SuiClient.layer(client)), Effect.flip), + ); + + expect(err).toBeInstanceOf(SuiRpcError); + expect(err).toMatchObject({ _tag: "SuiRpcError", operation: "signAndExecuteTransaction" }); +}); + +test("execThunks builds from thunks then signs and executes in one call", async () => { + const client = fakeClient({ + signAndExecuteTransaction: async () => successResult("digest-thunked"), + waitForTransaction: async () => successResult("digest-thunked"), + }); + + const result = await Effect.runPromise( + execThunks(fakeSigner, () => {}).pipe(Effect.provide(SuiClient.layer(client))), + ); + + expect(result.digest).toBe("digest-thunked"); +}); + +// ── object-change extractors (pure) ───────────────────────────────────────── + +const okResult = toExecResult(successResult("digest-ok") as unknown as Parameters[0]); + +test("toExecResult normalizes a successful envelope", () => { + expect(okResult.digest).toBe("digest-ok"); + expect(okResult.gasUsed).toBe(13); +}); + +test("toExecResult throws on a reverted transaction", () => { + expect(() => + toExecResult(failedResult("digest-fail") as unknown as Parameters[0]), + ).toThrow(/reverted/); +}); + +test("publishedPackageId finds the newly-published package", () => { + const r: ExecResult = { + digest: "d", + changedObjects: [{ objectId: "0xpkg", idOperation: "Created", outputState: "PackageWrite" } as never], + objectTypes: {}, + balanceChanges: [], + gasUsed: 0, + }; + expect(publishedPackageId(r)).toBe("0xpkg"); +}); + +test("createdByType and createdByExactType find created objects by their type", () => { + expect(createdByType(okResult, "mod::Thing")).toBe("0xcreated"); + expect(createdByExactType(okResult, "0x1::mod::Thing")).toBe("0xcreated"); + expect(() => createdByType(okResult, "nope")).toThrow(); +}); + +test("allCreatedByType and balanceDelta read the changed-object and balance maps", () => { + expect(allCreatedByType(okResult, "Thing")).toEqual([{ objectId: "0xcreated", objectType: "0x1::mod::Thing" }]); + expect(balanceDelta(okResult, "0xowner", "0x2::sui::SUI")).toBe("-15"); + expect(balanceDelta(okResult, "0xother", "0x2::sui::SUI")).toBe("0"); +}); diff --git a/packages/effect/tests/reads.test.ts b/packages/effect/tests/reads.test.ts new file mode 100644 index 0000000..029301e --- /dev/null +++ b/packages/effect/tests/reads.test.ts @@ -0,0 +1,209 @@ +// Copyright (c) Miso Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "bun:test"; +import { Effect, Option, Schema, Stream } from "effect"; +import type { ClientWithCoreApi, SuiClientTypes } from "@mysten/sui/client"; + +import { + assertObjectType, + decodeBcs, + getObjectContent, + getObjectsContent, + getOptionalObjectContent, + listDynamicFields, +} from "../src/reads.ts"; +import { BcsDecodeError, ObjectNotFoundError, ObjectTypeMismatchError, SuiRpcError } from "../src/errors.ts"; +import { SuiClient } from "../src/sui-client.ts"; + +/** Builds a fake `ClientWithCoreApi` exposing only the `core` methods a test needs. */ +function fakeClient(core: Record): ClientWithCoreApi { + return { core } as unknown as ClientWithCoreApi; +} + +/** Runs a `SuiClient`-requiring program against a fake client. */ +function run(effect: Effect.Effect, client: ClientWithCoreApi): Promise { + return Effect.runPromise(effect.pipe(Effect.provide(SuiClient.layer(client)))); +} + +function entry(fieldId: string): SuiClientTypes.DynamicFieldEntry { + return { + $kind: "DynamicField", + fieldId, + type: `0x1::field::Field<${fieldId}>`, + name: { type: "0x1::field::Key", bcs: new Uint8Array() }, + valueType: "0x1::field::Value", + }; +} + +// ── getObjectContent ───────────────────────────────────────────────────────── + +test("getObjectContent returns content, type, and version on success", async () => { + const client = fakeClient({ + getObject: async ({ objectId }: { objectId: string }) => ({ + object: { objectId, type: "0x1::foo::Bar", version: "3", content: new Uint8Array([1, 2, 3]) }, + }), + }); + + const result = await run(getObjectContent("0xabc"), client); + + expect(result).toEqual({ content: new Uint8Array([1, 2, 3]), type: "0x1::foo::Bar", version: "3" }); +}); + +test("getObjectContent fails with ObjectNotFoundError when the Core API reports the object missing", async () => { + const client = fakeClient({ + getObject: async () => { + throw new Error("Object 0xabc does not exist"); + }, + }); + + const err = await run(getObjectContent("0xabc").pipe(Effect.flip), client); + + expect(err).toBeInstanceOf(ObjectNotFoundError); + expect(err).toMatchObject({ _tag: "ObjectNotFoundError", objectId: "0xabc" }); +}); + +test("getObjectContent fails with SuiRpcError for a transport/protocol rejection", async () => { + const client = fakeClient({ + getObject: async () => { + throw new Error("Method not found"); + }, + }); + + const err = await run(getObjectContent("0xabc").pipe(Effect.flip), client); + + expect(err).toBeInstanceOf(SuiRpcError); + expect(err).toMatchObject({ _tag: "SuiRpcError", operation: "getObject" }); +}); + +// ── getOptionalObjectContent ───────────────────────────────────────────────── + +test("getOptionalObjectContent resolves to None when the object is missing", async () => { + const client = fakeClient({ + getObject: async () => { + throw Object.assign(new Error("boom"), { code: "notExists" }); + }, + }); + + const result = await run(getOptionalObjectContent("0xabc"), client); + + expect(Option.isNone(result)).toBe(true); +}); + +test("getOptionalObjectContent resolves to Some when the object exists", async () => { + const client = fakeClient({ + getObject: async ({ objectId }: { objectId: string }) => ({ + object: { objectId, type: "0x1::foo::Bar", version: "1", content: new Uint8Array([9]) }, + }), + }); + + const result = await run(getOptionalObjectContent("0xabc"), client); + + expect(Option.isSome(result)).toBe(true); + expect(Option.getOrThrow(result).type).toBe("0x1::foo::Bar"); +}); + +// ── getObjectsContent ──────────────────────────────────────────────────────── + +test("getObjectsContent omits ids that errored or have no content", async () => { + const client = fakeClient({ + getObjects: async ({ objectIds }: { objectIds: string[] }) => ({ + objects: [ + { objectId: objectIds[0], type: "T1", content: new Uint8Array([1]) }, + new Error("missing"), + { objectId: objectIds[2], type: "T3", content: undefined }, + ], + }), + }); + + const result = await run(getObjectsContent(["0x1", "0x2", "0x3"]), client); + + expect(result.size).toBe(1); + expect(result.get("0x1")).toEqual({ content: new Uint8Array([1]), type: "T1" }); +}); + +test("getObjectsContent short-circuits with an empty map for an empty id list", async () => { + const client = fakeClient({ + getObjects: async () => { + throw new Error("should not be called"); + }, + }); + + const result = await run(getObjectsContent([]), client); + + expect(result.size).toBe(0); +}); + +// ── listDynamicFields ──────────────────────────────────────────────────────── + +test("listDynamicFields pages through the cursor until hasNextPage is false", async () => { + const pages = [ + { dynamicFields: [entry("a")], hasNextPage: true, cursor: "c1" }, + { dynamicFields: [entry("b")], hasNextPage: true, cursor: "c2" }, + { dynamicFields: [entry("c")], hasNextPage: false, cursor: null }, + ]; + let call = 0; + const seenCursors: (string | null)[] = []; + const client = fakeClient({ + listDynamicFields: async ({ cursor }: { cursor: string | null }) => { + seenCursors.push(cursor); + return pages[call++]!; + }, + }); + + const result = await Effect.runPromise( + Stream.runCollect(listDynamicFields("0xparent")).pipe(Effect.provide(SuiClient.layer(client))), + ); + + expect(result.map((f) => f.fieldId)).toEqual(["a", "b", "c"]); + expect(seenCursors).toEqual([null, "c1", "c2"]); +}); + +// ── decodeBcs ──────────────────────────────────────────────────────────────── + +class Thing extends Schema.Class("Thing")({ id: Schema.Number }) {} + +test("decodeBcs runs the codec then decodes into the schema type", async () => { + const codec = { parse: (bytes: Uint8Array) => ({ id: bytes[0] }) }; + + const result = await Effect.runPromise(decodeBcs(codec, Thing, new Uint8Array([7]), { type: "Thing" })); + + expect(result).toEqual(new Thing({ id: 7 })); +}); + +test("decodeBcs fails with BcsDecodeError when the codec's parse throws", async () => { + const codec = { + parse: () => { + throw new Error("bad bytes"); + }, + }; + + const err = await Effect.runPromise( + decodeBcs(codec, Thing, new Uint8Array(), { type: "Thing", objectId: "0x1" }).pipe(Effect.flip), + ); + + expect(err).toBeInstanceOf(BcsDecodeError); + expect(err).toMatchObject({ _tag: "BcsDecodeError", type: "Thing", objectId: "0x1" }); +}); + +test("decodeBcs fails with BcsDecodeError when the parsed value fails schema validation", async () => { + const codec = { parse: () => ({ id: "not-a-number" }) }; + + const err = await Effect.runPromise(decodeBcs(codec, Thing, new Uint8Array(), { type: "Thing" }).pipe(Effect.flip)); + + expect(err).toBeInstanceOf(BcsDecodeError); + expect(err._tag).toBe("BcsDecodeError"); +}); + +// ── assertObjectType ───────────────────────────────────────────────────────── + +test("assertObjectType fails with ObjectTypeMismatchError on a mismatch", async () => { + const err = await Effect.runPromise(assertObjectType("0x1", "TypeA", "TypeB").pipe(Effect.flip)); + + expect(err).toBeInstanceOf(ObjectTypeMismatchError); + expect(err).toMatchObject({ objectId: "0x1", actual: "TypeA", expected: "TypeB" }); +}); + +test("assertObjectType succeeds when the actual type matches", async () => { + await Effect.runPromise(assertObjectType("0x1", "TypeA", "TypeA")); +}); diff --git a/packages/effect/tsconfig.build.json b/packages/effect/tsconfig.build.json new file mode 100644 index 0000000..306e9dd --- /dev/null +++ b/packages/effect/tsconfig.build.json @@ -0,0 +1,29 @@ +// Build config — emits the published artifact to `dist/`. +// +// The source uses `./foo.ts` relative imports, which Bun runs natively but which +// no other consumer can: Node refuses to type-strip anything under +// `node_modules`, and `tsc` rejects `.ts` import specifiers unless the CONSUMER +// opts into `allowImportingTsExtensions` (which in turn forbids them from +// emitting, so any consumer that is itself a library could not build). +// +// `rewriteRelativeImportExtensions` rewrites those specifiers to `./foo.js` on +// emit, so the shipped artifact is plain ESM + declarations with no demands on +// the consumer's tsconfig — and the source needs no edits. +{ + "extends": "./tsconfig.json", + "include": ["src"], + "exclude": ["tests"], + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "rewriteRelativeImportExtensions": true, + "allowImportingTsExtensions": true, + "lib": ["ESNext"], + "rootDir": "src", + "outDir": "dist" + } +} diff --git a/packages/effect/tsconfig.json b/packages/effect/tsconfig.json new file mode 100644 index 0000000..564a599 --- /dev/null +++ b/packages/effect/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/packages/effect/typedoc.json b/packages/effect/typedoc.json new file mode 100644 index 0000000..5ccf757 --- /dev/null +++ b/packages/effect/typedoc.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": [ + "src/index.ts" + ], + "out": "docs", + "name": "@misofm/effect", + "readme": "README.md", + "excludePrivate": true, + "excludeProtected": true, + "excludeInternal": true, + "categorizeByGroup": true, + "sort": [ + "source-order" + ], + "plugin": [ + "typedoc-plugin-markdown" + ], + "outputFileStrategy": "modules" +} From 63375a05d75629afc11cb0b26f93c4128aef67a8 Mon Sep 17 00:00:00 2001 From: BL Date: Tue, 8 Sep 2026 17:01:50 +0000 Subject: [PATCH 2/5] feat!(musicos,partyos,streaming,transcoding): Effect v4 in the public API Every read that performs I/O now returns an Effect that requires the shared SuiClient service from @misofm/effect and fails with the tagged error vocabulary (ObjectNotFoundError, ObjectTypeMismatchError, SuiRpcError, BcsDecodeError); the client extensions keep their names and provide the service internally so their methods have no requirements. Domain types are Schema classes decoded from the generated BCS output, so no generated shape crosses the public boundary. PTB builders stay synchronous. - musicos 0.3.0: queries, view, and the miso() client; execute re-exports the foundation; optional extension reads return Option. - partyos 0.3.0: queries built on the foundation reads and the dynamic-field Stream; Party is a Schema class. - streaming 0.3.0: warmTrack is an Effect with interruption-driven abort and the same per-item tolerance; acquirePlayer is a Scope-managed player; play() stays a synchronous gesture-safe call. - transcoding 0.4.0: ./errors subpath and error-handling docs; already Effect-based. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QGmuA3eMr4iaRTdz3J8JZJ --- bun.lock | 24 +- packages/musicos/README.md | 103 ++- packages/musicos/package.json | 9 +- packages/musicos/src/client.ts | 175 ++--- packages/musicos/src/deployments.ts | 15 + packages/musicos/src/errors.ts | 19 + packages/musicos/src/events.ts | 2 +- packages/musicos/src/execute.ts | 138 +--- packages/musicos/src/internal.ts | 40 +- packages/musicos/src/queries.ts | 809 ++++++++++----------- packages/musicos/src/transactions.ts | 3 +- packages/musicos/src/types.ts | 107 +-- packages/musicos/src/view.ts | 32 +- packages/musicos/tests/batching.test.ts | 75 +- packages/musicos/tests/queries.test.ts | 256 +++---- packages/musicos/tests/view.test.ts | 36 +- packages/partyos/README.md | 70 +- packages/partyos/package.json | 9 +- packages/partyos/src/client.ts | 40 +- packages/partyos/src/deployments.ts | 16 + packages/partyos/src/errors.ts | 18 + packages/partyos/src/index.ts | 1 + packages/partyos/src/internal.ts | 6 +- packages/partyos/src/queries.ts | 159 ++-- packages/partyos/src/types.ts | 23 +- packages/partyos/tests/deployments.test.ts | 18 +- packages/partyos/tests/queries.test.ts | 198 ++++- packages/streaming/README.md | 43 +- packages/streaming/package.json | 8 +- packages/streaming/src/errors.ts | 39 + packages/streaming/src/player.ts | 109 ++- packages/streaming/src/warm.ts | 66 +- packages/streaming/tests/player.test.ts | 86 ++- packages/streaming/tests/warm.test.ts | 43 +- packages/transcoding/README.md | 23 + packages/transcoding/package.json | 7 +- packages/transcoding/src/errors.ts | 13 + 37 files changed, 1678 insertions(+), 1160 deletions(-) create mode 100644 packages/musicos/src/errors.ts create mode 100644 packages/partyos/src/errors.ts create mode 100644 packages/streaming/src/errors.ts diff --git a/bun.lock b/bun.lock index 1767fb3..a226a9f 100644 --- a/bun.lock +++ b/bun.lock @@ -30,37 +30,43 @@ }, "packages/musicos": { "name": "@misofm/musicos", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { + "@misofm/effect": "0.1.0", "@mysten/bcs": "^2.1.1", }, "devDependencies": { "@mysten/sui": "2.29.0", "@types/bun": "^1.4.0", + "effect": "4.0.0-rc.112", "typedoc": "^0.28.20", "typedoc-plugin-markdown": "^4.13.0", "typescript": "^7.0.2", }, "peerDependencies": { "@mysten/sui": "2.29.0", + "effect": "4.0.0-rc.112", "typescript": "^5.7 || ^6 || ^7", }, }, "packages/partyos": { "name": "@misofm/partyos", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { + "@misofm/effect": "0.1.0", "@mysten/bcs": "^2.1.1", }, "devDependencies": { "@mysten/sui": "2.29.0", "@types/bun": "^1.4.0", + "effect": "4.0.0-rc.112", "typedoc": "^0.28.20", "typedoc-plugin-markdown": "^4.13.0", "typescript": "^7.0.2", }, "peerDependencies": { "@mysten/sui": "2.29.0", + "effect": "4.0.0-rc.112", "typescript": "^5.7 || ^6 || ^7", }, }, @@ -87,13 +93,15 @@ }, "packages/streaming": { "name": "@misofm/streaming", - "version": "0.2.1", + "version": "0.3.0", "devDependencies": { "@types/bun": "^1.4.0", + "effect": "4.0.0-rc.112", "hls.js": "1.6.13", "typescript": "^7.0.2", }, "peerDependencies": { + "effect": "4.0.0-rc.112", "hls.js": "^1.6", }, "optionalPeers": [ @@ -102,7 +110,7 @@ }, "packages/transcoding": { "name": "@misofm/transcoding", - "version": "0.3.1", + "version": "0.4.0", "dependencies": { "@misofm/streaming": "0.2.1", "m3u8-parser": "7.2.0", @@ -370,6 +378,14 @@ "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "@misofm/platform/@misofm/musicos": ["@misofm/musicos@0.2.0", "", { "dependencies": { "@mysten/bcs": "^2.1.1" }, "peerDependencies": { "@mysten/sui": "2.29.0", "typescript": "^5.7 || ^6 || ^7" } }, "sha512-FQmEmU+96u/YjeLxBK9NxOKkUoQS/OkjWsBTHOb/dCr/LIIlf6SpJwm9z2L9eQAxvF0RCNi7Mk3Wr8O+yNGKzA=="], + + "@misofm/platform/@misofm/partyos": ["@misofm/partyos@0.2.0", "", { "dependencies": { "@mysten/bcs": "^2.1.1" }, "peerDependencies": { "@mysten/sui": "2.29.0", "typescript": "^5.7 || ^6 || ^7" } }, "sha512-jtRpxmAaWwVZ8ov+fM3UXQ3wf0Q+b1AyMCFFbL4TxJ5AQe/sQgIYJFHWTmlSELVCUGNOjHBn82JuwoPRuyZo6Q=="], + + "@misofm/platform/@misofm/streaming": ["@misofm/streaming@0.2.1", "", { "peerDependencies": { "hls.js": "^1.6" }, "optionalPeers": ["hls.js"] }, "sha512-wz/PEQLb3cUgYFrC98T3P+nHyP5gEoskh01mpG/Fv0J8K0ke9C/9cP3qk+PB39O6rimPgqofQe2tJ9STlhdvPQ=="], + + "@misofm/transcoding/@misofm/streaming": ["@misofm/streaming@0.2.1", "", { "peerDependencies": { "hls.js": "^1.6" }, "optionalPeers": ["hls.js"] }, "sha512-wz/PEQLb3cUgYFrC98T3P+nHyP5gEoskh01mpG/Fv0J8K0ke9C/9cP3qk+PB39O6rimPgqofQe2tJ9STlhdvPQ=="], + "@misofm/transcoding/@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="], "@misofm/transcoding/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/packages/musicos/README.md b/packages/musicos/README.md index 0ab36ae..d977b11 100644 --- a/packages/musicos/README.md +++ b/packages/musicos/README.md @@ -8,17 +8,61 @@ product-specific workflow — is platform, and lives in typed bindings, queries, event decoders, and PTB builders for the `musicos` Move package: Composition, Recording, Release, and Track. +Every read is an [Effect](https://effect.website): nothing performs I/O or can +fail without saying so in its return type. Reads require the `SuiClient` +service from [`@misofm/effect`](../effect/README.md) instead of taking a +client parameter, and failures are typed (`ObjectNotFoundError`, +`BcsDecodeError`, ...) instead of thrown. PTB builders (`transactions.ts`) +stay plain, synchronous functions — they only append commands to a +caller-owned `Transaction`. + ## Usage -Register the `miso()` client extension on any `@mysten/sui` Core-API client: +Register the `miso()` client extension on any `@mysten/sui` Core-API client, +then run its methods as `Effect` programs: ```ts +import { Effect } from "effect"; import { SuiGrpcClient } from "@mysten/sui/grpc"; import { miso } from "@misofm/musicos/client"; const client = new SuiGrpcClient({ network: "testnet" }).$extend(miso()); -const composition = await client.miso.getCompositionById("0x..."); +const program = Effect.gen(function* () { + const composition = yield* client.miso.getCompositionById("0x..."); + const release = yield* client.miso.getReleaseById("0x..."); + return { composition, release }; +}); + +const { composition, release } = await Effect.runPromise(program); +``` + +`client.miso.*` methods already have `R = never` — the client extension +provides `SuiClient` (and `SuiGraphQL`, when `graphqlClient` was passed to +`miso()`) internally from the `ClientWithCoreApi` it was registered on. + +### Using the free functions directly + +`queries.ts`, `view.ts`, and `execute.ts` export the same reads and writes as +free functions that declare `SuiClient` (and `SuiGraphQL`, for the two +type-discovery reads) in their `Effect` requirements. Provide the service +once at your program's boundary: + +```ts +import { Effect } from "effect"; +import { SuiGrpcClient } from "@mysten/sui/grpc"; +import { SuiClient } from "@misofm/effect"; +import { getCompositionById, getReleaseById } from "@misofm/musicos/queries"; + +const client = new SuiGrpcClient({ network: "testnet" }); + +const program = Effect.gen(function* () { + const composition = yield* getCompositionById("0x..."); + const release = yield* getReleaseById("0x..."); + return { composition, release }; +}).pipe(Effect.provide(SuiClient.layer(client))); + +const { composition, release } = await Effect.runPromise(program); ``` `miso()` resolves the bundled, verified `MISO_DEPLOYMENTS` manifest for the @@ -27,21 +71,52 @@ either the minimal `{ packageId }` shape (core calls, types, and derived IDs only) or a complete `MisoDeployment` (also unlocks `client.miso.packages`, the full generated call/BCS bindings bound to that manifest). +## Errors + +`@misofm/musicos/errors` re-exports the shared `@misofm/effect` error +vocabulary — this package has no failure modes of its own beyond the Sui +read/write primitives every read is built from: + +| Error | When | +| --- | --- | +| `ObjectNotFoundError` | An object id (Composition, Recording, Release, an admin cap, ...) does not exist on-chain. | +| `BcsDecodeError` | The generated codec's `.parse` threw, or the parsed value failed schema validation into the domain type. | +| `SuiRpcError` | A Core API or GraphQL call threw or rejected for a reason other than a missing object. | +| `GraphQLUnavailableError` | `getWorkAddressesByShareTypes`, `getCompositionByShareType`, or `getRecordingByShareType` was called without a `graphqlClient`. | +| `TransactionFailedError` | A submitted transaction executed but its effects reported failure. | +| `DeploymentError` | `validateMisoDeployment` was given a manifest that fails validation. | + +Recover from a specific error with `Effect.catchTag` instead of an +`isNotFound(error)`-style predicate: + +```ts +import { Effect } from "effect"; + +const program = client.miso.getCompositionById(id).pipe( + Effect.catchTag("ObjectNotFoundError", (error) => + Effect.logWarning(`missing composition: ${error.objectId}`).pipe(Effect.as(null)), + ), +); + +const composition = await Effect.runPromise(program); +``` + ## Exports | Subpath | Purpose | -| --------------------------- | ------------------------------------------------------- | +| --------------------------- | --------------------------------------------------------- | | `@misofm/musicos` | Root entrypoint | | `@misofm/musicos/client` | Client construction (`miso()`, `MisoProtocolClient`) | | `@misofm/musicos/deployments` | Deployed package IDs by network | -| `@misofm/musicos/queries` | Read queries over Composition, Recording, Release, Track | -| `@misofm/musicos/transactions` | PTB command builders | -| `@misofm/musicos/execute` | Transaction execution helpers | -| `@misofm/musicos/view` | `simulateTransaction`-backed view helpers | -| `@misofm/musicos/types` | Shared TypeScript types | -| `@misofm/musicos/parsers` | Event parsers | -| `@misofm/musicos/events` | Event decoders | +| `@misofm/musicos/queries` | Read queries over Composition, Recording, Release, Track (`Effect`, requires `SuiClient`) | +| `@misofm/musicos/transactions` | PTB command builders (synchronous) | +| `@misofm/musicos/execute` | Transaction execution helpers (re-exported from `@misofm/effect/execute`) | +| `@misofm/musicos/view` | `simulateTransaction`-backed view helpers (`Effect`, requires `SuiClient`) | +| `@misofm/musicos/types` | Shared domain types (`Schema.Class`) | +| `@misofm/musicos/parsers` | Event parsers (pure) | +| `@misofm/musicos/events` | Event decoders (pure) | | `@misofm/musicos/packages` | Module→package bindings | +| `@misofm/musicos/errors` | Tagged errors (re-exported from `@misofm/effect/errors`) | | `@misofm/musicos/contracts` and `@misofm/musicos/contracts/*` | Generated ABI-bound bindings (BCS structs + Move calls) | ## Deployment manifest @@ -51,7 +126,9 @@ package address. `MISO_DEPLOYMENTS` bundles the verified immutable manifest per network (currently `testnet`); `getMisoDeployment(network)` resolves it, failing closed for networks without a bundled manifest. Pass an explicit manifest to `miso({ deployment })` to override it, e.g. for a local or -freshly-published deployment. +freshly-published deployment. `validateMisoDeployment(input)` is the +`Effect`-returning form of `assertMisoDeployment`/`normalizeMisoDeployment`, +for callers already composing `Effect` programs at a configuration boundary. ## Everything else is platform @@ -67,10 +144,10 @@ lands in. ## Install ```sh -bun add @misofm/musicos +bun add @misofm/musicos @misofm/effect effect ``` -Peer dependency: `@mysten/sui@2.29.0` (exact). +Peer dependencies: `@mysten/sui@2.29.0` and `effect@4.0.0-rc.112` (both exact). ## License diff --git a/packages/musicos/package.json b/packages/musicos/package.json index 8a76194..4ce6f04 100644 --- a/packages/musicos/package.json +++ b/packages/musicos/package.json @@ -1,6 +1,6 @@ { "name": "@misofm/musicos", - "version": "0.2.0", + "version": "0.3.0", "packageManager": "bun@1.4.0", "description": "Typed bindings, queries, event decoders, and PTB builders for the Miso object model (Composition, Recording, Release, Track) on Sui.", "license": "Apache-2.0", @@ -92,6 +92,10 @@ "types": "./dist/contracts/*.d.ts", "default": "./dist/contracts/*.js" }, + "./errors": { + "types": "./dist/errors.d.ts", + "default": "./dist/errors.js" + }, "./package.json": "./package.json" }, "files": [ @@ -102,17 +106,20 @@ "package.json" ], "dependencies": { + "@misofm/effect": "0.1.0", "@mysten/bcs": "^2.1.1" }, "devDependencies": { "@mysten/sui": "2.29.0", "@types/bun": "^1.4.0", + "effect": "4.0.0-rc.112", "typedoc": "^0.28.20", "typedoc-plugin-markdown": "^4.13.0", "typescript": "^7.0.2" }, "peerDependencies": { "@mysten/sui": "2.29.0", + "effect": "4.0.0-rc.112", "typescript": "^5.7 || ^6 || ^7" }, "main": "./dist/index.js" diff --git a/packages/musicos/src/client.ts b/packages/musicos/src/client.ts index 3c59429..d78455a 100644 --- a/packages/musicos/src/client.ts +++ b/packages/musicos/src/client.ts @@ -6,6 +6,15 @@ import type { SuiClientRegistration, } from "@mysten/sui/client"; import type { SuiGraphQLClient } from "@mysten/sui/graphql"; +import { Effect } from "effect"; +import { + GraphQLUnavailableError, + SuiClient, + SuiGraphQL, + type BcsDecodeError, + type ObjectNotFoundError, + type SuiRpcError, +} from "@misofm/effect"; import { getMisoDeployment, normalizeMisoDeployment, @@ -26,6 +35,7 @@ import type { RecordingAdminCap, Release, ReleaseAdminCap, + ReleaseRegistry, } from "./types.ts"; // Generated call modules (type-safe Move calls) and BCS structs. @@ -66,7 +76,7 @@ function isFullDeployment( * ```ts * const client = new SuiGrpcClient({ network: 'testnet' }) * .$extend(miso()); - * const composition = await client.miso.getCompositionById('0x...'); + * const composition = await Effect.runPromise(client.miso.getCompositionById('0x...')); * ``` */ export function miso( @@ -143,92 +153,77 @@ export class MisoProtocolClient { return this.#deployment.packageId; } + /** Provides `SuiClient` from this client's `ClientWithCoreApi`, so a method's program has `R = never`. */ + #run(effect: Effect.Effect): Effect.Effect { + return effect.pipe(Effect.provide(SuiClient.layer(this.#client))); + } + // === Composition === - async getCompositionById(compositionId: string): Promise { - return queries.getCompositionById(this.#client, compositionId); + getCompositionById(compositionId: string): Effect.Effect { + return this.#run(queries.getCompositionById(compositionId)); } - async getCompositionsByIds( - ids: string[], - ): Promise> { - return queries.getCompositionsByIds(this.#client, ids); + getCompositionsByIds(ids: string[]): Effect.Effect, SuiRpcError | BcsDecodeError> { + return this.#run(queries.getCompositionsByIds(ids)); } - async getWorksByIds(ids: queries.WorkIds): Promise { - return queries.getWorksByIds(this.#client, ids); + getWorksByIds(ids: queries.WorkIds): Effect.Effect { + return this.#run(queries.getWorksByIds(ids)); } - async getWorkAddressesByShareTypes( + getWorkAddressesByShareTypes( shareTypes: queries.WorkShareTypes, - ): Promise { - return queries.getWorkAddressesByShareTypes( - this.#requireGraphQL(), - shareTypes, - this.#misoPackageId, - ); + ): Effect.Effect { + if (!this.#graphqlClient) return Effect.fail(new GraphQLUnavailableError()); + return queries + .getWorkAddressesByShareTypes(shareTypes, this.#misoPackageId) + .pipe(Effect.provide(SuiGraphQL.layer(this.#graphqlClient))); } - async getCompositionShareType(compositionId: string): Promise { - return queries.getCompositionShareType(this.#client, compositionId); + getCompositionShareType(compositionId: string): Effect.Effect { + return this.#run(queries.getCompositionShareType(compositionId)); } - async getCompositionByShareType(shareType: string): Promise { - return queries.getCompositionByShareType( - this.#client, - this.#requireGraphQL(), - shareType, - this.#misoPackageId, + getCompositionByShareType( + shareType: string, + ): Effect.Effect { + if (!this.#graphqlClient) return Effect.fail(new GraphQLUnavailableError()); + return queries.getCompositionByShareType(shareType, this.#misoPackageId).pipe( + Effect.provide(SuiGraphQL.layer(this.#graphqlClient)), + Effect.provide(SuiClient.layer(this.#client)), ); } - async getCompositionAdminCapById( - adminCapId: string, - ): Promise { - return queries.getCompositionAdminCapById(this.#client, adminCapId); - } - async getOwnedCompositionAdminCaps( - owner: string, - ): Promise { - return queries.getOwnedCompositionAdminCaps( - this.#client, - owner, - this.#misoPackageId, - ); + getCompositionAdminCapById(adminCapId: string): Effect.Effect { + return this.#run(queries.getCompositionAdminCapById(adminCapId)); + } + getOwnedCompositionAdminCaps(owner: string): Effect.Effect { + return this.#run(queries.getOwnedCompositionAdminCaps(owner, this.#misoPackageId)); } deriveCompositionAdminCapId(compositionId: string): string { - return queries.deriveCompositionAdminCapId( - compositionId, - this.#misoPackageId, - ); + return queries.deriveCompositionAdminCapId(compositionId, this.#misoPackageId); } // === Recording === - async getRecordingById(recordingId: string): Promise { - return queries.getRecordingById(this.#client, recordingId); + getRecordingById(recordingId: string): Effect.Effect { + return this.#run(queries.getRecordingById(recordingId)); } - async getRecordingsByIds(ids: string[]): Promise> { - return queries.getRecordingsByIds(this.#client, ids); + getRecordingsByIds(ids: string[]): Effect.Effect, SuiRpcError | BcsDecodeError> { + return this.#run(queries.getRecordingsByIds(ids)); } - async getRecordingShareType(recordingId: string): Promise { - return queries.getRecordingShareType(this.#client, recordingId); + getRecordingShareType(recordingId: string): Effect.Effect { + return this.#run(queries.getRecordingShareType(recordingId)); } - async getRecordingByShareType(shareType: string): Promise { - return queries.getRecordingByShareType( - this.#client, - this.#requireGraphQL(), - shareType, - this.#misoPackageId, + getRecordingByShareType( + shareType: string, + ): Effect.Effect { + if (!this.#graphqlClient) return Effect.fail(new GraphQLUnavailableError()); + return queries.getRecordingByShareType(shareType, this.#misoPackageId).pipe( + Effect.provide(SuiGraphQL.layer(this.#graphqlClient)), + Effect.provide(SuiClient.layer(this.#client)), ); } - async getRecordingAdminCapById( - adminCapId: string, - ): Promise { - return queries.getRecordingAdminCapById(this.#client, adminCapId); - } - async getOwnedRecordingAdminCaps( - owner: string, - ): Promise { - return queries.getOwnedRecordingAdminCaps( - this.#client, - owner, - this.#misoPackageId, - ); + getRecordingAdminCapById(adminCapId: string): Effect.Effect { + return this.#run(queries.getRecordingAdminCapById(adminCapId)); + } + getOwnedRecordingAdminCaps(owner: string): Effect.Effect { + return this.#run(queries.getOwnedRecordingAdminCaps(owner, this.#misoPackageId)); } deriveRecordingAdminCapId(recordingId: string): string { return queries.deriveRecordingAdminCapId(recordingId, this.#misoPackageId); @@ -236,54 +231,47 @@ export class MisoProtocolClient { // === Release === - async getReleaseById(releaseId: string): Promise { - return queries.getReleaseById(this.#client, releaseId); + getReleaseById(releaseId: string): Effect.Effect { + return this.#run(queries.getReleaseById(releaseId)); } /** Read the shared canonical core `miso::release::ReleaseRegistry`. */ - async getReleaseRegistryById(registryId: string) { - return queries.getReleaseRegistryById(this.#client, registryId); + getReleaseRegistryById(registryId: string): Effect.Effect { + return this.#run(queries.getReleaseRegistryById(registryId)); } - async getReleasesByIds(ids: string[]): Promise> { - return queries.getReleasesByIds(this.#client, ids); + getReleasesByIds(ids: string[]): Effect.Effect, SuiRpcError | BcsDecodeError> { + return this.#run(queries.getReleasesByIds(ids)); } - async getReleaseAdminCapById(adminCapId: string): Promise { - return queries.getReleaseAdminCapById(this.#client, adminCapId); + getReleaseAdminCapById(adminCapId: string): Effect.Effect { + return this.#run(queries.getReleaseAdminCapById(adminCapId)); } deriveReleaseAdminCapId(releaseId: string): string { return queries.deriveReleaseAdminCapId(releaseId, this.#misoPackageId); } - async getOwnedReleaseAdminCaps(owner: string): Promise { - return queries.getOwnedReleaseAdminCaps( - this.#client, - owner, - this.#misoPackageId, - ); + getOwnedReleaseAdminCaps(owner: string): Effect.Effect { + return this.#run(queries.getOwnedReleaseAdminCaps(owner, this.#misoPackageId)); } + // === Share Currency === - async getShareCurrencyType(shareCurrencyId: string): Promise { - return queries.getShareCurrencyType(this.#client, shareCurrencyId); + getShareCurrencyType(shareCurrencyId: string): Effect.Effect { + return this.#run(queries.getShareCurrencyType(shareCurrencyId)); } /** * The `TreasuryCap` owned by `owner`. Takes the share TYPE — if you * hold only the `Currency` object id, resolve it first with * {@link getShareCurrencyType}. */ - async getShareCurrencyTreasuryCap( - shareType: string, - owner: string, - ): Promise { - return queries.getShareCurrencyTreasuryCap(this.#client, shareType, owner); + getShareCurrencyTreasuryCap(shareType: string, owner: string): Effect.Effect { + return this.#run(queries.getShareCurrencyTreasuryCap(shareType, owner)); } // === Simulate-based reads (view) === get view() { - const client = this.#client; const misoPackageId = this.#misoPackageId; return { deriveTargetReleaseId: (params: view.DeriveTargetReleaseIdParams) => - view.deriveTargetReleaseId(client, misoPackageId, params), + this.#run(view.deriveTargetReleaseId(misoPackageId, params)), }; } @@ -326,15 +314,6 @@ export class MisoProtocolClient { events: eventParsers, }; } - - #requireGraphQL(): SuiGraphQLClient { - if (!this.#graphqlClient) { - throw new Error( - "GraphQL client required. Pass graphqlClient to miso() options.", - ); - } - return this.#graphqlClient; - } } /** @deprecated Prefer the layer-specific `MisoProtocolClient` name. */ diff --git a/packages/musicos/src/deployments.ts b/packages/musicos/src/deployments.ts index 6372e99..046070b 100644 --- a/packages/musicos/src/deployments.ts +++ b/packages/musicos/src/deployments.ts @@ -1,7 +1,9 @@ // Copyright (c) Miso Labs, Inc. // SPDX-License-Identifier: Apache-2.0 +import { Effect } from "effect"; import { normalizeSuiAddress } from "@mysten/sui/utils"; +import { DeploymentError } from "@misofm/effect/errors"; /** Sui networks for which this SDK may bundle a verified deployment. */ export type MisoNetwork = "mainnet" | "testnet"; @@ -164,3 +166,16 @@ export function normalizeMisoDeployment(deployment: unknown): MisoDeployment { Object.fromEntries(CANONICAL_MISO_PACKAGE_NAMES.map((name) => [name, deployment[name]])), ) as MisoDeployment; } + +/** + * Effect-returning form of {@link assertMisoDeployment} + {@link normalizeMisoDeployment}, for + * configuration boundaries that are already composing `Effect` programs (e.g. loading a + * deployment manifest from a config service). Synchronous and pure — validation is not I/O — so + * both this and the throwing form are fine; use whichever fits the call site. + */ +export function validateMisoDeployment(deployment: unknown): Effect.Effect { + return Effect.try({ + try: () => normalizeMisoDeployment(deployment), + catch: (cause) => new DeploymentError({ message: cause instanceof Error ? cause.message : String(cause) }), + }); +} diff --git a/packages/musicos/src/errors.ts b/packages/musicos/src/errors.ts new file mode 100644 index 0000000..0aa6864 --- /dev/null +++ b/packages/musicos/src/errors.ts @@ -0,0 +1,19 @@ +// Copyright (c) Miso Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// `@misofm/musicos` has no failure modes of its own beyond the shared Sui +// read/write vocabulary — every query is a Core object read, a GraphQL type +// discovery, or a BCS decode, and every one of those is already covered by +// `@misofm/effect`'s tagged errors. Re-exported here so callers can import the +// complete error vocabulary from one place (`@misofm/musicos/errors`) without +// also depending on `@misofm/effect` directly. +export { + ObjectNotFoundError, + ObjectTypeMismatchError, + SuiRpcError, + BcsDecodeError, + TransactionFailedError, + GraphQLUnavailableError, + DeploymentError, + type SuiReadError, +} from "@misofm/effect/errors"; diff --git a/packages/musicos/src/events.ts b/packages/musicos/src/events.ts index 3dcefe8..ffb6c47 100644 --- a/packages/musicos/src/events.ts +++ b/packages/musicos/src/events.ts @@ -7,8 +7,8 @@ * and nested layouts exactly, which is the safe indexer boundary. */ +import type { BcsParser } from "@misofm/effect"; import * as release from "./contracts/musicos/release.ts"; -import type { BcsParser } from "./queries.ts"; /** Decode raw event BCS with the corresponding generated codec. */ export function decodeEvent(codec: BcsParser, bytes: Uint8Array): T { diff --git a/packages/musicos/src/execute.ts b/packages/musicos/src/execute.ts index f1412a0..482153e 100644 --- a/packages/musicos/src/execute.ts +++ b/packages/musicos/src/execute.ts @@ -2,136 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 // Transaction execution + effect extraction (the Signer-parameter pattern). -// -// Builders elsewhere only *append* to a caller-owned Transaction; this module is -// where a transaction is actually submitted. `signAndExecute` submits over the -// unified Core API, unwraps the `{ $kind: "Transaction", Transaction }` envelope, -// waits for finality, and returns changed objects, the objectId→type map, balance -// changes, and net gas for downstream extraction. Everything here is -// transport-agnostic — it takes any `ClientWithCoreApi`, so gRPC / JSON-RPC / -// GraphQL clients all work. -// - -import { Transaction } from "@mysten/sui/transactions"; -import type { ClientWithCoreApi, SuiClientTypes } from "@mysten/sui/client"; -import type { Signer } from "@mysten/sui/cryptography"; -import type { TxThunk } from "./transactions.ts"; - -/** The effect fields every submit path requests, so extraction is uniform. */ -export const FULL_INCLUDE = { effects: true, objectTypes: true, balanceChanges: true } as const; -type FullInclude = typeof FULL_INCLUDE; - -export interface ExecResult { - digest: string; - /** Objects created/mutated/deleted by the transaction. */ - changedObjects: SuiClientTypes.ChangedObject[]; - /** Map of changed objectId → fully-qualified type. */ - objectTypes: Record; - /** Net coin balance deltas by address. */ - balanceChanges: SuiClientTypes.BalanceChange[]; - /** Net gas cost in MIST (computation + storage − rebate). */ - gasUsed: number; -} - -/** Builds a fresh Transaction from one or more thunks (awaiting async ones). */ -export async function buildTx(...thunks: TxThunk[]): Promise { - const tx = new Transaction(); - for (const thunk of thunks) await thunk(tx); - return tx; -} - -/** Normalizes the `{ $kind }` transaction-result envelope into an {@link ExecResult}. */ -export function toExecResult(res: SuiClientTypes.TransactionResult): ExecResult { - if (res.$kind !== "Transaction") { - const failed = res.FailedTransaction; - const status = failed.effects?.status; - const err = status && !status.success ? JSON.stringify(status.error) : "unknown error"; - throw new Error(`Transaction failed: ${err} (digest ${failed.digest})`); - } - - const t = res.Transaction; - const effects = t.effects as SuiClientTypes.TransactionEffects; - if (!effects.status.success) { - throw new Error(`Transaction reverted: ${JSON.stringify(effects.status.error)} (digest ${t.digest})`); - } - - return { - digest: t.digest, - changedObjects: effects.changedObjects, - objectTypes: (t.objectTypes as Record) ?? {}, - balanceChanges: (t.balanceChanges as SuiClientTypes.BalanceChange[]) ?? [], - gasUsed: netGas(effects.gasUsed), - }; -} - -/** Signs, executes, and waits for a transaction; returns changes, types, gas. */ -export async function signAndExecute(client: ClientWithCoreApi, signer: Signer, tx: Transaction): Promise { - const res = await client.core.signAndExecuteTransaction({ transaction: tx, signer, include: FULL_INCLUDE }); - const result = toExecResult(res); - await client.core.waitForTransaction({ digest: result.digest }); - return result; -} - -/** Convenience: build from thunks, then sign+execute in one call. */ -export async function execThunks(client: ClientWithCoreApi, signer: Signer, ...thunks: TxThunk[]): Promise { - return signAndExecute(client, signer, await buildTx(...thunks)); -} - -// ── Object-change extractors ──────────────────────────────────────────────── - -/** The package id from the (single) newly-published package. */ -export function publishedPackageId(r: ExecResult): string { - const pkg = r.changedObjects.find((c) => c.idOperation === "Created" && c.outputState === "PackageWrite"); - if (!pkg) throw new Error("No published package found in object changes."); - return pkg.objectId; -} - -/** All package ids newly published by the transaction (up to 5 per PTB). */ -export function allPublishedPackageIds(r: ExecResult): string[] { - return r.changedObjects - .filter((c) => c.idOperation === "Created" && c.outputState === "PackageWrite") - .map((c) => c.objectId); -} - -/** The first newly-created object whose type contains `substr`. */ -export function createdByType(r: ExecResult, substr: string): string { - const id = maybeCreatedByType(r, substr); - if (!id) throw new Error(`No created object with type containing "${substr}" found in object changes.`); - return id; -} - -/** Like {@link createdByType} but returns undefined instead of throwing. */ -export function maybeCreatedByType(r: ExecResult, substr: string): string | undefined { - for (const c of r.changedObjects) { - if (c.idOperation === "Created" && (r.objectTypes[c.objectId] ?? "").includes(substr)) return c.objectId; - } - return undefined; -} - -/** The first newly-created object whose type is EXACTLY `type` (for non-generic types). */ -export function createdByExactType(r: ExecResult, type: string): string { - for (const c of r.changedObjects) { - if (c.idOperation === "Created" && r.objectTypes[c.objectId] === type) return c.objectId; - } - throw new Error(`No created object of exact type "${type}" found in object changes.`); -} - -/** All newly-created objects whose type contains `substr`. */ -export function allCreatedByType(r: ExecResult, substr: string): { objectId: string; objectType: string }[] { - const out: { objectId: string; objectType: string }[] = []; - for (const c of r.changedObjects) { - const type = r.objectTypes[c.objectId] ?? ""; - if (c.idOperation === "Created" && type.includes(substr)) out.push({ objectId: c.objectId, objectType: type }); - } - return out; -} - -/** The signed balance delta for `address` in `coinType`, or "0" if absent. */ -export function balanceDelta(r: ExecResult, address: string, coinType: string): string { - const change = r.balanceChanges.find((b) => b.address === address && b.coinType === coinType); - return change?.amount ?? "0"; -} - -function netGas(gasUsed: SuiClientTypes.GasCostSummary): number { - return Number(gasUsed.computationCost) + Number(gasUsed.storageCost) - Number(gasUsed.storageRebate); -} +// This module is a thin re-export of `@misofm/effect/execute` so the +// `@misofm/musicos/execute` subpath keeps resolving for existing callers — +// the implementation is transport-agnostic and shared across every `@misofm/*` +// SDK, so there is exactly one copy of it. +export * from "@misofm/effect/execute"; diff --git a/packages/musicos/src/internal.ts b/packages/musicos/src/internal.ts index fd0091a..606fe34 100644 --- a/packages/musicos/src/internal.ts +++ b/packages/musicos/src/internal.ts @@ -6,20 +6,14 @@ // Mappers from the generated BCS-parse output (snake_case, with @mysten/bcs // conventions: enums as `{ $kind, [Variant]: payload }`, tuples as arrays, // VecMap as `{ contents: [{ key, value }] }`, u64/u256 as strings, Address as -// hex) into the public camelCase domain types in `./types`. These are the -// single boundary between codegen output and the public API, so when the -// generated shapes change, type errors surface here. +// hex) into the camelCase constructor-input shape of the public `Schema.Class` +// domain types in `./types`. These mappers return plain objects, not schema +// instances — `queries.ts` runs the mapped output through `decodeBcs` (which +// calls `Schema.decodeUnknownEffect`) to validate and construct the actual +// class instance. This is the single boundary between codegen output and the +// public API, so when the generated shapes change, type errors surface here. // -import type { - BPS, - Composition, - Recording, - Release as ReleaseType, - Track, - TrackState, -} from "./types.ts"; - // === Mappers === /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -28,7 +22,7 @@ type Parsed = any; // === Primitives === /** `BPS` is a Move tuple struct `(u16)`, parsed as `[number]`. */ -export function mapBps(d: Parsed): BPS { +export function mapBps(d: Parsed): { value: number } { return { value: Number(Array.isArray(d) ? d[0] : d) }; } @@ -42,37 +36,37 @@ export function mapState( // === Objects === -export function mapComposition(id: string, d: Parsed): Composition { +export function mapComposition(id: string, d: Parsed) { return { id, state: mapState(d.state), - title: d.title, + title: d.title as string, royaltyRate: mapBps(d.royalty_rate), }; } -export function mapRecording(id: string, d: Parsed): Recording { +export function mapRecording(id: string, d: Parsed) { return { id, state: mapState(d.state), - compositionId: d.composition_id, + compositionId: d.composition_id as string, }; } -export function mapTrack(d: Parsed): Track { +export function mapTrack(d: Parsed) { return { - state: (d.state?.$kind ?? "Unassigned") as TrackState, - compositionId: d.composition_id, - recordingId: d.recording_id, + state: (d.state?.$kind ?? "Unassigned") as "Unassigned" | "Assigned", + compositionId: d.composition_id as string, + recordingId: d.recording_id as string, splitBps: mapBps(d.split_bps), }; } -export function mapRelease(id: string, d: Parsed): ReleaseType { +export function mapRelease(id: string, d: Parsed) { return { id, state: mapState(d.state), - title: d.title, + title: d.title as string, tracks: (d.tracks ?? []).map(mapTrack), }; } diff --git a/packages/musicos/src/queries.ts b/packages/musicos/src/queries.ts index 2522313..0c3ec52 100644 --- a/packages/musicos/src/queries.ts +++ b/packages/musicos/src/queries.ts @@ -6,22 +6,30 @@ // tracks the on-chain ABI). Generic-type discovery (by share type / by owner) // uses GraphQL to find object addresses, then reads them through the Core path. // -// Missing-object convention (null vs throw): -// - Core-object getters in this module (`getCompositionById`, -// `get*AdminCapById`, …) THROW when the object is missing. Callers pass ids -// they obtained from the chain, so a miss means a broken reference — an -// exceptional state, not a normal one. -// - Extension dynamic-field readers return `null` — extension data is optional -// by design, and "not attached" is a normal, expected state. Use -// `getExtensionField` with the generated BCS codec for the extension. -// All null-returning readers use {@link isNotFound} to distinguish a missing -// object from a transport failure (which still throws). - -import type { ClientWithCoreApi } from "@mysten/sui/client"; -import type { SuiGraphQLClient } from "@mysten/sui/graphql"; +// Every read requires the `SuiClient` service (and `SuiGraphQL` for the +// type-discovery reads) instead of taking a client parameter — see +// `@misofm/effect`. Not-found is a typed `ObjectNotFoundError`; a BCS decode +// failure (including a wrong on-chain type, which fails to parse against the +// expected ABI) is a typed `BcsDecodeError`. Extension dynamic-field readers +// return `Option.none()` for "not attached" — extension data is optional by +// design, and absence is a normal, expected state, not a failure. + +import { Effect, Option, Schema } from "effect"; import { graphql } from "@mysten/sui/graphql/schema"; import { deriveObjectID, normalizeSuiAddress } from "@mysten/sui/utils"; +import { + type BcsParser, + BcsDecodeError, + decodeBcs, + getObjectContent, + getObjectsContent, + ObjectNotFoundError, + SuiClient, + SuiGraphQL, + SuiRpcError, +} from "@misofm/effect"; + import { Composition as CompositionBcs } from "./contracts/musicos/composition.ts"; import { Recording as RecordingBcs } from "./contracts/musicos/recording.ts"; import { @@ -29,20 +37,22 @@ import { ReleaseRegistry as ReleaseRegistryBcs, } from "./contracts/musicos/release.ts"; import { - mapBps, mapComposition, mapRecording, mapRelease, } from "./internal.ts"; -import type { +import { Composition, CompositionAdminCap, Recording, RecordingAdminCap, Release, ReleaseAdminCap, + ReleaseRegistry, } from "./types.ts"; +export type { BcsParser }; + // ============================================================================ // Helpers // ============================================================================ @@ -73,70 +83,86 @@ export function extractTypeParams2(objectType: string): [string, string] { throw new Error(`Expected two type parameters in: ${objectType}`); } +/** Key bytes for Move unit structs (single `0x00` for `dummy_field: bool = false`). */ +const UNIT_STRUCT_KEY_BYTES = new Uint8Array([0x00]); + /** - * True when `e` is a "this object does not exist" error from any of the Sui - * client transports, so null-returning readers can distinguish absence from - * transport failure. Matches, in order of preference: + * True when `e` is a "this object/dynamic field does not exist" error from any + * of the Sui client transports. This mirrors `@misofm/effect`'s internal + * classifier (not exported, since `getObjectContent` already covers the common + * case) — needed here for the reads that don't go through it: type-only object + * reads (no `content`) and dynamic-field reads. Matches, in order of + * preference: * * 1. Structured `ObjectError.code` values thrown by the JSON-RPC core client * (`notExists`, `deleted`, `dynamicFieldNotFound`) and the GraphQL core - * client (`notFound`). The class itself is not exported by `@mysten/sui`, - * so we duck-type on `code`. + * client (`notFound`). * 2. The message shapes those clients (and the gRPC core client, which wraps - * the server's per-object status message in a plain `Error`) produce: - * "Object 0x… does not exist" / "Object 0x… not found" / "Object 0x… has - * been deleted" / "Dynamic field not found for object 0x…" / "No object - * found for id 0x…". - * - * Transport/protocol errors must NOT match: every message pattern requires - * object-ish context ("object" / "dynamic field"), so e.g. a JSON-RPC - * "Method not found" or a gRPC "peer not found" is never treated as a missing - * object and propagates to the caller. + * the server's per-object status message in a plain `Error`) produce. */ -export function isNotFound(e: unknown): boolean { +function isMissingObjectError(e: unknown): boolean { if (typeof e === "object" && e !== null && "code" in e) { const code = (e as { code: unknown }).code; - if ( - code === "notExists" || - code === "deleted" || - code === "dynamicFieldNotFound" || - code === "notFound" - ) { + if (code === "notExists" || code === "deleted" || code === "dynamicFieldNotFound" || code === "notFound") { return true; } } const msg = e instanceof Error ? e.message : String(e); return ( - /\bobject\b[\s\S]*\b(?:not\s?found|does not exist|has been deleted)\b/i.test( - msg, - ) || + /\bobject\b[\s\S]*\b(?:not\s?found|does not exist|has been deleted)\b/i.test(msg) || /\bdynamic field\b[\s\S]*\bnot\s?found\b/i.test(msg) || /\bno object\b/i.test(msg) ); } -/** Key bytes for Move unit structs (single `0x00` for `dummy_field: bool = false`). */ -const UNIT_STRUCT_KEY_BYTES = new Uint8Array([0x00]); - -/** Any generated BCS codec with a `parse` method. */ -export interface BcsParser { - parse(bytes: Uint8Array): T; +/** Classifies a Core API rejection against `objectId`: missing -> `ObjectNotFoundError`, else `SuiRpcError`. */ +function classifyMissing(objectId: string, operation: string, cause: unknown): ObjectNotFoundError | SuiRpcError { + return isMissingObjectError(cause) ? new ObjectNotFoundError({ objectId }) : new SuiRpcError({ operation, cause }); } +/** Fetches just an object's on-chain `type` (no content) through the Core API. */ +const getObjectType = Effect.fn("getObjectType")(function* ( + objectId: string, +): Effect.fn.Return { + const client = yield* SuiClient; + const { object } = yield* Effect.tryPromise({ + try: (signal) => client.core.getObject({ objectId, signal }), + catch: (cause) => classifyMissing(objectId, "getObject", cause), + }); + return object.type; +}); + +/** Fetches one dynamic field's raw BCS value bytes; not-found becomes `ObjectNotFoundError`. */ +const getDynamicFieldValue = Effect.fn("getDynamicFieldValue")(function* ( + parentId: string, + name: { type: string; bcs: Uint8Array }, +): Effect.fn.Return { + const client = yield* SuiClient; + const { dynamicField } = yield* Effect.tryPromise({ + try: (signal) => client.core.getDynamicField({ parentId, name, signal }), + catch: (cause) => classifyMissing(parentId, "getDynamicField", cause), + }); + return dynamicField.value.bcs; +}); + /** Exhaust every Core owned-object page; cap discovery must not truncate. */ -async function listAllOwnedObjects( - client: ClientWithCoreApi, +const listAllOwnedObjects = Effect.fn("listAllOwnedObjects")(function* ( input: Record, -): Promise> { +): Effect.fn.Return, SuiRpcError, SuiClient> { + const client = yield* SuiClient; const objects: Array<{ objectId: string; type?: string; json?: unknown }> = []; let cursor: string | null | undefined; do { - const page = await (client.core.listOwnedObjects as (args: unknown) => Promise<{ - objects: Array<{ objectId: string; type?: string; json?: unknown }>; - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; - hasNextPage?: boolean; - cursor?: string | null; - }>)(cursor ? { ...input, cursor } : input); + const page = yield* Effect.tryPromise({ + try: (signal) => + (client.core.listOwnedObjects as (args: unknown) => Promise<{ + objects: Array<{ objectId: string; type?: string; json?: unknown }>; + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; + hasNextPage?: boolean; + cursor?: string | null; + }>)(cursor ? { ...input, cursor, signal } : { ...input, signal }), + catch: (cause) => new SuiRpcError({ operation: "listOwnedObjects", cause }), + }); objects.push(...page.objects); cursor = page.pageInfo?.hasNextPage ? page.pageInfo.endCursor @@ -145,33 +171,22 @@ async function listAllOwnedObjects( : null; } while (cursor); return objects; -} - -/** Fetches one object's BCS content bytes (or null if absent). */ -async function getContent( - client: ClientWithCoreApi, - objectId: string, -): Promise { - const { object } = await client.core.getObject({ - objectId, - include: { content: true }, - }); - return object.content ?? null; -} +}); /** - * Fetch and parse an object through the transport-neutral Core API. Object - * content is BCS; never pass the full `objectBcs` envelope to a Move codec. + * Fetch and parse an object through the transport-neutral Core API, validating + * the parsed result against a domain `Schema`. Object content is BCS; never + * pass the full `objectBcs` envelope to a Move codec. */ -export async function getObjectByBcs( - client: ClientWithCoreApi, +export const getObjectByBcs = Effect.fn("getObjectByBcs")(function* ( objectId: string, - codec: BcsParser, -): Promise { - const content = await getContent(client, objectId); - if (!content) throw new Error(`Object not found: ${objectId}`); - return codec.parse(content); -} + codec: BcsParser

, + schema: Schema.Codec, + typeName: string, +): Effect.fn.Return { + const { content } = yield* getObjectContent(objectId); + return yield* decodeBcs(codec, schema, content, { type: typeName, objectId }); +}); export interface ExtensionFieldParams { /** Freshly published package address for the extension. */ @@ -185,27 +200,20 @@ export interface ExtensionFieldParams { /** * Read an optional first-party extension field from a core object's UID. Every * current extension uses a fieldless `ExtensionKey`, whose BCS is one false - * boolean byte. Absence returns `null`; transport errors still propagate. + * boolean byte. Absence resolves to `Option.none()`; transport errors still fail. */ -export async function getExtensionField( - client: ClientWithCoreApi, +export const getExtensionField = Effect.fn("getExtensionField")(function* ( parentId: string, params: ExtensionFieldParams, -): Promise { - try { - const { dynamicField } = await client.core.getDynamicField({ - parentId, - name: { - type: `${params.packageId}::${params.module}::ExtensionKey`, - bcs: UNIT_STRUCT_KEY_BYTES, - }, - }); - return params.codec.parse(dynamicField.value.bcs); - } catch (error) { - if (isNotFound(error)) return null; - throw error; - } -} +): Effect.fn.Return, SuiRpcError, SuiClient> { + return yield* getDynamicFieldValue(parentId, { + type: `${params.packageId}::${params.module}::ExtensionKey`, + bcs: UNIT_STRUCT_KEY_BYTES, + }).pipe( + Effect.map((bytes) => Option.some(params.codec.parse(bytes))), + Effect.catchTag("ObjectNotFoundError", () => Effect.succeed(Option.none())), + ); +}); export interface ReleaseDspFieldParams { /** Freshly published `release_dsp_link` package address. */ @@ -216,46 +224,37 @@ export interface ReleaseDspFieldParams { codec: BcsParser; } -async function getReleaseDspField( - client: ClientWithCoreApi, +const getReleaseDspField = Effect.fn("getReleaseDspField")(function* ( releaseId: string, key: "ReleaseLinkKey" | "TrackLinksKey", params: ReleaseDspFieldParams, -): Promise { +): Effect.fn.Return, SuiRpcError, SuiClient> { if (!Number.isInteger(params.platform) || params.platform < 0 || params.platform > 255) { throw new Error("DSP platform must be a u8 discriminator"); } - try { - const { dynamicField } = await client.core.getDynamicField({ - parentId: releaseId, - name: { - type: `${params.packageId}::release_dsp_link::${key}`, - bcs: Uint8Array.of(params.platform), - }, - }); - return params.codec.parse(dynamicField.value.bcs); - } catch (error) { - if (isNotFound(error)) return null; - throw error; - } -} + return yield* getDynamicFieldValue(releaseId, { + type: `${params.packageId}::release_dsp_link::${key}`, + bcs: Uint8Array.of(params.platform), + }).pipe( + Effect.map((bytes) => Option.some(params.codec.parse(bytes))), + Effect.catchTag("ObjectNotFoundError", () => Effect.succeed(Option.none())), + ); +}); /** Read a release-level DSP link stored under `ReleaseLinkKey(platform)`. */ export function getReleaseDspLink( - client: ClientWithCoreApi, releaseId: string, params: ReleaseDspFieldParams, -): Promise { - return getReleaseDspField(client, releaseId, "ReleaseLinkKey", params); +): Effect.Effect, SuiRpcError, SuiClient> { + return getReleaseDspField(releaseId, "ReleaseLinkKey", params); } /** Read the per-track DSP-link array stored under `TrackLinksKey(platform)`. */ export function getTrackDspLinks( - client: ClientWithCoreApi, releaseId: string, params: ReleaseDspFieldParams, -): Promise { - return getReleaseDspField(client, releaseId, "TrackLinksKey", params); +): Effect.Effect, SuiRpcError, SuiClient> { + return getReleaseDspField(releaseId, "TrackLinksKey", params); } // ============================================================================ @@ -263,11 +262,10 @@ export function getTrackDspLinks( // ============================================================================ /** Parse the shared canonical core `miso::release::ReleaseRegistry` by ID. */ -export async function getReleaseRegistryById( - client: ClientWithCoreApi, +export function getReleaseRegistryById( registryId: string, -) { - return getObjectByBcs(client, registryId, ReleaseRegistryBcs); +): Effect.Effect { + return getObjectByBcs(registryId, ReleaseRegistryBcs, ReleaseRegistry, "ReleaseRegistry"); } // ============================================================================ @@ -339,16 +337,17 @@ interface WorkAddressConnection { * exposes the first, so one bare Recording scan is shared by every requested * recording type and filtered client-side. */ -export async function getWorkAddressesByShareTypes( - client: SuiGraphQLClient, +export const getWorkAddressesByShareTypes = Effect.fn("getWorkAddressesByShareTypes")(function* ( shareTypes: WorkShareTypes, misoPackageId: string, -): Promise { +): Effect.fn.Return { const compositions = [...new Set(shareTypes.compositions)]; const recordings = new Set(shareTypes.recordings); const out: WorkAddressesByShareType = { compositions: {}, recordings: {} }; if (compositions.length === 0 && recordings.size === 0) return out; + const client = yield* SuiGraphQL; + const declarations: string[] = []; const selections: string[] = []; const variables: Record = {}; @@ -359,8 +358,7 @@ export async function getWorkAddressesByShareTypes( selections.push( `composition${index}: objects(first: 1, filter: { type: $${variable} }) { nodes { address } }`, ); - variables[variable] = - `${misoPackageId}::composition::Composition<${shareType}>`; + variables[variable] = `${misoPackageId}::composition::Composition<${shareType}>`; }); if (recordings.size > 0) { @@ -372,20 +370,24 @@ export async function getWorkAddressesByShareTypes( variables.recordingType = `${misoPackageId}::recording::Recording`; } - const result = await client.query< - Record, - Record - >({ - query: `query WorkAddressesByShareTypes(${declarations.join(", ")}) { - ${selections.join("\n")} - }`, - variables, + const result = yield* Effect.tryPromise({ + try: () => + client.query, Record>({ + query: `query WorkAddressesByShareTypes(${declarations.join(", ")}) { + ${selections.join("\n")} + }`, + variables, + }), + catch: (cause) => new SuiRpcError({ operation: "workAddressesByShareTypes", cause }), }); if (result.errors?.length) { - throw new AggregateError( - result.errors.map((error) => new Error(error.message)), - "Work type discovery failed", - ); + return yield* new SuiRpcError({ + operation: "workAddressesByShareTypes", + cause: new AggregateError( + result.errors.map((error) => new Error(error.message)), + "Work type discovery failed", + ), + }); } compositions.forEach((shareType, index) => { @@ -393,9 +395,7 @@ export async function getWorkAddressesByShareTypes( if (address) out.compositions[shareType] = address; }); - const readRecordingPage = ( - page: WorkAddressConnection | null | undefined, - ) => { + const readRecordingPage = (page: WorkAddressConnection | null | undefined) => { for (const node of page?.nodes ?? []) { const repr = node.asMoveObject?.contents?.type?.repr; if (!repr) continue; @@ -417,33 +417,35 @@ export async function getWorkAddressesByShareTypes( recordingPage.pageInfo.endCursor && Object.keys(out.recordings).length < recordings.size ) { - const next = await client.query< - { recordings: WorkAddressConnection | null }, - { recordingType: string; cursor: string } - >({ - query: `query RecordingWorkAddresses($recordingType: String!, $cursor: String!) { - recordings: objects(first: 50, after: $cursor, filter: { type: $recordingType }) { - pageInfo { hasNextPage endCursor } - nodes { address asMoveObject { contents { type { repr } } } } - } - }`, - variables: { - recordingType: variables.recordingType!, - cursor: recordingPage.pageInfo.endCursor, - }, + const cursor: string = recordingPage.pageInfo.endCursor; + const next = yield* Effect.tryPromise({ + try: () => + client.query<{ recordings: WorkAddressConnection | null }, { recordingType: string; cursor: string }>({ + query: `query RecordingWorkAddresses($recordingType: String!, $cursor: String!) { + recordings: objects(first: 50, after: $cursor, filter: { type: $recordingType }) { + pageInfo { hasNextPage endCursor } + nodes { address asMoveObject { contents { type { repr } } } } + } + }`, + variables: { recordingType: variables.recordingType!, cursor }, + }), + catch: (cause) => new SuiRpcError({ operation: "recordingWorkAddresses", cause }), }); if (next.errors?.length) { - throw new AggregateError( - next.errors.map((error) => new Error(error.message)), - "Recording type discovery failed", - ); + return yield* new SuiRpcError({ + operation: "recordingWorkAddresses", + cause: new AggregateError( + next.errors.map((error) => new Error(error.message)), + "Recording type discovery failed", + ), + }); } recordingPage = next.data?.recordings; readRecordingPage(recordingPage); } return out; -} +}); export interface WorkIds { compositions: readonly string[]; @@ -458,21 +460,16 @@ export interface WorksById { } /** Fetch and parse heterogeneous work objects through one Core bulk request. */ -export async function getWorksByIds( - client: ClientWithCoreApi, +export const getWorksByIds = Effect.fn("getWorksByIds")(function* ( ids: WorkIds, -): Promise { +): Effect.fn.Return { const kinds = new Map(); - for (const [kind, objectIds] of Object.entries(ids) as Array< - [keyof WorksById, readonly string[]] - >) { + for (const [kind, objectIds] of Object.entries(ids) as Array<[keyof WorksById, readonly string[]]>) { for (const objectId of objectIds) { const normalized = normalizeSuiAddress(objectId); const previous = kinds.get(normalized); if (previous && previous !== kind) { - throw new Error( - `Work ${normalized} was requested as both ${previous} and ${kind}`, - ); + throw new Error(`Work ${normalized} was requested as both ${previous} and ${kind}`); } kinds.set(normalized, kind); } @@ -481,93 +478,95 @@ export async function getWorksByIds( const out: WorksById = { compositions: {}, recordings: {}, releases: {} }; if (kinds.size === 0) return out; - const { objects } = await client.core.getObjects({ - objectIds: [...kinds.keys()], - include: { content: true }, - }); - for (const obj of objects) { - if (obj instanceof Error || !obj.content) continue; - const kind = kinds.get(normalizeSuiAddress(obj.objectId)); - if (kind === "compositions") { - out.compositions[obj.objectId] = mapComposition( - obj.objectId, - CompositionBcs.parse(obj.content), - ); - } else if (kind === "recordings") { - out.recordings[obj.objectId] = mapRecording( - obj.objectId, - RecordingBcs.parse(obj.content), - ); - } else if (kind === "releases") { - out.releases[obj.objectId] = mapRelease( - obj.objectId, - ReleaseBcs.parse(obj.content), - ); - } - } + const contents = yield* getObjectsContent([...kinds.keys()]); + yield* Effect.forEach( + [...contents.entries()], + ([objectId, { content }]) => + Effect.gen(function* () { + const kind = kinds.get(normalizeSuiAddress(objectId)); + if (kind === "compositions") { + out.compositions[objectId] = yield* decodeBcs( + { parse: (bytes) => mapComposition(objectId, CompositionBcs.parse(bytes)) }, + Composition, + content, + { type: "Composition", objectId }, + ); + } else if (kind === "recordings") { + out.recordings[objectId] = yield* decodeBcs( + { parse: (bytes) => mapRecording(objectId, RecordingBcs.parse(bytes)) }, + Recording, + content, + { type: "Recording", objectId }, + ); + } else if (kind === "releases") { + out.releases[objectId] = yield* decodeBcs( + { parse: (bytes) => mapRelease(objectId, ReleaseBcs.parse(bytes)) }, + Release, + content, + { type: "Release", objectId }, + ); + } + }), + { concurrency: "unbounded" }, + ); return out; -} +}); // ============================================================================ // Composition // ============================================================================ /** Fetches multiple compositions by ID in one Core request. */ -export async function getCompositionsByIds( - client: ClientWithCoreApi, +export const getCompositionsByIds = Effect.fn("getCompositionsByIds")(function* ( compositionIds: string[], -): Promise> { +): Effect.fn.Return, SuiRpcError | BcsDecodeError, SuiClient> { if (compositionIds.length === 0) return {}; - const { objects } = await client.core.getObjects({ - objectIds: compositionIds, - include: { content: true }, - }); - const out: Record = {}; - for (const obj of objects) { - if (obj instanceof Error || !obj.content) continue; - out[obj.objectId] = mapComposition( - obj.objectId, - CompositionBcs.parse(obj.content), - ); - } - return out; -} + const contents = yield* getObjectsContent(compositionIds); + const decoded = yield* Effect.forEach( + [...contents.entries()], + ([objectId, { content }]) => + decodeBcs( + { parse: (bytes) => mapComposition(objectId, CompositionBcs.parse(bytes)) }, + Composition, + content, + { type: "Composition", objectId }, + ).pipe(Effect.map((composition) => [objectId, composition] as const)), + { concurrency: "unbounded" }, + ); + return Object.fromEntries(decoded); +}); /** Fetches a composition by its object ID. */ -export async function getCompositionById( - client: ClientWithCoreApi, +export function getCompositionById( compositionId: string, -): Promise { - const content = await getContent(client, compositionId); - if (!content) throw new Error(`Composition not found: ${compositionId}`); - return mapComposition(compositionId, CompositionBcs.parse(content)); +): Effect.Effect { + return getObjectByBcs( + compositionId, + { parse: (bytes) => mapComposition(compositionId, CompositionBcs.parse(bytes)) }, + Composition, + "Composition", + ); } /** Extracts the share type `T` from a `Composition` object. */ -export async function getCompositionShareType( - client: ClientWithCoreApi, +export const getCompositionShareType = Effect.fn("getCompositionShareType")(function* ( compositionId: string, -): Promise { - const { object } = await client.core.getObject({ objectId: compositionId }); - return extractTypeParam(object.type); -} +): Effect.fn.Return { + const type = yield* getObjectType(compositionId); + return extractTypeParam(type); +}); /** Fetches a composition by its share type (GraphQL discovery + Core read). */ -export async function getCompositionByShareType( - client: ClientWithCoreApi, - graphqlClient: SuiGraphQLClient, +export const getCompositionByShareType = Effect.fn("getCompositionByShareType")(function* ( shareType: string, misoPackageId: string, -): Promise { - const address = await getCompositionAddressByShareType( - graphqlClient, - shareType, - misoPackageId, - ); - if (!address) - throw new Error(`Composition not found for share type: ${shareType}`); - return getCompositionById(client, address); -} +): Effect.fn.Return { + const address = yield* getCompositionAddressByShareType(shareType, misoPackageId); + if (Option.isNone(address)) { + return yield* new ObjectNotFoundError({ objectId: `composition::Composition<${shareType}>` }); + } + return yield* getCompositionById(address.value); +}); /** * Resolves a composition share type to its object address. @@ -576,22 +575,20 @@ export async function getCompositionByShareType( * composition's identity but will read extension fields rather than the core * Composition contents. */ -export async function getCompositionAddressByShareType( - graphqlClient: SuiGraphQLClient, +export function getCompositionAddressByShareType( shareType: string, misoPackageId: string, -): Promise { +): Effect.Effect, SuiRpcError, SuiGraphQL> { const type = `${misoPackageId}::composition::Composition<${shareType}>`; - return firstAddressOfType(graphqlClient, type); + return firstAddressOfType(type); } -export async function getCompositionAdminCapById( - client: ClientWithCoreApi, +export const getCompositionAdminCapById = Effect.fn("getCompositionAdminCapById")(function* ( adminCapId: string, -): Promise { - const { object } = await client.core.getObject({ objectId: adminCapId }); - return { id: adminCapId, shareType: extractTypeParam(object.type) }; -} +): Effect.fn.Return { + const type = yield* getObjectType(adminCapId); + return new CompositionAdminCap({ id: adminCapId, shareType: extractTypeParam(type) }); +}); /** * Composition admin caps owned by `owner`. @@ -600,25 +597,21 @@ export async function getCompositionAdminCapById( * object's instantiated `type`, so the share type is read straight off * `CompositionAdminCap` with no second round-trip. */ -export async function getOwnedCompositionAdminCaps( - client: ClientWithCoreApi, +export const getOwnedCompositionAdminCaps = Effect.fn("getOwnedCompositionAdminCaps")(function* ( owner: string, misoPackageId: string, -): Promise { +): Effect.fn.Return { const capType = `${misoPackageId}::composition::CompositionAdminCap`; - const objects = await listAllOwnedObjects(client, { owner, type: capType }); + const objects = yield* listAllOwnedObjects({ owner, type: capType }); const caps: CompositionAdminCap[] = []; for (const obj of objects) { const match = obj.type?.match(/<(.+)>$/); - if (match?.[1]) caps.push({ id: obj.objectId, shareType: match[1] }); + if (match?.[1]) caps.push(new CompositionAdminCap({ id: obj.objectId, shareType: match[1] })); } return caps; -} +}); -export function deriveCompositionAdminCapId( - compositionId: string, - misoPackageId: string, -): string { +export function deriveCompositionAdminCapId(compositionId: string, misoPackageId: string): string { return deriveObjectID( compositionId, `${misoPackageId}::composition::CompositionAdminCapKey`, @@ -630,33 +623,34 @@ export function deriveCompositionAdminCapId( // Recording // ============================================================================ -export async function getRecordingsByIds( - client: ClientWithCoreApi, +export const getRecordingsByIds = Effect.fn("getRecordingsByIds")(function* ( recordingIds: string[], -): Promise> { +): Effect.fn.Return, SuiRpcError | BcsDecodeError, SuiClient> { if (recordingIds.length === 0) return {}; - const { objects } = await client.core.getObjects({ - objectIds: recordingIds, - include: { content: true }, - }); - const out: Record = {}; - for (const obj of objects) { - if (obj instanceof Error || !obj.content) continue; - out[obj.objectId] = mapRecording( - obj.objectId, - RecordingBcs.parse(obj.content), - ); - } - return out; -} + const contents = yield* getObjectsContent(recordingIds); + const decoded = yield* Effect.forEach( + [...contents.entries()], + ([objectId, { content }]) => + decodeBcs( + { parse: (bytes) => mapRecording(objectId, RecordingBcs.parse(bytes)) }, + Recording, + content, + { type: "Recording", objectId }, + ).pipe(Effect.map((recording) => [objectId, recording] as const)), + { concurrency: "unbounded" }, + ); + return Object.fromEntries(decoded); +}); -export async function getRecordingById( - client: ClientWithCoreApi, +export function getRecordingById( recordingId: string, -): Promise { - const content = await getContent(client, recordingId); - if (!content) throw new Error(`Recording not found: ${recordingId}`); - return mapRecording(recordingId, RecordingBcs.parse(content)); +): Effect.Effect { + return getObjectByBcs( + recordingId, + { parse: (bytes) => mapRecording(recordingId, RecordingBcs.parse(bytes)) }, + Recording, + "Recording", + ); } /** @@ -665,16 +659,12 @@ export async function getRecordingById( * them and returns the first; use {@link getRecordingShareTypes} when the * parent composition's share type is needed too. */ -export async function getRecordingShareType( - client: ClientWithCoreApi, +export const getRecordingShareType = Effect.fn("getRecordingShareType")(function* ( recordingId: string, -): Promise { - const [recordingShareType] = await getRecordingShareTypes( - client, - recordingId, - ); +): Effect.fn.Return { + const [recordingShareType] = yield* getRecordingShareTypes(recordingId); return recordingShareType; -} +}); /** * Both of a recording's share types, as `[RecordingShare, CompositionShare]`. @@ -682,37 +672,30 @@ export async function getRecordingShareType( * and the recording credit/pool extensions are all generic over both, in this * order. */ -export async function getRecordingShareTypes( - client: ClientWithCoreApi, +export const getRecordingShareTypes = Effect.fn("getRecordingShareTypes")(function* ( recordingId: string, -): Promise<[string, string]> { - const { object } = await client.core.getObject({ objectId: recordingId }); - return extractTypeParams2(object.type); -} +): Effect.fn.Return<[string, string], ObjectNotFoundError | SuiRpcError, SuiClient> { + const type = yield* getObjectType(recordingId); + return extractTypeParams2(type); +}); -export async function getRecordingByShareType( - client: ClientWithCoreApi, - graphqlClient: SuiGraphQLClient, +export const getRecordingByShareType = Effect.fn("getRecordingByShareType")(function* ( shareType: string, misoPackageId: string, -): Promise { - const address = await addressOfRecordingWithShareType( - graphqlClient, - misoPackageId, - shareType, - ); - if (!address) - throw new Error(`Recording not found for share type: ${shareType}`); - return getRecordingById(client, address); -} +): Effect.fn.Return { + const address = yield* addressOfRecordingWithShareType(misoPackageId, shareType); + if (Option.isNone(address)) { + return yield* new ObjectNotFoundError({ objectId: `recording::Recording<${shareType}, ...>` }); + } + return yield* getRecordingById(address.value); +}); -export async function getRecordingAdminCapById( - client: ClientWithCoreApi, +export const getRecordingAdminCapById = Effect.fn("getRecordingAdminCapById")(function* ( adminCapId: string, -): Promise { - const { object } = await client.core.getObject({ objectId: adminCapId }); - return { id: adminCapId, shareType: extractTypeParam(object.type) }; -} +): Effect.fn.Return { + const type = yield* getObjectType(adminCapId); + return new RecordingAdminCap({ id: adminCapId, shareType: extractTypeParam(type) }); +}); /** * Recording admin caps owned by `owner`. @@ -722,108 +705,89 @@ export async function getRecordingAdminCapById( * so this yields only the recording's own share type — its parent composition's * share type is not recoverable from the cap alone. */ -export async function getOwnedRecordingAdminCaps( - client: ClientWithCoreApi, +export const getOwnedRecordingAdminCaps = Effect.fn("getOwnedRecordingAdminCaps")(function* ( owner: string, misoPackageId: string, -): Promise { +): Effect.fn.Return { const capType = `${misoPackageId}::recording::RecordingAdminCap`; - const objects = await listAllOwnedObjects(client, { owner, type: capType }); + const objects = yield* listAllOwnedObjects({ owner, type: capType }); const caps: RecordingAdminCap[] = []; for (const obj of objects) { const match = obj.type?.match(/<(.+)>$/); - if (match?.[1]) caps.push({ id: obj.objectId, shareType: match[1] }); + if (match?.[1]) caps.push(new RecordingAdminCap({ id: obj.objectId, shareType: match[1] })); } return caps; -} +}); -export function deriveRecordingAdminCapId( - recordingId: string, - misoPackageId: string, -): string { - return deriveObjectID( - recordingId, - `${misoPackageId}::recording::RecordingAdminCapKey`, - UNIT_STRUCT_KEY_BYTES, - ); +export function deriveRecordingAdminCapId(recordingId: string, misoPackageId: string): string { + return deriveObjectID(recordingId, `${misoPackageId}::recording::RecordingAdminCapKey`, UNIT_STRUCT_KEY_BYTES); } // ============================================================================ // Release // ============================================================================ -export async function getReleasesByIds( - client: ClientWithCoreApi, +export const getReleasesByIds = Effect.fn("getReleasesByIds")(function* ( releaseIds: string[], -): Promise> { +): Effect.fn.Return, SuiRpcError | BcsDecodeError, SuiClient> { if (releaseIds.length === 0) return {}; - const { objects } = await client.core.getObjects({ - objectIds: releaseIds, - include: { content: true }, - }); - const out: Record = {}; - for (const obj of objects) { - if (obj instanceof Error || !obj.content) continue; - out[obj.objectId] = mapRelease(obj.objectId, ReleaseBcs.parse(obj.content)); - } - return out; -} + const contents = yield* getObjectsContent(releaseIds); + const decoded = yield* Effect.forEach( + [...contents.entries()], + ([objectId, { content }]) => + decodeBcs( + { parse: (bytes) => mapRelease(objectId, ReleaseBcs.parse(bytes)) }, + Release, + content, + { type: "Release", objectId }, + ).pipe(Effect.map((release) => [objectId, release] as const)), + { concurrency: "unbounded" }, + ); + return Object.fromEntries(decoded); +}); -export async function getReleaseById( - client: ClientWithCoreApi, +export function getReleaseById( releaseId: string, -): Promise { - const { object } = await client.core.getObject({ - objectId: releaseId, - include: { content: true }, - }); - if (!object.content) throw new Error(`Release not found: ${releaseId}`); - return mapRelease(releaseId, ReleaseBcs.parse(object.content)); +): Effect.Effect { + return getObjectByBcs( + releaseId, + { parse: (bytes) => mapRelease(releaseId, ReleaseBcs.parse(bytes)) }, + Release, + "Release", + ); } -export async function getReleaseAdminCapById( - client: ClientWithCoreApi, +export const getReleaseAdminCapById = Effect.fn("getReleaseAdminCapById")(function* ( adminCapId: string, -): Promise { - const { object } = await client.core.getObject({ - objectId: adminCapId, - include: { json: true }, +): Effect.fn.Return { + const client = yield* SuiClient; + const { object } = yield* Effect.tryPromise({ + try: (signal) => client.core.getObject({ objectId: adminCapId, include: { json: true }, signal }), + catch: (cause) => classifyMissing(adminCapId, "getObject", cause), }); const json = object.json as { release_id: string } | null; - if (!json?.release_id) - throw new Error(`ReleaseAdminCap not found: ${adminCapId}`); - return { id: adminCapId, releaseId: json.release_id }; -} + if (!json?.release_id) { + return yield* new ObjectNotFoundError({ objectId: adminCapId }); + } + return new ReleaseAdminCap({ id: adminCapId, releaseId: json.release_id }); +}); -export async function getOwnedReleaseAdminCaps( - client: ClientWithCoreApi, +export const getOwnedReleaseAdminCaps = Effect.fn("getOwnedReleaseAdminCaps")(function* ( owner: string, misoPackageId: string, -): Promise { +): Effect.fn.Return { const capType = `${misoPackageId}::release::ReleaseAdminCap`; - const objects = await listAllOwnedObjects(client, { - owner, - type: capType, - include: { json: true }, - }); + const objects = yield* listAllOwnedObjects({ owner, type: capType, include: { json: true } }); const caps: ReleaseAdminCap[] = []; for (const obj of objects) { const json = obj.json as { release_id: string } | null; - if (json?.release_id) - caps.push({ id: obj.objectId, releaseId: json.release_id }); + if (json?.release_id) caps.push(new ReleaseAdminCap({ id: obj.objectId, releaseId: json.release_id })); } return caps; -} +}); -export function deriveReleaseAdminCapId( - releaseId: string, - misoPackageId: string, -): string { - return deriveObjectID( - releaseId, - `${misoPackageId}::release::ReleaseAdminCapKey`, - UNIT_STRUCT_KEY_BYTES, - ); +export function deriveReleaseAdminCapId(releaseId: string, misoPackageId: string): string { + return deriveObjectID(releaseId, `${misoPackageId}::release::ReleaseAdminCapKey`, UNIT_STRUCT_KEY_BYTES); } // ============================================================================ @@ -831,13 +795,12 @@ export function deriveReleaseAdminCapId( // ============================================================================ /** Extracts the share type `T` from a `Currency` object. */ -export async function getShareCurrencyType( - client: ClientWithCoreApi, +export const getShareCurrencyType = Effect.fn("getShareCurrencyType")(function* ( shareCurrencyId: string, -): Promise { - const { object } = await client.core.getObject({ objectId: shareCurrencyId }); - return extractTypeParam(object.type); -} +): Effect.fn.Return { + const type = yield* getObjectType(shareCurrencyId); + return extractTypeParam(type); +}); /** * Finds the `TreasuryCap` owned by `owner`. One Core API call. @@ -850,43 +813,40 @@ export async function getShareCurrencyType( * compose the two: * * ```ts - * const shareType = await getShareCurrencyType(client, shareCurrencyId); - * const capId = await getShareCurrencyTreasuryCap(client, shareType, owner); + * const shareType = yield* getShareCurrencyType(shareCurrencyId); + * const capId = yield* getShareCurrencyTreasuryCap(shareType, owner); * ``` */ -export async function getShareCurrencyTreasuryCap( - client: ClientWithCoreApi, +export const getShareCurrencyTreasuryCap = Effect.fn("getShareCurrencyTreasuryCap")(function* ( shareType: string, owner: string, -): Promise { - const objects = await listAllOwnedObjects(client, { - owner, - type: `0x2::coin::TreasuryCap<${shareType}>`, - }); +): Effect.fn.Return { + const objects = yield* listAllOwnedObjects({ owner, type: `0x2::coin::TreasuryCap<${shareType}>` }); if (objects.length === 0) { throw new Error(`No TreasuryCap found for ${shareType} owned by ${owner}`); } return objects[0]!.objectId; -} +}); // ============================================================================ // Private // ============================================================================ -/** Returns the first object address of a fully-qualified type, or null. */ -async function firstAddressOfType( - client: SuiGraphQLClient, +/** Returns the first object address of a fully-qualified type, or `Option.none()`. */ +const firstAddressOfType = Effect.fn("firstAddressOfType")(function* ( type: string, -): Promise { - const result = await client.query({ - query: AddressesByTypeQuery, - variables: { type }, +): Effect.fn.Return, SuiRpcError, SuiGraphQL> { + const client = yield* SuiGraphQL; + const result = yield* Effect.tryPromise({ + try: () => client.query({ query: AddressesByTypeQuery, variables: { type } }), + catch: (cause) => new SuiRpcError({ operation: "addressesByType", cause }), }); - return result.data?.objects?.nodes?.[0]?.address ?? null; -} + return Option.fromNullishOr(result.data?.objects?.nodes?.[0]?.address); +}); /** - * Address of the `Recording` whose FIRST type parameter is `shareType`, or null. + * Address of the `Recording` whose FIRST type parameter is `shareType`, or + * `Option.none()`. * * `Recording` takes two parameters and a type * filter must supply all of them or none, so filtering by @@ -896,33 +856,34 @@ async function firstAddressOfType( * first parameter client-side. A recording's share currency is unique to it, so * the match is unambiguous. */ -async function addressOfRecordingWithShareType( - client: SuiGraphQLClient, +const addressOfRecordingWithShareType = Effect.fn("addressOfRecordingWithShareType")(function* ( misoPackageId: string, shareType: string, -): Promise { +): Effect.fn.Return, SuiRpcError, SuiGraphQL> { + const client = yield* SuiGraphQL; let cursor: string | null | undefined; do { - const result = await client.query< - { objects?: WorkAddressConnection | null }, - { type: string; cursor?: string | null } - >({ - query: `query RecordingAddress($type: String!, $cursor: String) { - objects(first: 50, after: $cursor, filter: { type: $type }) { - pageInfo { hasNextPage endCursor } - nodes { address asMoveObject { contents { type { repr } } } } - } - }`, - variables: { type: `${misoPackageId}::recording::Recording`, cursor }, + const result = yield* Effect.tryPromise({ + try: () => + client.query<{ objects?: WorkAddressConnection | null }, { type: string; cursor?: string | null }>({ + query: `query RecordingAddress($type: String!, $cursor: String) { + objects(first: 50, after: $cursor, filter: { type: $type }) { + pageInfo { hasNextPage endCursor } + nodes { address asMoveObject { contents { type { repr } } } } + } + }`, + variables: { type: `${misoPackageId}::recording::Recording`, cursor }, + }), + catch: (cause) => new SuiRpcError({ operation: "recordingAddress", cause }), }); const page = result.data?.objects; for (const node of page?.nodes ?? []) { const repr = node?.asMoveObject?.contents?.type?.repr; if (!repr || !node.address) continue; const [recordingShareType] = extractTypeParams2(repr); - if (recordingShareType === shareType) return node.address; + if (recordingShareType === shareType) return Option.some(node.address); } cursor = page?.pageInfo?.hasNextPage ? page.pageInfo.endCursor : null; } while (cursor); - return null; -} + return Option.none(); +}); diff --git a/packages/musicos/src/transactions.ts b/packages/musicos/src/transactions.ts index 336c3c6..b8d4b24 100644 --- a/packages/musicos/src/transactions.ts +++ b/packages/musicos/src/transactions.ts @@ -33,6 +33,7 @@ import { Transaction, type TransactionObjectArgument, } from "@mysten/sui/transactions"; +import type { TxThunk } from "@misofm/effect"; import * as composition from "./contracts/musicos/composition.ts"; import * as recording from "./contracts/musicos/recording.ts"; @@ -41,7 +42,7 @@ import { asU256, type UnsignedInput } from "./numeric.ts"; import * as track from "./contracts/musicos/track.ts"; /** A thunk that adds commands to a transaction. May be async (resolves at build time). */ -export type TxThunk = (tx: Transaction) => void | Promise; +export type { TxThunk }; // ============================================================================ // Shared inputs diff --git a/packages/musicos/src/types.ts b/packages/musicos/src/types.ts index e2447cb..9e0ec97 100644 --- a/packages/musicos/src/types.ts +++ b/packages/musicos/src/types.ts @@ -1,23 +1,30 @@ // Copyright (c) Miso Labs, Inc. // SPDX-License-Identifier: Apache-2.0 +import { Schema } from "effect"; + // ============================================================================ // Common // ============================================================================ /** Basis points value (0-10000, where 10000 = 100%). */ -export interface BPS { - value: number; -} +export class BPS extends Schema.Class("@misofm/musicos/BPS")({ + value: Schema.Number, +}) {} + +/** Shared lifecycle-state shape for Composition, Recording, and Release: Initialized -> Published(timestampMs). */ +const WorkState = Schema.Union([ + Schema.Struct({ type: Schema.Literal("Initialized") }), + Schema.Struct({ type: Schema.Literal("Published"), timestampMs: Schema.Number }), +]); // ============================================================================ // Composition // ============================================================================ /** Lifecycle state of a composition. */ -export type CompositionState = - | { type: "Initialized" } - | { type: "Published"; timestampMs: number }; +export const CompositionState = WorkState; +export type CompositionState = typeof WorkState.Type; /** * A musical composition representing the underlying written work. @@ -28,19 +35,19 @@ export type CompositionState = * * State machine: Initialized -> Published (immutable after publish) */ -export interface Composition { +export class Composition extends Schema.Class("@misofm/musicos/Composition")({ /** Unique identifier for this composition. */ - id: string; + id: Schema.String, /** Current lifecycle state. */ - state: CompositionState; + state: CompositionState, /** Primary title of the composition. */ - title: string; + title: Schema.String, /** * Royalty rate this composition earns from each recording's revenue (basis * points, 0-10000). Immutable for the composition's lifetime. */ - royaltyRate: BPS; -} + royaltyRate: BPS, +}) {} /** * Emitted once when a composition is published. A pure pointer carrying only the @@ -57,21 +64,20 @@ export interface CompositionPublishedEvent { * The share type parameter T is extracted from the on-chain type * `CompositionAdminCap` where T is the composition's share token type. */ -export interface CompositionAdminCap { +export class CompositionAdminCap extends Schema.Class("@misofm/musicos/CompositionAdminCap")({ /** The object ID of the admin cap. */ - id: string; + id: Schema.String, /** The share type parameter T from CompositionAdminCap. */ - shareType: string; -} + shareType: Schema.String, +}) {} // ============================================================================ // Recording // ============================================================================ /** Lifecycle state of a recording. */ -export type RecordingState = - | { type: "Initialized" } - | { type: "Published"; timestampMs: number }; +export const RecordingState = WorkState; +export type RecordingState = typeof WorkState.Type; /** * An audio recording of a composition. @@ -86,18 +92,18 @@ export type RecordingState = * * State machine: Initialized -> Published (immutable after publish) */ -export interface Recording { +export class Recording extends Schema.Class("@misofm/musicos/Recording")({ /** Unique identifier for this recording. */ - id: string; + id: Schema.String, /** Current lifecycle state. */ - state: RecordingState; + state: RecordingState, /** * Object ID of the parent composition. An identity/membership handle — not a * revenue routing target: the composition is paid via its recording-share * ownership, settled at recording creation. Immutable. */ - compositionId: string; -} + compositionId: Schema.String, +}) {} /** * Emitted once when a recording is published. A pure pointer carrying only the @@ -128,18 +134,19 @@ export interface CompositionSharesGrantedEvent { * The share type parameter T is extracted from the on-chain type * `RecordingAdminCap` where T is the recording's share token type. */ -export interface RecordingAdminCap { +export class RecordingAdminCap extends Schema.Class("@misofm/musicos/RecordingAdminCap")({ /** The object ID of the admin cap. */ - id: string; + id: Schema.String, /** The share type parameter T from RecordingAdminCap. */ - shareType: string; -} + shareType: Schema.String, +}) {} // Track // ============================================================================ /** Lifecycle state of a track on a release. */ -export type TrackState = "Unassigned" | "Assigned"; +export const TrackState = Schema.Literals(["Unassigned", "Assigned"]); +export type TrackState = typeof TrackState.Type; /** * A track on a release, linking a recording to its position in the tracklist. @@ -147,30 +154,29 @@ export type TrackState = "Unassigned" | "Assigned"; * composition lineage, and — via the composition — the display title) is * reached. */ -export interface Track { +export class Track extends Schema.Class("@misofm/musicos/Track")({ /** Current state of the track (Unassigned until the release claims it, then Assigned). */ - state: TrackState; + state: TrackState, /** * ID of the composition underlying this track's recording. An identity/ * membership handle — not a revenue routing target: the composition is paid * via its recording-share ownership, and a track routes its full split to the * recording. */ - compositionId: string; + compositionId: Schema.String, /** ID of the recording on this track. */ - recordingId: string; + recordingId: Schema.String, /** Revenue split for this track within the release (in basis points). */ - splitBps: BPS; -} + splitBps: BPS, +}) {} // ============================================================================ // Release // ============================================================================ /** Lifecycle state of a release. */ -export type ReleaseState = - | { type: "Initialized" } - | { type: "Published"; timestampMs: number }; +export const ReleaseState = WorkState; +export type ReleaseState = typeof WorkState.Type; /** * A music release (album, EP, or single). @@ -182,16 +188,16 @@ export type ReleaseState = * * State machine: Initialized -> Published (immutable after publish) */ -export interface Release { +export class Release extends Schema.Class("@misofm/musicos/Release")({ /** Unique identifier for this release. */ - id: string; + id: Schema.String, /** Current lifecycle state. */ - state: ReleaseState; + state: ReleaseState, /** Title of the release. */ - title: string; + title: Schema.String, /** The ordered tracklist. */ - tracks: Track[]; -} + tracks: Schema.Array(Track), +}) {} /** * Emitted once when a release is published. A pure pointer carrying only the @@ -208,15 +214,20 @@ export interface ReleaseRegistryCreatedEvent { createdBy: string; } +/** The shared canonical core `miso::release::ReleaseRegistry`. */ +export class ReleaseRegistry extends Schema.Class("@misofm/musicos/ReleaseRegistry")({ + id: Schema.String, +}) {} + /** * Admin cap for a Release, derived deterministically from the Release object ID. * * Unlike Composition and Recording admin caps, ReleaseAdminCap is not generic * (Release has no share type parameter) and stores a reference to its Release. */ -export interface ReleaseAdminCap { +export class ReleaseAdminCap extends Schema.Class("@misofm/musicos/ReleaseAdminCap")({ /** The object ID of the admin cap. */ - id: string; + id: Schema.String, /** The object ID of the Release this cap administers. */ - releaseId: string; -} + releaseId: Schema.String, +}) {} diff --git a/packages/musicos/src/view.ts b/packages/musicos/src/view.ts index fec288b..d4568ab 100644 --- a/packages/musicos/src/view.ts +++ b/packages/musicos/src/view.ts @@ -6,9 +6,10 @@ // changing state — used where the value is a pure function of inputs the chain // derives (e.g. the deterministic release id). +import { Effect } from "effect"; import { Transaction } from "@mysten/sui/transactions"; import { bcs } from "@mysten/sui/bcs"; -import type { ClientWithCoreApi } from "@mysten/sui/client"; +import { SuiClient, SuiRpcError } from "@misofm/effect"; import * as release from "./contracts/musicos/release.ts"; import { asU256, asU64, type UnsignedInput } from "./numeric.ts"; @@ -31,13 +32,14 @@ export interface DeriveTargetReleaseIdParams { * Tracks embedded in a release must reference this exact ID, so it is computed * up front and threaded into the core release builder. */ -export async function deriveTargetReleaseId( - client: ClientWithCoreApi, +export const deriveTargetReleaseId = Effect.fn("deriveTargetReleaseId")(function* ( misoPackageId: string, params: DeriveTargetReleaseIdParams, -): Promise { +): Effect.fn.Return { if (params.recordingIds.length !== params.splitBps.length) { - throw new Error(`deriveTargetReleaseId: recordingIds (${params.recordingIds.length}) and splitBps (${params.splitBps.length}) length mismatch.`); + throw new Error( + `deriveTargetReleaseId: recordingIds (${params.recordingIds.length}) and splitBps (${params.splitBps.length}) length mismatch.`, + ); } const tx = new Transaction(); @@ -54,12 +56,24 @@ export async function deriveTargetReleaseId( }), ); + const client = yield* SuiClient; // gRPC/Core equivalent of devInspect: simulate with per-command return values. - const res = await client.core.simulateTransaction({ transaction: tx, include: { commandResults: true } }); + const res = yield* Effect.tryPromise({ + try: (signal) => client.core.simulateTransaction({ transaction: tx, include: { commandResults: true }, signal }), + catch: (cause) => new SuiRpcError({ operation: "simulateTransaction", cause }), + }); if (res.$kind !== "Transaction") { - throw new Error(`derive_target_release_id simulation failed: ${JSON.stringify(res.FailedTransaction.status)}`); + return yield* new SuiRpcError({ + operation: "simulateTransaction", + cause: new Error(`derive_target_release_id simulation failed: ${JSON.stringify(res.FailedTransaction.status)}`), + }); } const returned = res.commandResults?.[0]?.returnValues?.[0]?.bcs; - if (!returned) throw new Error("derive_target_release_id returned no value."); + if (!returned) { + return yield* new SuiRpcError({ + operation: "simulateTransaction", + cause: new Error("derive_target_release_id returned no value."), + }); + } return bcs.Address.parse(returned); -} +}); diff --git a/packages/musicos/tests/batching.test.ts b/packages/musicos/tests/batching.test.ts index dd95e2a..356f0f1 100644 --- a/packages/musicos/tests/batching.test.ts +++ b/packages/musicos/tests/batching.test.ts @@ -2,8 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { expect, test } from "bun:test"; +import { Effect } from "effect"; import type { ClientWithCoreApi } from "@mysten/sui/client"; import type { SuiGraphQLClient } from "@mysten/sui/graphql"; +import { SuiClient, SuiGraphQL } from "@misofm/effect"; import { getOwnedReleaseAdminCaps, getWorkAddressesByShareTypes, @@ -12,6 +14,7 @@ import { import { Composition } from "../src/contracts/musicos/composition.ts"; import { Recording } from "../src/contracts/musicos/recording.ts"; import { Release } from "../src/contracts/musicos/release.ts"; +import { ReleaseAdminCap } from "../src/types.ts"; const id = (digit: string) => `0x${digit.repeat(64)}`; @@ -51,13 +54,14 @@ test("getWorkAddressesByShareTypes combines all type discovery in one GraphQL re }, } as unknown as SuiGraphQLClient; - const result = await getWorkAddressesByShareTypes( - client, - { - compositions: [compositionShare, compositionShare], - recordings: [recordingShare], - }, - packageId, + const result = await Effect.runPromise( + getWorkAddressesByShareTypes( + { + compositions: [compositionShare, compositionShare], + recordings: [recordingShare], + }, + packageId, + ).pipe(Effect.provide(SuiGraphQL.layer(client))), ); expect(calls).toHaveLength(1); @@ -114,18 +118,19 @@ test("getWorksByIds fetches heterogeneous work objects in one Core request", asy }, } as unknown as ClientWithCoreApi; - const result = await getWorksByIds(client, { - compositions: [compositionId], - recordings: [recordingId], - releases: [releaseId], - }); + const result = await Effect.runPromise( + getWorksByIds({ + compositions: [compositionId], + recordings: [recordingId], + releases: [releaseId], + }).pipe(Effect.provide(SuiClient.layer(client))), + ); - expect(calls).toEqual([ - { - objectIds: [compositionId, recordingId, releaseId], - include: { content: true }, - }, - ]); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + objectIds: [compositionId, recordingId, releaseId], + include: { content: true }, + }); expect(result.compositions[compositionId]?.title).toBe("Composition"); expect(result.recordings[recordingId]?.state).toEqual({ type: "Published", @@ -162,20 +167,24 @@ test("getOwnedReleaseAdminCaps uses the list projection without a second object }, } as unknown as ClientWithCoreApi; - await expect( - getOwnedReleaseAdminCaps(client, id("3"), id("a")), - ).resolves.toEqual([{ id: capId, releaseId }, { id: secondCapId, releaseId: id("5") }]); - expect(calls).toEqual([ - { - owner: id("3"), - type: `${id("a")}::release::ReleaseAdminCap`, - include: { json: true }, - }, - { - owner: id("3"), - type: `${id("a")}::release::ReleaseAdminCap`, - include: { json: true }, - cursor: "next", - }, + const result = await Effect.runPromise( + getOwnedReleaseAdminCaps(id("3"), id("a")).pipe(Effect.provide(SuiClient.layer(client))), + ); + expect(result).toEqual([ + new ReleaseAdminCap({ id: capId, releaseId }), + new ReleaseAdminCap({ id: secondCapId, releaseId: id("5") }), ]); + expect(calls).toHaveLength(2); + expect(calls[0]).toMatchObject({ + owner: id("3"), + type: `${id("a")}::release::ReleaseAdminCap`, + include: { json: true }, + }); + expect(calls[0]).not.toHaveProperty("cursor"); + expect(calls[1]).toMatchObject({ + owner: id("3"), + type: `${id("a")}::release::ReleaseAdminCap`, + include: { json: true }, + cursor: "next", + }); }); diff --git a/packages/musicos/tests/queries.test.ts b/packages/musicos/tests/queries.test.ts index 9c37e71..4838a2a 100644 --- a/packages/musicos/tests/queries.test.ts +++ b/packages/musicos/tests/queries.test.ts @@ -1,11 +1,15 @@ // Copyright (c) Miso Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -// The exported query helpers that are pure (no network): type-param extraction -// and missing-object detection. +// The exported query helpers that are pure (no network): type-param extraction. +// Effect-requiring reads are exercised against a fake `ClientWithCoreApi`, +// providing `SuiClient.layer(...)` at the boundary. import { test, expect } from "bun:test"; +import { Effect, Option } from "effect"; +import type { ClientWithCoreApi } from "@mysten/sui/client"; import type { SuiGraphQLClient } from "@mysten/sui/graphql"; +import { SuiClient, SuiGraphQL } from "@misofm/effect"; import { getExtensionField, getReleaseDspLink, @@ -15,14 +19,21 @@ import { extractTypeParams2, getCompositionAddressByShareType, getRecordingByShareType, - isNotFound, } from "../src/queries.ts"; import { CompositionPublishedEvent } from "../src/contracts/musicos/composition.ts"; -import { ReleaseRegistry } from "../src/contracts/musicos/release.ts"; import { Recording } from "../src/contracts/musicos/recording.ts"; +import { ReleaseRegistry as ReleaseRegistryBcs } from "../src/contracts/musicos/release.ts"; const PKG = "0x" + "cd".repeat(32); +function fakeCoreClient(core: Record): ClientWithCoreApi { + return { core } as unknown as ClientWithCoreApi; +} + +function runWithCore(effect: Effect.Effect, client: ClientWithCoreApi): Promise { + return Effect.runPromise(effect.pipe(Effect.provide(SuiClient.layer(client)))); +} + // ── extractTypeParam / extractTypeParams2 ───────────────────────────────────── test("extractTypeParam pulls the single type parameter", () => { @@ -45,33 +56,6 @@ test("extractTypeParams2 splits two top-level parameters, respecting nesting", ( ); }); -// ── isNotFound ──────────────────────────────────────────────────────────────── - -test("isNotFound matches structured ObjectError codes from the JSON-RPC/GraphQL clients", () => { - const withCode = (code: string) => Object.assign(new Error("boom"), { code }); - expect(isNotFound(withCode("notExists"))).toBe(true); - expect(isNotFound(withCode("deleted"))).toBe(true); - expect(isNotFound(withCode("dynamicFieldNotFound"))).toBe(true); - expect(isNotFound(withCode("notFound"))).toBe(true); - expect(isNotFound(withCode("displayError"))).toBe(false); -}); - -test("isNotFound matches the missing-object message shapes of each transport", () => { - expect(isNotFound(new Error(`Object ${PKG} does not exist`))).toBe(true); // JSON-RPC - expect(isNotFound(new Error(`Object ${PKG} not found`))).toBe(true); // GraphQL / gRPC - expect(isNotFound(new Error(`Object ${PKG} has been deleted`))).toBe(true); // JSON-RPC - expect(isNotFound(new Error(`Dynamic field not found for object ${PKG}`))).toBe(true); // JSON-RPC - expect(isNotFound(new Error(`No object found for id ${PKG}`))).toBe(true); // codegen MoveStruct.get -}); - -test("isNotFound does NOT match transport/protocol errors", () => { - expect(isNotFound(new Error("Method not found"))).toBe(false); // JSON-RPC -32601 - expect(isNotFound(new Error("peer not found"))).toBe(false); - expect(isNotFound(new Error("route not found"))).toBe(false); - expect(isNotFound(new Error("fetch failed"))).toBe(false); - expect(isNotFound("not found")).toBe(false); // bare phrase, no object context -}); - // ── GraphQL discovery ──────────────────────────────────────────────────────── test("getCompositionAddressByShareType queries the fully-qualified composition type", async () => { @@ -83,9 +67,13 @@ test("getCompositionAddressByShareType queries the fully-qualified composition t }, } as unknown as SuiGraphQLClient; - const address = await getCompositionAddressByShareType(graphqlClient, `${PKG}::share::CompositionShare`, PKG); + const address = await Effect.runPromise( + getCompositionAddressByShareType(`${PKG}::share::CompositionShare`, PKG).pipe( + Effect.provide(SuiGraphQL.layer(graphqlClient)), + ), + ); - expect(address).toBe("0xcomposition"); + expect(Option.getOrNull(address)).toBe("0xcomposition"); expect(calls).toHaveLength(1); expect(calls[0]).toMatchObject({ variables: { @@ -94,12 +82,15 @@ test("getCompositionAddressByShareType queries the fully-qualified composition t }); }); -test("getCompositionAddressByShareType returns null when no composition matches", async () => { +test("getCompositionAddressByShareType resolves to None when no composition matches", async () => { const graphqlClient = { query: async () => ({ data: { objects: { nodes: [] } } }), } as unknown as SuiGraphQLClient; - await expect(getCompositionAddressByShareType(graphqlClient, `${PKG}::share::Missing`, PKG)).resolves.toBeNull(); + const address = await Effect.runPromise( + getCompositionAddressByShareType(`${PKG}::share::Missing`, PKG).pipe(Effect.provide(SuiGraphQL.layer(graphqlClient))), + ); + expect(Option.isNone(address)).toBe(true); }); test("getRecordingByShareType follows GraphQL pages before loading the matching object", async () => { @@ -137,75 +128,76 @@ test("getRecordingByShareType follows GraphQL pages before loading the matching }; }, } as unknown as SuiGraphQLClient; - const coreClient = { - core: { - getObject: async ({ objectId }: { objectId: string }) => ({ - object: { - content: Recording.serialize({ - id: objectId, - state: { Initialized: true }, - composition_id: PKG, - }).toBytes(), - }, - }), - }, - } as unknown as ClientWithCoreApi; - - await expect(getRecordingByShareType(coreClient, graphqlClient, targetShare, PKG)).resolves.toMatchObject({ - id: matchingId, - compositionId: PKG, + const coreClient = fakeCoreClient({ + getObject: async ({ objectId }: { objectId: string }) => ({ + object: { + content: Recording.serialize({ + id: objectId, + state: { Initialized: true }, + composition_id: PKG, + }).toBytes(), + }, + }), }); + + const recording = await Effect.runPromise( + getRecordingByShareType(targetShare, PKG).pipe( + Effect.provide(SuiGraphQL.layer(graphqlClient)), + Effect.provide(SuiClient.layer(coreClient)), + ), + ); + + expect(recording).toMatchObject({ id: matchingId, compositionId: PKG }); expect(calls).toHaveLength(2); expect(calls[1]).toMatchObject({ variables: { cursor: "page-2" } }); }); -test("getExtensionField reads a fieldless ExtensionKey and returns null only for absence", async () => { +test("getExtensionField reads a fieldless ExtensionKey and resolves to None only for absence", async () => { const calls: unknown[] = []; const value = CompositionPublishedEvent.serialize({ composition_id: PKG, }).toBytes(); - const client = { - core: { - getDynamicField: async (request: unknown) => { - calls.push(request); - return { dynamicField: { value: { bcs: value } } }; - }, + const client = fakeCoreClient({ + getDynamicField: async (request: unknown) => { + calls.push(request); + return { dynamicField: { value: { bcs: value } } }; }, - } as unknown as ClientWithCoreApi; + }); - await expect( - getExtensionField(client, PKG, { + const result = await runWithCore( + getExtensionField(PKG, { packageId: PKG, module: "some_extension", codec: CompositionPublishedEvent, }), - ).resolves.toEqual({ composition_id: PKG }); - expect(calls).toEqual([ - { - parentId: PKG, - name: { - type: `${PKG}::some_extension::ExtensionKey`, - bcs: new Uint8Array([0]), - }, + client, + ); + expect(Option.getOrThrow(result)).toEqual({ composition_id: PKG }); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + parentId: PKG, + name: { + type: `${PKG}::some_extension::ExtensionKey`, + bcs: new Uint8Array([0]), }, - ]); + }); - const missing = { - core: { - getDynamicField: async () => { - throw Object.assign(new Error("Dynamic field not found for object"), { - code: "dynamicFieldNotFound", - }); - }, + const missing = fakeCoreClient({ + getDynamicField: async () => { + throw Object.assign(new Error("Dynamic field not found for object"), { + code: "dynamicFieldNotFound", + }); }, - } as unknown as ClientWithCoreApi; - await expect( - getExtensionField(missing, PKG, { + }); + const missingResult = await runWithCore( + getExtensionField(PKG, { packageId: PKG, module: "some_extension", codec: CompositionPublishedEvent, }), - ).resolves.toBeNull(); + missing, + ); + expect(Option.isNone(missingResult)).toBe(true); }); test("DSP fields use their platform-keyed dynamic-field names", async () => { @@ -213,58 +205,68 @@ test("DSP fields use their platform-keyed dynamic-field names", async () => { const value = CompositionPublishedEvent.serialize({ composition_id: PKG, }).toBytes(); - const client = { - core: { - getDynamicField: async (request: unknown) => { - calls.push(request); - return { dynamicField: { value: { bcs: value } } }; - }, + const client = fakeCoreClient({ + getDynamicField: async (request: unknown) => { + calls.push(request); + return { dynamicField: { value: { bcs: value } } }; }, - } as unknown as ClientWithCoreApi; - - await getReleaseDspLink(client, PKG, { - packageId: PKG, - platform: 7, - codec: CompositionPublishedEvent, - }); - await getTrackDspLinks(client, PKG, { - packageId: PKG, - platform: 7, - codec: CompositionPublishedEvent, }); - expect(calls).toEqual([ - { - parentId: PKG, - name: { - type: `${PKG}::release_dsp_link::ReleaseLinkKey`, - bcs: new Uint8Array([7]), - }, - }, - { - parentId: PKG, - name: { - type: `${PKG}::release_dsp_link::TrackLinksKey`, - bcs: new Uint8Array([7]), - }, - }, - ]); - await expect( - getReleaseDspLink(client, PKG, { + await runWithCore( + getReleaseDspLink(PKG, { packageId: PKG, - platform: 256, + platform: 7, codec: CompositionPublishedEvent, }), - ).rejects.toThrow(/u8/); + client, + ); + await runWithCore( + getTrackDspLinks(PKG, { + packageId: PKG, + platform: 7, + codec: CompositionPublishedEvent, + }), + client, + ); + + expect(calls).toHaveLength(2); + expect(calls[0]).toMatchObject({ + parentId: PKG, + name: { + type: `${PKG}::release_dsp_link::ReleaseLinkKey`, + bcs: new Uint8Array([7]), + }, + }); + expect(calls[1]).toMatchObject({ + parentId: PKG, + name: { + type: `${PKG}::release_dsp_link::TrackLinksKey`, + bcs: new Uint8Array([7]), + }, + }); + + let thrown: unknown; + try { + await runWithCore( + getReleaseDspLink(PKG, { + packageId: PKG, + platform: 256, + codec: CompositionPublishedEvent, + }), + client, + ); + } catch (error) { + thrown = error; + } + expect(String(thrown)).toMatch(/u8/); }); test("core registry parsing is deterministic", async () => { - const client = { - core: { - getObject: async () => ({ - object: { content: ReleaseRegistry.serialize({ id: PKG }).toBytes() }, - }), - }, - } as unknown as ClientWithCoreApi; - await expect(getReleaseRegistryById(client, PKG)).resolves.toEqual({ id: PKG }); + const client = fakeCoreClient({ + getObject: async () => ({ + object: { content: ReleaseRegistryBcs.serialize({ id: PKG }).toBytes() }, + }), + }); + const registry = await runWithCore(getReleaseRegistryById(PKG), client); + expect(registry).toEqual({ id: PKG }); }); diff --git a/packages/musicos/tests/view.test.ts b/packages/musicos/tests/view.test.ts index f4b6153..a0af826 100644 --- a/packages/musicos/tests/view.test.ts +++ b/packages/musicos/tests/view.test.ts @@ -2,8 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { expect, test } from "bun:test"; +import { Effect } from "effect"; import { bcs } from "@mysten/sui/bcs"; import type { ClientWithCoreApi } from "@mysten/sui/client"; +import { SuiClient } from "@misofm/effect"; import { deriveTargetReleaseId } from "../src/view.ts"; const PKG = "0x" + "cd".repeat(32); @@ -24,15 +26,16 @@ test("deriveTargetReleaseId sends the core registry as the first Move argument", }, } as unknown as ClientWithCoreApi; - await expect( - deriveTargetReleaseId(client, PKG, { + const releaseId = await Effect.runPromise( + deriveTargetReleaseId(PKG, { sender: REGISTRY, releaseRegistryId: REGISTRY, recordingIds: [RECORDING], splitBps: [10_000], nonce: "42", - }), - ).resolves.toBe(RECORDING); + }).pipe(Effect.provide(SuiClient.layer(client))), + ); + expect(releaseId).toBe(RECORDING); const data = transaction!.getData() as { inputs: { $kind: string; UnresolvedObject?: { objectId: string } }[]; @@ -56,13 +59,20 @@ test("deriveTargetReleaseId rejects unsafe JavaScript integer inputs before simu }, }, } as unknown as ClientWithCoreApi; - await expect( - deriveTargetReleaseId(client, PKG, { - sender: REGISTRY, - recordingIds: [RECORDING], - splitBps: [Number.MAX_SAFE_INTEGER + 2], - nonce: "1", - releaseRegistryId: REGISTRY, - }), - ).rejects.toThrow("safe integer"); + + let thrown: unknown; + try { + await Effect.runPromise( + deriveTargetReleaseId(PKG, { + sender: REGISTRY, + recordingIds: [RECORDING], + splitBps: [Number.MAX_SAFE_INTEGER + 2], + nonce: "1", + releaseRegistryId: REGISTRY, + }).pipe(Effect.provide(SuiClient.layer(client))), + ); + } catch (error) { + thrown = error; + } + expect(String(thrown)).toMatch(/safe integer/); }); diff --git a/packages/partyos/README.md b/packages/partyos/README.md index a613ded..3015825 100644 --- a/packages/partyos/README.md +++ b/packages/partyos/README.md @@ -14,38 +14,90 @@ this package. Which Move package generates into which SDK is decided by ## Install ```sh -bun add @misofm/partyos @mysten/sui +bun add @misofm/partyos @misofm/effect effect @mysten/sui ``` -`@mysten/sui` is a peer dependency pinned to one exact version. +`@mysten/sui` and `effect` (exactly `4.0.0-rc.112`) are peer dependencies. ## Use +Every read returns an `Effect` that requires the `SuiClient` service instead of +taking a client parameter — see [`@misofm/effect`](https://www.npmjs.com/package/@misofm/effect) +for the shared `SuiClient` service and error vocabulary every `@misofm/*` package +builds on. + +### Through the client extension + +`client.partyos.*` provides `SuiClient` internally (bound to the client it was +built from), so each method already returns `Effect` with `R = never` — +just `Effect.runPromise` it: + ```ts +import { Effect } from "effect"; import { SuiGrpcClient } from "@mysten/sui/grpc"; import { Transaction } from "@mysten/sui/transactions"; import { partyos } from "@misofm/partyos"; const client = new SuiGrpcClient({ network: "testnet" }).$extend(partyos()); -const party = await client.partyos.getPartyById("0x..."); -const groups = await client.partyos.getMemberships(party.id); +const program = Effect.gen(function* () { + const party = yield* client.partyos.getPartyById("0x..."); + const groups = yield* client.partyos.getMemberships(party.id); + return { party, groups }; +}).pipe( + Effect.catchTag("ObjectNotFoundError", (error) => Effect.succeed({ party: null, groups: [], missing: error })), +); + +const { party, groups } = await Effect.runPromise(program); const tx = new Transaction(); await client.partyos.tx.createIndividualParty({ name: "Ada", recipient: "0x..." })(tx); ``` Every builder under `tx` appends commands to a caller-owned `Transaction` and never -executes. `call` exposes the generated, package-bound Move calls (minus the -reference-returning views a PTB cannot use), and `bcs` the generated codecs. +executes (`tx`/`call`/`bcs` stay synchronous). `call` exposes the generated, +package-bound Move calls (minus the reference-returning views a PTB cannot use), +and `bcs` the generated codecs. + +### Free functions + +The functions in `./queries` take no client parameter — they declare `SuiClient` +in their Effect's requirements, so provide it once at your program's boundary: + +```ts +import { Effect } from "effect"; +import { SuiGrpcClient } from "@mysten/sui/grpc"; +import { SuiClient } from "@misofm/effect"; +import { getPartyById } from "@misofm/partyos/queries"; + +const client = new SuiGrpcClient({ network: "testnet" }); + +const program = getPartyById("0x...", "0xPARTYOS_PACKAGE"); + +const party = await Effect.runPromise(program.pipe(Effect.provide(SuiClient.layer(client)))); +``` + +## Errors + +Reads fail with the shared `@misofm/effect` vocabulary: `ObjectNotFoundError { objectId }`, +`ObjectTypeMismatchError { objectId, expected, actual }` (e.g. a `Party` object from a +different partyos deployment, or an id that is not a `Party` at all), `BcsDecodeError`, +and `SuiRpcError`. Recover from one with `Effect.catchTag("ObjectNotFoundError", ...)` +rather than an `isNotFound(error)` predicate. `./errors` re-exports the full +`@misofm/effect/errors` vocabulary plus `PartyNotFoundError { partyId }`, offered for +callers that want a party-scoped tag instead of pattern-matching the generic one. +`validatePartyDeployment` (in `./deployments`) fails with `DeploymentError { message }`. ## Deployment manifest `PARTYOS_DEPLOYMENTS` bundles the verified testnet deployment: a single key, `partyos`. Pass `partyos({ deployment })` for another network; a manifest with any other shape is -rejected before a Move target is constructed. +rejected before a Move target is constructed (`assertPartyDeployment`/`normalizePartyDeployment` +throw synchronously; `validatePartyDeployment` is the Effect-returning counterpart for +callers composing a config-loading pipeline out of Effects). ## Subpaths -`@misofm/partyos` (everything), `/client`, `/deployments`, `/queries`, `/transactions`, -`/types`, `/contracts` (curated bindings), `/contracts/*` (raw generated modules). +`@misofm/partyos` (everything), `/client`, `/deployments`, `/errors`, `/queries`, +`/transactions`, `/types`, `/contracts` (curated bindings), `/contracts/*` (raw +generated modules). diff --git a/packages/partyos/package.json b/packages/partyos/package.json index 0624259..3afed9b 100644 --- a/packages/partyos/package.json +++ b/packages/partyos/package.json @@ -1,6 +1,6 @@ { "name": "@misofm/partyos", - "version": "0.2.0", + "version": "0.3.0", "packageManager": "bun@1.4.0", "description": "Typed bindings, queries, and PTB builders for the PartyOS object model (Party identity, admin caps, and group membership) on Sui.", "license": "Apache-2.0", @@ -50,6 +50,10 @@ "types": "./dist/deployments.d.ts", "default": "./dist/deployments.js" }, + "./errors": { + "types": "./dist/errors.d.ts", + "default": "./dist/errors.js" + }, "./queries": { "types": "./dist/queries.d.ts", "default": "./dist/queries.js" @@ -80,17 +84,20 @@ "package.json" ], "dependencies": { + "@misofm/effect": "0.1.0", "@mysten/bcs": "^2.1.1" }, "devDependencies": { "@mysten/sui": "2.29.0", "@types/bun": "^1.4.0", + "effect": "4.0.0-rc.112", "typedoc": "^0.28.20", "typedoc-plugin-markdown": "^4.13.0", "typescript": "^7.0.2" }, "peerDependencies": { "@mysten/sui": "2.29.0", + "effect": "4.0.0-rc.112", "typescript": "^5.7 || ^6 || ^7" }, "main": "./dist/index.js" diff --git a/packages/partyos/src/client.ts b/packages/partyos/src/client.ts index b672ae5..65a9e4f 100644 --- a/packages/partyos/src/client.ts +++ b/packages/partyos/src/client.ts @@ -5,7 +5,10 @@ // deployment. Consumers reach this through `client.partyos` after // `$extend(partyos())`, or construct `PartyosClient` directly. +import { Effect } from "effect"; import type { ClientWithCoreApi, SuiClientRegistration } from "@mysten/sui/client"; +import { SuiClient } from "@misofm/effect"; +import type { BcsDecodeError, ObjectNotFoundError, ObjectTypeMismatchError, SuiRpcError } from "@misofm/effect"; import { getPartyDeployment, normalizePartyDeployment, type PartyDeployment } from "./deployments.ts"; import { PARTY_REF_RETURNING_CALLS } from "./contracts.ts"; import * as queries from "./queries.ts"; @@ -74,10 +77,12 @@ export function partyos( export class PartyosClient { #client: ClientWithCoreApi; #deployment: PartyDeployment; + #layer: ReturnType; constructor(client: ClientWithCoreApi, deployment: PartyDeployment) { this.#client = client; this.#deployment = normalizePartyDeployment(deployment); + this.#layer = SuiClient.layer(client); } /** The exact deployment selected for this client. */ @@ -89,32 +94,43 @@ export class PartyosClient { return this.#deployment.partyos; } + /** Provides the `SuiClient` service bound to this client, discharging `R`. */ + #provide(effect: Effect.Effect): Effect.Effect { + return effect.pipe(Effect.provide(this.#layer)); + } + // === Queries === + // Each method provides `SuiClient` internally, so callers get `Effect` + // (R = never) and only need `Effect.runPromise` (or their own composition). - async getPartyById(partyId: string): Promise { - return queries.getPartyById(this.#client, partyId, this.#pkg); + getPartyById( + partyId: string, + ): Effect.Effect { + return this.#provide(queries.getPartyById(partyId, this.#pkg)); } - async getPartiesByIds(partyIds: readonly string[]): Promise>> { - return queries.getPartiesByIds(this.#client, partyIds, this.#pkg); + getPartiesByIds( + partyIds: readonly string[], + ): Effect.Effect>, ObjectTypeMismatchError | BcsDecodeError | SuiRpcError> { + return this.#provide(queries.getPartiesByIds(partyIds, this.#pkg)); } derivePartyAdminCapId(partyId: string): string { return queries.derivePartyAdminCapId(partyId, this.#pkg); } /** Group ids a party belongs to (member-side membership records). */ - async getMemberships(partyId: string): Promise { - return queries.getMemberships(this.#client, partyId); + getMemberships(partyId: string): Effect.Effect { + return this.#provide(queries.getMemberships(partyId)); } /** Member ids invited to a group but not yet accepted. */ - async getPendingInvites(groupId: string): Promise { - return queries.getPendingInvites(this.#client, groupId); + getPendingInvites(groupId: string): Effect.Effect { + return this.#provide(queries.getPendingInvites(groupId)); } /** Group ids that have invited this party but are awaiting its response. */ - async getPendingMemberships(partyId: string): Promise { - return queries.getPendingMemberships(this.#client, partyId); + getPendingMemberships(partyId: string): Effect.Effect { + return this.#provide(queries.getPendingMemberships(partyId)); } /** Whether a party is a member of a group. */ - async isMember(memberId: string, groupId: string): Promise { - return queries.isMember(this.#client, memberId, groupId, this.#pkg); + isMember(memberId: string, groupId: string): Effect.Effect { + return this.#provide(queries.isMember(memberId, groupId, this.#pkg)); } // === Transaction builders (thunks; the package id is bound from the client) === diff --git a/packages/partyos/src/deployments.ts b/packages/partyos/src/deployments.ts index 51aa52f..c5e358d 100644 --- a/packages/partyos/src/deployments.ts +++ b/packages/partyos/src/deployments.ts @@ -1,7 +1,9 @@ // Copyright (c) Miso Labs, Inc. // SPDX-License-Identifier: Apache-2.0 +import { Effect } from "effect"; import { normalizeSuiAddress } from "@mysten/sui/utils"; +import { DeploymentError } from "@misofm/effect"; /** Sui networks for which this SDK may bundle a verified deployment. */ export type PartyosNetwork = "mainnet" | "testnet"; @@ -88,3 +90,17 @@ export function normalizePartyDeployment(deployment: unknown): PartyDeployment { Object.fromEntries(CANONICAL_PARTYOS_PACKAGE_NAMES.map((name) => [name, deployment[name]])), ) as PartyDeployment; } + +/** + * Effect counterpart to {@link normalizePartyDeployment}: validation is pure and + * synchronous, so this exists only for callers composing a config-loading + * pipeline out of Effects rather than throwing at a configuration boundary. + */ +export const validatePartyDeployment = Effect.fn("validatePartyDeployment")(function* ( + input: unknown, +): Effect.fn.Return { + return yield* Effect.try({ + try: () => normalizePartyDeployment(input), + catch: (cause) => new DeploymentError({ message: cause instanceof Error ? cause.message : String(cause) }), + }); +}); diff --git a/packages/partyos/src/errors.ts b/packages/partyos/src/errors.ts new file mode 100644 index 0000000..db421d0 --- /dev/null +++ b/packages/partyos/src/errors.ts @@ -0,0 +1,18 @@ +// Copyright (c) Miso Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Party-specific tagged errors, plus the shared vocabulary re-exported so +// callers never need to reach into `@misofm/effect` directly. `getPartyById` +// and `getPartiesByIds` fail with the foundation's `ObjectNotFoundError` / +// `ObjectTypeMismatchError` — `PartyNotFoundError` below is offered for +// callers that want a party-scoped tag instead of pattern-matching the +// generic object vocabulary. + +import { Schema } from "effect"; + +export * from "@misofm/effect/errors"; + +/** A party object does not exist (never existed, was deleted, or the id is not a party). */ +export class PartyNotFoundError extends Schema.TaggedError()("PartyNotFoundError", { + partyId: Schema.String, +}) {} diff --git a/packages/partyos/src/index.ts b/packages/partyos/src/index.ts index 2b72434..67abda8 100644 --- a/packages/partyos/src/index.ts +++ b/packages/partyos/src/index.ts @@ -10,6 +10,7 @@ export * from "./transactions.ts"; export * from "./queries.ts"; export * from "./client.ts"; export * from "./deployments.ts"; +export * from "./errors.ts"; // Generated, ABI-bound bindings (BCS structs + type-safe Move calls). export * as contracts from "./contracts.ts"; diff --git a/packages/partyos/src/internal.ts b/packages/partyos/src/internal.ts index 288a490..950877d 100644 --- a/packages/partyos/src/internal.ts +++ b/packages/partyos/src/internal.ts @@ -2,13 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 // The single boundary between generated BCS-parse output (snake_case, Move-shaped) -// and the public camelCase types. +// and the public camelCase types. `mapParty` returns a plain object shaped like +// `Party`'s encoded form; `Schema.decodeUnknownEffect(Party)` (via `decodeBcs`) +// validates and constructs the class from it. import { fromBase64 } from "@mysten/sui/utils"; import type { Party } from "./types.ts"; // deno-lint-ignore no-explicit-any -- generated parse output is loosely typed -export function mapParty(id: string, d: any): Party { +export function mapParty(id: string, d: any): typeof Party.Encoded { const kind = d.kind; const createdAtMs = Number(d.created_at_ms); // PartyKind is `Individual | Group(VecSet)`. The enum parses to diff --git a/packages/partyos/src/queries.ts b/packages/partyos/src/queries.ts index 4ad08b9..56615b2 100644 --- a/packages/partyos/src/queries.ts +++ b/packages/partyos/src/queries.ts @@ -2,11 +2,23 @@ // SPDX-License-Identifier: Apache-2.0 // Typed reads: fetch an on-chain object (or dynamic field) via the Core API, BCS-parse -// it through the generated struct, and map to the public camelCase types. +// it through the generated struct, and map to the public camelCase types. Every read +// requires the `SuiClient` service (see `@misofm/effect`) instead of taking a client +// parameter; not-found and type-mismatch paths are typed failures, not thrown errors. -import type { ClientWithCoreApi } from "@mysten/sui/client"; +import { Effect, Option, Stream } from "effect"; import { deriveDynamicFieldID, deriveObjectID, normalizeStructTag } from "@mysten/sui/utils"; import { bcs } from "@mysten/sui/bcs"; +import { + assertObjectType, + decodeBcs, + getObjectContent, + getObjectsContent, + getOptionalObjectContent, + listDynamicFields, + type SuiClient, +} from "@misofm/effect"; +import type { BcsDecodeError, ObjectNotFoundError, ObjectTypeMismatchError, SuiRpcError } from "@misofm/effect"; import { MembershipKey as MembershipKeyBcs, Party as PartyBcs, @@ -14,87 +26,63 @@ import { PendingMembershipKey as PendingMembershipKeyBcs, } from "./contracts/partyos/party.ts"; import { keyBytes, mapParty } from "./internal.ts"; -import type { Party } from "./types.ts"; - -/** True for the Core API's "object does not exist" error (a missing dynamic field). */ -export function isNotFound(e: unknown): boolean { - const msg = e instanceof Error ? e.message : String(e); - return /not\s*found|does not exist|no object/i.test(msg); -} - -/** - * Reads an object's BCS content, or null when it does not exist. A missing - * object (e.g. an unset dynamic field) surfaces as a thrown "not found" from the - * Core API, not an empty result, so optional reads can treat it as absence. - */ -export async function getObjectContent(client: ClientWithCoreApi, objectId: string): Promise { - try { - const { object } = await client.core.getObject({ objectId, include: { content: true } }); - return object?.content ?? null; - } catch (e) { - if (isNotFound(e)) return null; - throw e; - } -} +import { Party } from "./types.ts"; /** The `Party` struct tag of one partyos deployment. */ export function partyType(partyPackageId: string): string { return normalizeStructTag(`${partyPackageId}::party::Party`); } -// A Party from another partyos package (a previous generation) parses -// identically but no deployed package accepts it, so the object's type is -// checked against the deployment before its content is trusted. -function assertPartyType(objectId: string, type: string | undefined, partyPackageId: string): void { - const expected = partyType(partyPackageId); - if (type === undefined || normalizeStructTag(type) !== expected) { - throw new Error( - `Object ${objectId} is ${type ?? "of unknown type"}, not a ${expected}` + - (type?.endsWith("::party::Party") ? " (a Party from a different partyos deployment)" : ""), - ); - } +/** Decodes one object's BCS content into a `Party`, mapping the generated parse output first. */ +function decodeParty(objectId: string, content: Uint8Array, expectedType: string) { + return decodeBcs( + { parse: (bytes) => mapParty(objectId, PartyBcs.parse(bytes)) }, + Party, + content, + { type: expectedType, objectId }, + ); } /** Fetches and parses a shared `Party` object of this deployment's partyos package. */ -export async function getPartyById( - client: ClientWithCoreApi, +export const getPartyById = Effect.fn("getPartyById")(function* ( partyId: string, partyPackageId: string, -): Promise { - let object; - try { - ({ object } = await client.core.getObject({ objectId: partyId, include: { content: true } })); - } catch (e) { - if (isNotFound(e)) throw new Error(`Party not found: ${partyId}`); - throw e; - } - if (!object?.content) throw new Error(`Party not found: ${partyId}`); - assertPartyType(partyId, object.type, partyPackageId); - return mapParty(partyId, PartyBcs.parse(object.content)); -} +): Effect.fn.Return< + Party, + ObjectNotFoundError | ObjectTypeMismatchError | BcsDecodeError | SuiRpcError, + SuiClient +> { + const expected = partyType(partyPackageId); + const object = yield* getObjectContent(partyId); + // A Party from another partyos package (a previous generation) parses + // identically but no deployed package accepts it, so the object's type is + // checked against the deployment before its content is trusted. + yield* assertObjectType(partyId, normalizeStructTag(object.type), expected); + return yield* decodeParty(partyId, object.content, expected); +}); /** * Fetches and parses multiple shared `Party` objects in one Core request. * Missing objects are skipped; an object of another type is an error. */ -export async function getPartiesByIds( - client: ClientWithCoreApi, +export const getPartiesByIds = Effect.fn("getPartiesByIds")(function* ( partyIds: readonly string[], partyPackageId: string, -): Promise>> { +): Effect.fn.Return< + Partial>, + ObjectTypeMismatchError | BcsDecodeError | SuiRpcError, + SuiClient +> { if (partyIds.length === 0) return {}; - const { objects } = await client.core.getObjects({ - objectIds: [...new Set(partyIds)], - include: { content: true }, - }); + const expected = partyType(partyPackageId); + const contentById = yield* getObjectsContent([...new Set(partyIds)]); const parties: Partial> = {}; - for (const obj of objects) { - if (obj instanceof Error) continue; - assertPartyType(obj.objectId, obj.type, partyPackageId); - parties[obj.objectId] = mapParty(obj.objectId, PartyBcs.parse(obj.content)); + for (const [objectId, { content, type }] of contentById) { + yield* assertObjectType(objectId, normalizeStructTag(type), expected); + parties[objectId] = yield* decodeParty(objectId, content, expected); } return parties; -} +}); /** Derives a party's `PartyAdminCap` id (it is a `derived_object` off the party UID). */ export function derivePartyAdminCapId(partyId: string, partyPackageId: string): string { @@ -108,38 +96,33 @@ export function derivePartyAdminCapId(partyId: string, partyPackageId: string): // === Group membership reads === /** Collects the ids stored in every dynamic-field key of `parentId` whose type ends with `keySuffix`. */ -async function collectKeyIds( - client: ClientWithCoreApi, +function collectKeyIds( parentId: string, keySuffix: string, codec: { parse(bytes: Uint8Array): readonly unknown[] }, -): Promise { - const ids: string[] = []; - let cursor: string | null | undefined; - do { - const page = await client.core.listDynamicFields({ parentId, cursor: cursor ?? undefined }); - for (const f of page.dynamicFields) { - if (typeof f.name?.type === "string" && f.name.type.endsWith(keySuffix) && f.name.bcs != null) { - const [id] = codec.parse(keyBytes(f.name.bcs)); - ids.push(String(id)); - } - } - cursor = page.hasNextPage ? page.cursor : null; - } while (cursor); - return ids; +): Effect.Effect { + return listDynamicFields(parentId).pipe( + Stream.filter((f) => typeof f.name?.type === "string" && f.name.type.endsWith(keySuffix) && f.name.bcs != null), + Stream.map((f) => { + const [id] = codec.parse(keyBytes(f.name!.bcs)); + return String(id); + }), + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)), + ); } /** * The group ids a party currently belongs to, read from its `MembershipKey` * dynamic fields (the member-side record). No indexer required. */ -export async function getMemberships(client: ClientWithCoreApi, partyId: string): Promise { - return collectKeyIds(client, partyId, "::party::MembershipKey", MembershipKeyBcs); +export function getMemberships(partyId: string): Effect.Effect { + return collectKeyIds(partyId, "::party::MembershipKey", MembershipKeyBcs); } /** The member ids invited to a group but not yet accepted (its `PendingInviteKey` fields). */ -export async function getPendingInvites(client: ClientWithCoreApi, groupId: string): Promise { - return collectKeyIds(client, groupId, "::party::PendingInviteKey", PendingInviteKeyBcs); +export function getPendingInvites(groupId: string): Effect.Effect { + return collectKeyIds(groupId, "::party::PendingInviteKey", PendingInviteKeyBcs); } /** @@ -147,21 +130,21 @@ export async function getPendingInvites(client: ClientWithCoreApi, groupId: stri * This reads the member-side `PendingMembershipKey` inbox index, so it only * enumerates the target party's dynamic fields. */ -export async function getPendingMemberships(client: ClientWithCoreApi, partyId: string): Promise { - return collectKeyIds(client, partyId, "::party::PendingMembershipKey", PendingMembershipKeyBcs); +export function getPendingMemberships(partyId: string): Effect.Effect { + return collectKeyIds(partyId, "::party::PendingMembershipKey", PendingMembershipKeyBcs); } /** Whether `memberId` currently holds a membership record for `groupId`. */ -export async function isMember( - client: ClientWithCoreApi, +export const isMember = Effect.fn("isMember")(function* ( memberId: string, groupId: string, partyPackageId: string, -): Promise { +): Effect.fn.Return { const fieldId = deriveDynamicFieldID( memberId, `${partyPackageId}::party::MembershipKey`, MembershipKeyBcs.serialize([groupId]).toBytes(), ); - return (await getObjectContent(client, fieldId)) !== null; -} + const content = yield* getOptionalObjectContent(fieldId); + return Option.isSome(content); +}); diff --git a/packages/partyos/src/types.ts b/packages/partyos/src/types.ts index 1a5118e..63568ce 100644 --- a/packages/partyos/src/types.ts +++ b/packages/partyos/src/types.ts @@ -1,18 +1,21 @@ // Copyright (c) Miso Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -// Public, camelCase result types. The mappers in ./internal.ts turn the generated -// (snake_case, Move-shaped) parse output into these. +// Public, camelCase result types. The mapper in ./internal.ts turns the generated +// (snake_case, Move-shaped) parse output into the plain shape `Party` decodes from. -export type PartyKind = "individual" | "group"; +import { Schema } from "effect"; -export interface Party { - id: string; - kind: PartyKind; +export const PartyKind = Schema.Literals(["individual", "group"]); +export type PartyKind = typeof PartyKind.Type; + +export class Party extends Schema.Class("@misofm/partyos/Party")({ + id: Schema.String, + kind: PartyKind, /** Human-readable name (not verified). */ - name: string; + name: Schema.String, /** Member party ids — present only when `kind === "group"`. */ - members?: string[]; + members: Schema.optional(Schema.Array(Schema.String)), /** Unix ms when the party was created. */ - createdAtMs: number; -} + createdAtMs: Schema.Number, +}) {} diff --git a/packages/partyos/tests/deployments.test.ts b/packages/partyos/tests/deployments.test.ts index e4b8733..fb1551e 100644 --- a/packages/partyos/tests/deployments.test.ts +++ b/packages/partyos/tests/deployments.test.ts @@ -2,8 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 import { expect, test } from "bun:test"; +import { Effect } from "effect"; +import { DeploymentError } from "@misofm/effect"; import { PartyosClient, partyos } from "../src/client.ts"; -import { PARTYOS_DEPLOYMENTS, assertPartyDeployment, getPartyDeployment } from "../src/deployments.ts"; +import { + PARTYOS_DEPLOYMENTS, + assertPartyDeployment, + getPartyDeployment, + validatePartyDeployment, +} from "../src/deployments.ts"; const PKG = "0x" + "a".repeat(64); @@ -19,6 +26,15 @@ test("unbundled networks and malformed manifests fail closed", () => { expect(() => assertPartyDeployment({})).toThrow(/exactly these package IDs/); }); +test("validatePartyDeployment mirrors normalizePartyDeployment but as a typed Effect failure", async () => { + const ok = await Effect.runPromise(validatePartyDeployment({ partyos: PKG })); + expect(ok).toEqual({ partyos: PKG }); + + const err = await Effect.runPromise(validatePartyDeployment({}).pipe(Effect.flip)); + expect(err).toBeInstanceOf(DeploymentError); + expect(err.message).toMatch(/exactly these package IDs/); +}); + test("the client extension registers under `partyos` and binds the package to every builder", async () => { const registration = partyos({ deployment: { partyos: PKG } }); expect(registration.name).toBe("partyos"); diff --git a/packages/partyos/tests/queries.test.ts b/packages/partyos/tests/queries.test.ts index 436bd42..165f0a4 100644 --- a/packages/partyos/tests/queries.test.ts +++ b/packages/partyos/tests/queries.test.ts @@ -1,14 +1,51 @@ +// Copyright (c) Miso Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + import { describe, expect, it, mock } from "bun:test"; -import type { ClientWithCoreApi } from "@mysten/sui/client"; +import { Effect } from "effect"; +import type { ClientWithCoreApi, SuiClientTypes } from "@mysten/sui/client"; +import { SuiClient } from "@misofm/effect"; +import { BcsDecodeError, ObjectNotFoundError, ObjectTypeMismatchError } from "@misofm/effect"; import { Party as PartyBcs } from "../src/contracts/partyos/party.ts"; -import { getPartiesByIds, getPartyById } from "../src/queries.ts"; +import { + getMemberships, + getPartiesByIds, + getPartyById, + getPendingInvites, + getPendingMemberships, + isMember, +} from "../src/queries.ts"; +import { + MembershipKey as MembershipKeyBcs, + PendingInviteKey as PendingInviteKeyBcs, +} from "../src/contracts/partyos/party.ts"; const id = (digit: string) => `0x${digit.repeat(64)}`; const PKG = id("a"); const OLD_PKG = id("b"); const partyType = (pkg: string) => `${pkg}::party::Party`; +/** Runs a `SuiClient`-requiring program against a fake client. */ +function run(effect: Effect.Effect, client: ClientWithCoreApi): Promise { + return Effect.runPromise(effect.pipe(Effect.provide(SuiClient.layer(client)))); +} + +/** Runs a program and returns its typed failure via `Effect.flip`. */ +function runFailure(effect: Effect.Effect, client: ClientWithCoreApi): Promise { + return Effect.runPromise(effect.pipe(Effect.provide(SuiClient.layer(client)), Effect.flip)); +} + +function fieldEntry(fieldId: string, type: string, bcs: Uint8Array): SuiClientTypes.DynamicFieldEntry { + return { + $kind: "DynamicField", + fieldId, + type: `0x1::field::Field<${type}>`, + name: { type, bcs }, + valueType: "0x1::field::Value", + }; +} + describe("getPartiesByIds", () => { it("fetches every party through one Core batch and skips per-object errors", async () => { const soloId = id("1"); @@ -40,13 +77,12 @@ describe("getPartiesByIds", () => { })); const client = { core: { getObjects } } as unknown as ClientWithCoreApi; - const parties = await getPartiesByIds(client, [soloId, groupId, soloId], PKG); + const parties = await run(getPartiesByIds([soloId, groupId, soloId], PKG), client); expect(getObjects).toHaveBeenCalledTimes(1); - expect(getObjects).toHaveBeenCalledWith({ - objectIds: [soloId, groupId], - include: { content: true }, - }); + expect(getObjects).toHaveBeenCalledWith( + expect.objectContaining({ objectIds: [soloId, groupId], include: { content: true } }), + ); expect(parties[soloId]).toMatchObject({ id: soloId, kind: "individual", @@ -64,11 +100,11 @@ describe("getPartiesByIds", () => { const getObjects = mock(); const client = { core: { getObjects } } as unknown as ClientWithCoreApi; - await expect(getPartiesByIds(client, [], PKG)).resolves.toEqual({}); + await expect(run(getPartiesByIds([], PKG), client)).resolves.toEqual({}); expect(getObjects).not.toHaveBeenCalled(); }); - it("rejects a Party from another partyos deployment", async () => { + it("rejects a Party from another partyos deployment with ObjectTypeMismatchError", async () => { const staleId = id("3"); const getObjects = mock(async () => ({ objects: [ @@ -86,9 +122,10 @@ describe("getPartiesByIds", () => { })); const client = { core: { getObjects } } as unknown as ClientWithCoreApi; - await expect(getPartiesByIds(client, [staleId], PKG)).rejects.toThrow( - /a Party from a different partyos deployment/, - ); + const err = await runFailure(getPartiesByIds([staleId], PKG), client); + + expect(err).toBeInstanceOf(ObjectTypeMismatchError); + expect(err).toMatchObject({ objectId: staleId, expected: partyType(PKG), actual: partyType(OLD_PKG) }); }); }); @@ -99,41 +136,154 @@ describe("getPartyById", () => { it("parses a Party of the deployment's package", async () => { const soloId = id("1"); const getObject = mock(async () => ({ - object: { objectId: soloId, type: partyType(PKG), content: partyContent(soloId) }, + object: { objectId: soloId, type: partyType(PKG), version: "1", content: partyContent(soloId) }, })); const client = { core: { getObject } } as unknown as ClientWithCoreApi; - await expect(getPartyById(client, soloId, PKG)).resolves.toMatchObject({ id: soloId, name: "Solo" }); + await expect(run(getPartyById(soloId, PKG), client)).resolves.toMatchObject({ id: soloId, name: "Solo" }); }); - it("rejects a Party from another partyos deployment", async () => { + it("rejects a Party from another partyos deployment with ObjectTypeMismatchError", async () => { const staleId = id("3"); const getObject = mock(async () => ({ - object: { objectId: staleId, type: partyType(OLD_PKG), content: partyContent(staleId) }, + object: { objectId: staleId, type: partyType(OLD_PKG), version: "1", content: partyContent(staleId) }, })); const client = { core: { getObject } } as unknown as ClientWithCoreApi; - await expect(getPartyById(client, staleId, PKG)).rejects.toThrow( - /a Party from a different partyos deployment/, - ); + const err = await runFailure(getPartyById(staleId, PKG), client); + + expect(err).toBeInstanceOf(ObjectTypeMismatchError); + expect(err).toMatchObject({ objectId: staleId, expected: partyType(PKG), actual: partyType(OLD_PKG) }); }); - it("rejects an object that is not a Party", async () => { + it("rejects an object that is not a Party with ObjectTypeMismatchError", async () => { const coinId = id("4"); const getObject = mock(async () => ({ - object: { objectId: coinId, type: "0x2::coin::Coin<0x2::sui::SUI>", content: new Uint8Array([1]) }, + object: { + objectId: coinId, + type: "0x2::coin::Coin<0x2::sui::SUI>", + version: "1", + content: new Uint8Array([1]), + }, + })); + const client = { core: { getObject } } as unknown as ClientWithCoreApi; + + const err = await runFailure(getPartyById(coinId, PKG), client); + + expect(err).toBeInstanceOf(ObjectTypeMismatchError); + expect(err.objectId).toBe(coinId); + expect(err.actual).toContain("::coin::Coin<"); + }); + + it("reports a missing object as ObjectNotFoundError", async () => { + const missingId = id("5"); + const getObject = mock(async () => { + throw new Error("Object not found"); + }); + const client = { core: { getObject } } as unknown as ClientWithCoreApi; + + const err = await runFailure(getPartyById(missingId, PKG), client); + + expect(err).toBeInstanceOf(ObjectNotFoundError); + expect(err).toMatchObject({ objectId: missingId }); + }); + + it("fails with BcsDecodeError when the object's content does not parse as a Party", async () => { + const badId = id("6"); + const getObject = mock(async () => ({ + object: { objectId: badId, type: partyType(PKG), version: "1", content: new Uint8Array([1, 2, 3]) }, })); const client = { core: { getObject } } as unknown as ClientWithCoreApi; - await expect(getPartyById(client, coinId, PKG)).rejects.toThrow(/not a 0x/); + const err = await runFailure(getPartyById(badId, PKG), client); + + expect(err).toBeInstanceOf(BcsDecodeError); }); +}); + +describe("group membership reads", () => { + it("getMemberships pages through listDynamicFields and decodes matching keys", async () => { + const partyId = id("1"); + const groupA = id("a"); + const groupB = id("b"); + const keyBytes = (groupId: string) => MembershipKeyBcs.serialize([groupId]).toBytes(); + + const pages = [ + { + dynamicFields: [ + fieldEntry("f1", `${PKG}::party::MembershipKey`, keyBytes(groupA)), + fieldEntry("f2", `${PKG}::party::PendingInviteKey`, keyBytes(groupB)), + ], + hasNextPage: true, + cursor: "c1", + }, + { + dynamicFields: [fieldEntry("f3", `${PKG}::party::MembershipKey`, keyBytes(groupB))], + hasNextPage: false, + cursor: null, + }, + ]; + let call = 0; + const seenCursors: (string | null)[] = []; + const listDynamicFields = mock(async ({ cursor }: { cursor: string | null }) => { + seenCursors.push(cursor); + return pages[call++]!; + }); + const client = { core: { listDynamicFields } } as unknown as ClientWithCoreApi; + + const memberships = await run(getMemberships(partyId), client); + + expect(memberships).toEqual([groupA, groupB]); + expect(seenCursors).toEqual([null, "c1"]); + }); + + it("getPendingInvites filters to PendingInviteKey fields only", async () => { + const groupId = id("7"); + const memberId = id("8"); + const keyBytes = (m: string) => PendingInviteKeyBcs.serialize([m]).toBytes(); + + const listDynamicFields = mock(async () => ({ + dynamicFields: [ + fieldEntry("f1", `${PKG}::party::MembershipKey`, keyBytes(memberId)), + fieldEntry("f2", `${PKG}::party::PendingInviteKey`, keyBytes(memberId)), + ], + hasNextPage: false, + cursor: null, + })); + const client = { core: { listDynamicFields } } as unknown as ClientWithCoreApi; + + const invites = await run(getPendingInvites(groupId), client); - it("reports a missing object as not found", async () => { + expect(invites).toEqual([memberId]); + }); + + it("getPendingMemberships returns an empty array when there are no matching keys", async () => { + const partyId = id("1"); + const listDynamicFields = mock(async () => ({ dynamicFields: [], hasNextPage: false, cursor: null })); + const client = { core: { listDynamicFields } } as unknown as ClientWithCoreApi; + + await expect(run(getPendingMemberships(partyId), client)).resolves.toEqual([]); + }); + + it("isMember is true when the derived MembershipKey field exists", async () => { + const memberId = id("1"); + const groupId = id("2"); + const getObject = mock(async ({ objectId }: { objectId: string }) => ({ + object: { objectId, type: "0x1::dynamic_field::Field", version: "1", content: new Uint8Array([1]) }, + })); + const client = { core: { getObject } } as unknown as ClientWithCoreApi; + + await expect(run(isMember(memberId, groupId, PKG), client)).resolves.toBe(true); + }); + + it("isMember is false when the derived MembershipKey field is missing", async () => { + const memberId = id("1"); + const groupId = id("2"); const getObject = mock(async () => { throw new Error("Object not found"); }); const client = { core: { getObject } } as unknown as ClientWithCoreApi; - await expect(getPartyById(client, id("5"), PKG)).rejects.toThrow(/Party not found/); + await expect(run(isMember(memberId, groupId, PKG), client)).resolves.toBe(false); }); }); diff --git a/packages/streaming/README.md b/packages/streaming/README.md index 53ca069..e7671ab 100644 --- a/packages/streaming/README.md +++ b/packages/streaming/README.md @@ -22,12 +22,33 @@ a Walrus aggregator, and nothing else: no storage client, no encoder. uses to attach a stream to an `

("@misofm/platform/Listing")({ + id: Schema.String, + releaseId: Schema.String, + pressingId: Schema.String, + pricing: Schema.Struct({ + kind: Schema.Literals(["fixed", "floor"]), + amount: Schema.String, + }), + state: Schema.Literals(["enabled", "disabled"]), + currencyType: Schema.String, +}) {} + +/** + * One purchased copy of a Pressing. Named `PressingRecord`, not `Record` — the + * Move type is `record::Record`, but `Record` collides with TypeScript's + * built-in `Record` utility type, which this codebase uses constantly. + */ +export class PressingRecord extends Schema.Class("@misofm/platform/PressingRecord")({ + id: Schema.String, + releaseId: Schema.String, + pressingId: Schema.String, + edition: Schema.Number, + number: Schema.Number, + purchaseCurrency: Schema.String, + purchasePrice: Schema.String, + purchasedBy: Schema.String, + purchasedTimestampMs: Schema.String, +}) {} function sameId(a: string, b: string): boolean { return normalizeSuiAddress(a) === normalizeSuiAddress(b); @@ -464,15 +463,6 @@ function requireId(label: string, actual: string, expected: string): void { throw new Error(`${label} ${actual} does not match expected ${expected}`); } -function requireExactType(type: string, expected: string, label: string): void { - const actual = normalizeStructTag(type); - const normalizedExpected = normalizeStructTag(expected); - if (actual !== normalizedExpected) - throw new Error( - `${label} has type ${actual}, expected ${normalizedExpected}`, - ); -} - function requireListingCurrency( type: string, recordShopPackageId: string, @@ -500,7 +490,7 @@ function enumKind( throw new Error(`unknown enum variant; expected ${names.join(" or ")}`); } -function parsePricing(value: unknown): ListingView["pricing"] { +function parsePricing(value: unknown): { kind: "fixed" | "floor"; amount: string } { if (!value || typeof value !== "object") throw new Error("Listing has malformed pricing"); const v = value as { $kind?: string; Fixed?: string; Floor?: string }; @@ -511,13 +501,8 @@ function parsePricing(value: unknown): ListingView["pricing"] { return { kind: kind === "Fixed" ? "fixed" : "floor", amount }; } -function parsePressing( - pressingId: string, - content: Uint8Array, - type: string, - recordPackageId: string, -): PressingView { - requireExactType(type, `${recordPackageId}::pressing::Pressing`, "Pressing"); +/** Raw BCS-parse -> camelCase mapper; `Schema.decodeUnknownEffect` (via `decodeBcs`) validates and constructs `Pressing`. */ +function mapPressing(pressingId: string, recordPackageId: string, content: Uint8Array): typeof Pressing.Encoded { const parsed = pressingContract.Pressing.parse(content); requireId("Pressing UID", parsed.id, pressingId); requireId( @@ -531,19 +516,16 @@ function parsePressing( edition: parsed.edition, supply: parsed.supply, maxSupply: parsed.max_supply, - distributors: parsed.distributors.contents.map((item) => - normalizeStructTag(item.name), - ), + distributors: parsed.distributors.contents.map((item) => normalizeStructTag(item.name)), }; } -function parseListing( +function mapListing( listingId: string, - content: Uint8Array, - type: string, recordShopPackageId: string, -): ListingView { - const currencyType = requireListingCurrency(type, recordShopPackageId); + currencyType: string, + content: Uint8Array, +): typeof Listing.Encoded { const parsed = listingContract.Listing.parse(content); requireId("Listing UID", parsed.id, listingId); requireId( @@ -562,13 +544,7 @@ function parseListing( }; } -function parseRecord( - recordId: string, - content: Uint8Array, - type: string, - recordPackageId: string, -): RecordView { - requireExactType(type, `${recordPackageId}::record::Record`, "Record"); +function mapRecord(recordId: string, recordPackageId: string, content: Uint8Array): typeof PressingRecord.Encoded { const parsed = recordContract.Record.parse(content); requireId("Record UID", parsed.id, recordId); requireId( @@ -589,38 +565,77 @@ function parseRecord( }; } -export async function getPressing( - client: ClientWithCoreApi, +/** Fails with `ObjectTypeMismatchError` unless `type` is `recordShopPackageId`'s `listing::Listing`; returns the currency type argument. */ +function assertListingType( + objectId: string, + type: string, + recordShopPackageId: string, +): Effect.Effect { + return Effect.try({ + try: () => requireListingCurrency(type, recordShopPackageId), + catch: () => + new ObjectTypeMismatchError({ + objectId, + expected: `${normalizeSuiAddress(recordShopPackageId)}::listing::Listing`, + actual: normalizeStructTag(type), + }), + }); +} + +/** One permanent pressing by id, or `null` when no such Pressing exists. */ +export const getPressing = Effect.fn("getPressing")(function* ( pressingId: string, recordPackageId: string, -): Promise { - const got = await fetch(client, pressingId); - return got - ? parsePressing(pressingId, got.content, got.type, recordPackageId) - : null; -} +): Effect.fn.Return { + const found = yield* getOptionalObjectContent(pressingId); + if (found._tag === "None") return null; + const { content, type } = found.value; + yield* assertObjectType( + pressingId, + normalizeStructTag(type), + normalizeStructTag(`${recordPackageId}::pressing::Pressing`), + ); + return yield* decodeBcs( + { parse: (bytes) => mapPressing(pressingId, recordPackageId, bytes) }, + Pressing, + content, + { type: "Pressing", objectId: pressingId }, + ); +}); -export async function getListing( - client: ClientWithCoreApi, +/** One currency-specific listing by id, or `null` when no such Listing exists. */ +export const getListing = Effect.fn("getListing")(function* ( listingId: string, recordShopPackageId: string, -): Promise { - const got = await fetch(client, listingId); - return got - ? parseListing(listingId, got.content, got.type, recordShopPackageId) - : null; -} +): Effect.fn.Return { + const found = yield* getOptionalObjectContent(listingId); + if (found._tag === "None") return null; + const { content, type } = found.value; + const currencyType = yield* assertListingType(listingId, type, recordShopPackageId); + return yield* decodeBcs( + { parse: (bytes) => mapListing(listingId, recordShopPackageId, currencyType, bytes) }, + Listing, + content, + { type: "Listing", objectId: listingId }, + ); +}); -export async function getRecord( - client: ClientWithCoreApi, +/** One purchased copy by id, or `null` when no such Record exists. */ +export const getRecord = Effect.fn("getRecord")(function* ( recordId: string, recordPackageId: string, -): Promise { - const got = await fetch(client, recordId); - return got - ? parseRecord(recordId, got.content, got.type, recordPackageId) - : null; -} +): Effect.fn.Return { + const found = yield* getOptionalObjectContent(recordId); + if (found._tag === "None") return null; + const { content, type } = found.value; + yield* assertObjectType(recordId, normalizeStructTag(type), normalizeStructTag(`${recordPackageId}::record::Record`)); + return yield* decodeBcs( + { parse: (bytes) => mapRecord(recordId, recordPackageId, bytes) }, + PressingRecord, + content, + { type: "Record", objectId: recordId }, + ); +}); export interface GetSaleParams { releaseId: string; @@ -630,10 +645,14 @@ export interface GetSaleParams { recordShopPackageId: string; } -export async function getSale( - client: ClientWithCoreApi, +/** A Pressing and its currency-specific Listing, read in one Core batch. Either half may be `null`. */ +export const getSale = Effect.fn("getSale")(function* ( p: GetSaleParams, -): Promise<{ pressing: PressingView | null; listing: ListingView | null }> { +): Effect.fn.Return< + { pressing: Pressing | null; listing: Listing | null }, + ObjectTypeMismatchError | BcsDecodeError | SuiRpcError, + SuiClient +> { const { pressingId, listingId } = deriveSaleIds( p.releaseId, p.edition, @@ -641,36 +660,35 @@ export async function getSale( p.recordPackageId, p.recordShopPackageId, ); - const { objects } = await client.core.getObjects({ - objectIds: [pressingId, listingId], - include: { content: true }, - }); - const [pressingObject, listingObject] = objects; - if (pressingObject instanceof Error && !isNotFound(pressingObject)) - throw pressingObject; - if (listingObject instanceof Error && !isNotFound(listingObject)) - throw listingObject; - const pressing = - !pressingObject || pressingObject instanceof Error - ? null - : parseBatchPressing( - pressingId, - pressingObject.content, - pressingObject.type, - p.recordPackageId, - ); - const listing = - !listingObject || listingObject instanceof Error - ? null - : parseBatchListing( - listingId, - listingObject.content, - listingObject.type, - p.recordShopPackageId, - ); - if (pressing) + const contentById = yield* getObjectsContent([pressingId, listingId]); + + const pressingFound = contentById.get(pressingId); + let pressing: Pressing | null = null; + if (pressingFound) { + yield* assertObjectType( + pressingId, + normalizeStructTag(pressingFound.type), + normalizeStructTag(`${p.recordPackageId}::pressing::Pressing`), + ); + pressing = yield* decodeBcs( + { parse: (bytes) => mapPressing(pressingId, p.recordPackageId, bytes) }, + Pressing, + pressingFound.content, + { type: "Pressing", objectId: pressingId }, + ); requireId("Sale pressing release", pressing.releaseId, p.releaseId); - if (listing) { + } + + const listingFound = contentById.get(listingId); + let listing: Listing | null = null; + if (listingFound) { + const currencyType = yield* assertListingType(listingId, listingFound.type, p.recordShopPackageId); + listing = yield* decodeBcs( + { parse: (bytes) => mapListing(listingId, p.recordShopPackageId, currencyType, bytes) }, + Listing, + listingFound.content, + { type: "Listing", objectId: listingId }, + ); requireId("Sale listing release", listing.releaseId, p.releaseId); requireId("Sale listing pressing", listing.pressingId, pressingId); if (listing.currencyType !== normalizeStructTag(p.currencyType)) { @@ -679,27 +697,6 @@ export async function getSale( ); } } - return { pressing, listing }; -} -function parseBatchPressing( - id: string, - content: Uint8Array | undefined, - type: string | undefined, - packageId: string, -): PressingView { - if (!content || !type) - throw new Error(`Pressing ${id} has no BCS content or full type`); - return parsePressing(id, content, type, packageId); -} - -function parseBatchListing( - id: string, - content: Uint8Array | undefined, - type: string | undefined, - packageId: string, -): ListingView { - if (!content || !type) - throw new Error(`Listing ${id} has no BCS content or full type`); - return parseListing(id, content, type, packageId); -} + return { pressing, listing }; +}); diff --git a/packages/platform/src/queries.ts b/packages/platform/src/queries.ts index 8ec3b17..aaa5ef0 100644 --- a/packages/platform/src/queries.ts +++ b/packages/platform/src/queries.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 // Shared read plumbing for the platform reads. - -// `isNotFound` is the protocol SDK's — the not-found taxonomy is a property of -// the Sui transports, not of any one package's objects, so it is defined once in -// `@misofm/musicos` and re-exported here for the platform readers. -export { isNotFound } from "@misofm/musicos"; +// +// Not-found is a typed `ObjectNotFoundError` (see `@misofm/effect/errors`), +// matched with `Effect.catchTag("ObjectNotFoundError", ...)` — there is no +// `isNotFound` predicate to re-export anymore. +export {}; diff --git a/packages/platform/src/read/artist.ts b/packages/platform/src/read/artist.ts index 22b5c8a..185a73d 100644 --- a/packages/platform/src/read/artist.ts +++ b/packages/platform/src/read/artist.ts @@ -10,8 +10,12 @@ // // `roles` and `tags` are opt-in via `include`. +import { Effect, Option } from "effect"; +import { getPartiesByIds, getPartyById } from "@misofm/partyos"; +import type { BcsDecodeError, ObjectNotFoundError, ObjectTypeMismatchError, SuiClient, SuiRpcError } from "@misofm/effect"; +import { getCtas, getGenres, getLinks, getProfile, getRoles, getTags } from "../party/queries.ts"; import { resolveGenreNames } from "./genres.ts"; -import type { MisoClient } from "./client.ts"; +import type { MisoConfig } from "./config.ts"; import type { ArtistProfile, PartyMember, @@ -31,39 +35,40 @@ export function partyAvatarUrl(apiBaseUrl: string, partyId: string): string { } /** - * An artist page, fully resolved. Throws only if the PARTY itself can't be read — + * An artist page, fully resolved. Fails only if the PARTY itself can't be read — * every extension is optional by design (a party with no profile set is a new * party, not a broken one), so each decoration read falls back to its empty value. */ -export async function getArtistProfile( - client: MisoClient, +export const getArtistProfile = Effect.fn("getArtistProfile")(function* ( partyId: string, + config: MisoConfig, options: GetArtistOptions = {}, -): Promise { +): Effect.fn.Return< + ArtistProfile, + ObjectNotFoundError | ObjectTypeMismatchError | BcsDecodeError | SuiRpcError, + SuiClient +> { const include = new Set(options.include ?? []); - const { party } = client; + const party = config.party; - const [profile, ctas, genreIds, links, roles, tags, entity] = - await Promise.all([ - party.getProfile(partyId).catch(() => null), - party.getCtas(partyId).catch(() => []), - party.getGenres(partyId).catch(() => [] as string[]), - party.getLinks(partyId).catch(() => []), - include.has("roles") - ? party.getRoles(partyId).catch(() => [] as string[]) - : Promise.resolve(undefined), - include.has("tags") - ? party.getTags(partyId).catch(() => [] as string[]) - : Promise.resolve(undefined), - party.getPartyById(partyId), - ]); + const [profileOpt, ctas, genreIds, links, roles, tags, entity] = yield* Effect.all([ + getProfile(partyId, party.partyProfile).pipe(Effect.catch(() => Effect.succeed(Option.none()))), + getCtas(partyId, party.partyCta).pipe(Effect.catch(() => Effect.succeed([] as { label: string; url: string }[]))), + getGenres(partyId, party.partyGenre).pipe(Effect.catch(() => Effect.succeed([] as string[]))), + getLinks(partyId).pipe(Effect.catch(() => Effect.succeed([] as { platform: string; value: string; url: string }[]))), + include.has("roles") + ? getRoles(partyId, party.partyRoles).pipe(Effect.catch(() => Effect.succeed([] as string[]))) + : Effect.succeed(undefined), + include.has("tags") + ? getTags(partyId, party.partyTags).pipe(Effect.catch(() => Effect.succeed([] as string[]))) + : Effect.succeed(undefined), + getPartyById(partyId, config.partyos.partyos), + ]); + const profile = Option.getOrNull(profileOpt); - const [genres, members] = await Promise.all([ - resolveGenreNames(client, genreIds), - resolveMembers( - client, - entity.kind === "group" ? (entity.members ?? []) : [], - ), + const [genres, members] = yield* Effect.all([ + resolveGenreNames(genreIds), + resolveMembers(entity.kind === "group" ? (entity.members ?? []) : [], config), ]); return { @@ -74,45 +79,45 @@ export async function getArtistProfile( bioShort: profile?.bioShort ?? null, bioLong: profile?.bioLong ?? null, country: profile?.country ?? null, - languages: profile?.languages ?? [], + languages: profile?.languages ? [...profile.languages] : [], genres, links: links.map((l) => ({ platform: l.platform, value: l.value, url: l.url, - })), + })) as ArtistProfile["links"], ctas: ctas.map((c) => ({ label: c.label, url: c.url })), members, ...(roles !== undefined ? { roles } : {}), ...(tags !== undefined ? { tags } : {}), - avatarUrl: partyAvatarUrl(client.config.apiBaseUrl, entity.id), + avatarUrl: partyAvatarUrl(config.apiBaseUrl, entity.id), }; -} +}); /** Group members with names resolved. A member that fails to read is dropped. */ -async function resolveMembers( - client: MisoClient, +const resolveMembers = Effect.fn("resolveMembers")(function* ( memberIds: readonly string[], -): Promise { + config: MisoConfig, +): Effect.fn.Return { if (memberIds.length === 0) return []; - const parties = await client.party - .getPartiesByIds([...memberIds]) - .catch(() => ({}) as Record); + const parties = yield* getPartiesByIds([...memberIds], config.partyos.partyos).pipe( + Effect.catch(() => Effect.succeed({} as Record)), + ); return memberIds.flatMap((id) => { const p = parties[id]; return p ? [{ id, name: p.name }] : []; }); -} +}); /** Name + kind for many parties at once. Ids that don't resolve are omitted. */ -export async function getPartySummaries( - client: MisoClient, +export const getPartySummaries = Effect.fn("getPartySummaries")(function* ( ids: readonly string[], -): Promise { + config: MisoConfig, +): Effect.fn.Return { if (ids.length === 0) return []; - const parties = await client.party.getPartiesByIds([...ids]); + const parties = yield* getPartiesByIds([...ids], config.partyos.partyos); return ids.flatMap((id) => { const p = parties[id]; return p ? [{ id, name: p.name, kind: p.kind }] : []; }); -} +}); diff --git a/packages/platform/src/read/catalog.ts b/packages/platform/src/read/catalog.ts index ba1f707..09e840d 100644 --- a/packages/platform/src/read/catalog.ts +++ b/packages/platform/src/read/catalog.ts @@ -10,14 +10,15 @@ // credits → recordings); the same work happens once here, inside one datacenter, // and every subsequent visitor is served from cache. +import { Effect } from "effect"; import { deriveListingId, getListing, getPressing, getRecord, getSale, - type ListingView as ContractListingView, - type PressingView as ContractPressingView, + type Listing as ContractListing, + type Pressing as ContractPressing, } from "../pressing.ts"; import { requireRecordSalesDeployment } from "../deployments.ts"; import { @@ -44,9 +45,19 @@ import { type RecordingEngineSessionView, } from "../recording-extensions.ts"; import { getTrackCreditsByRecordingIds } from "../catalog.ts"; -import { getReleaseById, getReleasesByIds, isNotFound } from "@misofm/musicos"; +import { getReleaseById, getReleasesByIds } from "@misofm/musicos"; import type { Release } from "@misofm/musicos"; -import type { MisoClient } from "./client.ts"; +import { + getObjectsContent, + ObjectTypeMismatchError, + type BcsDecodeError, + type ObjectNotFoundError, + type SuiClient, + type SuiGraphQL, + type SuiRpcError, +} from "@misofm/effect"; +import { ReleaseNotFoundError } from "../errors.ts"; +import type { MisoConfig } from "./config.ts"; import { getRecordingTitles, parseReleaseObject } from "./works.ts"; import { int } from "./internal/scalars.ts"; import { @@ -117,7 +128,7 @@ export function currencyInfo(type: string | null): Currency { // ── Projections ────────────────────────────────────────────────────────────── -function toPressingView(pressing: ContractPressingView): PressingView { +function toPressingView(pressing: ContractPressing): PressingView { return { id: pressing.id, releaseId: pressing.releaseId, @@ -128,7 +139,7 @@ function toPressingView(pressing: ContractPressingView): PressingView { }; } -function toListingView(listing: ContractListingView): ListingView { +function toListingView(listing: ContractListing): ListingView { const price: Price = { kind: listing.pricing.kind, amount: listing.pricing.amount, @@ -144,37 +155,33 @@ function toListingView(listing: ContractListingView): ListingView { } function toSaleView( - pressing: ContractPressingView, - listing: ContractListingView, + pressing: ContractPressing, + listing: ContractListing, ): SaleView { return { pressing: toPressingView(pressing), listing: toListingView(listing) }; } /** One permanent pressing, projected to the JSON-safe read boundary. */ -export async function getPressingView( - client: MisoClient, +export const getPressingView = Effect.fn("getPressingView")(function* ( pressingId: string, -): Promise { - const sales = requireRecordSalesDeployment(client.config.recordSales); - const pressing = await getPressing( - client.protocol, - pressingId, - sales.recordPackageId, - ); + config: MisoConfig, +): Effect.fn.Return { + const sales = requireRecordSalesDeployment(config.recordSales); + const pressing = yield* getPressing(pressingId, sales.recordPackageId); return pressing ? toPressingView(pressing) : null; -} +}); /** One currency-specific listing derived from its permanent pressing. */ -export async function getListingView( - client: MisoClient, +export const getListingView = Effect.fn("getListingView")(function* ( pressingId: string, currencyType: string, -): Promise { - const sales = requireRecordSalesDeployment(client.config.recordSales); + config: MisoConfig, +): Effect.fn.Return { + const sales = requireRecordSalesDeployment(config.recordSales); const listingId = deriveListingId(pressingId, currencyType, sales.recordShopPackageId); - const listing = await getListing(client.protocol, listingId, sales.recordShopPackageId); + const listing = yield* getListing(listingId, sales.recordShopPackageId); return listing ? toListingView(listing) : null; -} +}); function toWorkState(state: Release["state"]): WorkState { return state.type === "Published" @@ -182,7 +189,7 @@ function toWorkState(state: Release["state"]): WorkState { : { type: "Initialized" }; } -function toCredits(credits: CreditView[] | null): Credit[] { +function toCredits(credits: readonly CreditView[] | null): Credit[] { return (credits ?? []).map((c) => ({ partyId: c.partyId, displayName: c.displayName, @@ -197,10 +204,6 @@ export function primaryArtistNames(credits: Credit[]): string[] { .map((c) => c.displayName); } -/** - * Number the protocol's flat tracklist. Display grouping such as discs belongs - * to metadata extensions and can be layered onto this projection later. - */ /** Per-recording audio attachments, every id already base64url. */ export interface TrackAudio { masterBlobIds?: Partial>; @@ -208,6 +211,10 @@ export interface TrackAudio { engineSessions?: Partial>; } +/** + * Number the protocol's flat tracklist. Display grouping such as discs belongs + * to metadata extensions and can be layered onto this projection later. + */ export function toTracks( release: Release, titles: Record, @@ -252,18 +259,16 @@ function toTrackEngineSession(view: RecordingEngineSessionView): TrackEngineSess // ── Cover ──────────────────────────────────────────────────────────────────── /** A release's cover from the configured `release_cover_art` package. */ -export async function readReleaseCover( - client: MisoClient, +export const readReleaseCover = Effect.fn("readReleaseCover")(function* ( releaseId: string, -): Promise { - const { releaseCoverArt } = client.config.protocol; - const covers = await getReleaseCoversByIds( - client.protocol, - [releaseId], - releaseCoverArt, - ).catch(() => ({}) as Partial>); - return toCover(client.config.walrusAggregatorUrl, covers[releaseId] ?? null); -} + config: MisoConfig, +): Effect.fn.Return { + const { releaseCoverArt } = config.protocol; + const covers = yield* getReleaseCoversByIds([releaseId], releaseCoverArt).pipe( + Effect.catch(() => Effect.succeed({} as Partial>)), + ); + return toCover(config.walrusAggregatorUrl, covers[releaseId] ?? null); +}); export type ReleaseResourceInclude = "cover" | "credits" | "kind"; @@ -279,66 +284,50 @@ export interface ReleaseResources { * Every extension id is deterministic, so separate object calls only add * network round trips without discovering anything new. */ -export async function getReleaseResources( - client: MisoClient, +export const getReleaseResources = Effect.fn("getReleaseResources")(function* ( releaseId: string, + config: MisoConfig, include: readonly ReleaseResourceInclude[] = [], -): Promise { +): Effect.fn.Return { const wantsCover = include.includes("cover"); const wantsCredits = include.includes("credits"); const wantsKind = include.includes("kind"); - const coverFieldIds = wantsCover - ? [releaseCoverFieldId(releaseId, client.config.protocol.releaseCoverArt)] - : []; - const creditsFieldId = wantsCredits - ? releaseCreditsFieldId(releaseId, client.config.protocol.releaseCredits) - : null; - const kindFieldId = wantsKind - ? releaseKindFieldId(releaseId, client.config.protocol.releaseKind) - : null; + const coverFieldId = wantsCover ? releaseCoverFieldId(releaseId, config.protocol.releaseCoverArt) : null; + const creditsFieldId = wantsCredits ? releaseCreditsFieldId(releaseId, config.protocol.releaseCredits) : null; + const kindFieldId = wantsKind ? releaseKindFieldId(releaseId, config.protocol.releaseKind) : null; const objectIds = [ releaseId, - ...coverFieldIds, + ...(coverFieldId ? [coverFieldId] : []), ...(creditsFieldId ? [creditsFieldId] : []), ...(kindFieldId ? [kindFieldId] : []), ]; - const { objects } = await client.protocol.core.getObjects({ - objectIds, - include: { content: true }, - }); - const releaseObject = objects[0]; - if (!releaseObject) throw new Error(`Release not found: ${releaseId}`); - if (releaseObject instanceof Error) throw releaseObject; - if (!releaseObject.content) - throw new Error(`Release has no content: ${releaseId}`); - const release = parseReleaseObject( - releaseObject.objectId, - releaseObject.content, - ); + const contentById = yield* getObjectsContent(objectIds); + + const releaseFound = contentById.get(releaseId); + if (!releaseFound) return yield* new ReleaseNotFoundError({ releaseId }); + const release = yield* parseReleaseObject(releaseId, releaseFound.content); let cover: Cover | null | undefined; if (wantsCover) { + const found = coverFieldId ? contentById.get(coverFieldId) : undefined; let view: ReleaseCoverView | null = null; - for (let index = 0; index < coverFieldIds.length; index += 1) { - const object = objects[index + 1]; - if (!object || object instanceof Error || !object.content) continue; + if (found) { try { - view = parseReleaseCoverContent(object.content); + view = parseReleaseCoverContent(found.content); } catch { view = null; } - if (view) break; } - cover = toCover(client.config.walrusAggregatorUrl, view); + cover = toCover(config.walrusAggregatorUrl, view); } let credits: Credit[] | undefined; if (wantsCredits) { - const object = objects[1 + coverFieldIds.length]; + const found = creditsFieldId ? contentById.get(creditsFieldId) : undefined; let view: CreditView[] = []; - if (object && !(object instanceof Error) && object.content) { + if (found) { try { - view = parseReleaseCreditsContent(object.content); + view = parseReleaseCreditsContent(found.content); } catch { view = []; } @@ -347,13 +336,8 @@ export async function getReleaseResources( } let kind: string | null | undefined; if (wantsKind) { - const object = objects[ - 1 + coverFieldIds.length + (creditsFieldId ? 1 : 0) - ]; - kind = - object && !(object instanceof Error) && object.content - ? parseReleaseKindContent(object.content) - : null; + const found = kindFieldId ? contentById.get(kindFieldId) : undefined; + kind = found ? parseReleaseKindContent(found.content) : null; } return { release, @@ -361,7 +345,7 @@ export async function getReleaseResources( ...(wantsCredits ? { credits: credits ?? [] } : {}), ...(wantsKind ? { kind: kind ?? null } : {}), }; -} +}); // ── Release ────────────────────────────────────────────────────────────────── @@ -380,42 +364,39 @@ export interface GetReleaseOptions { include?: readonly ReleaseInclude[]; } -export async function getReleaseDetail( - client: MisoClient, +export const getReleaseDetail = Effect.fn("getReleaseDetail")(function* ( releaseId: string, + config: MisoConfig, options: GetReleaseOptions = {}, -): Promise { - const { release, cover, credits, kind } = await getReleaseResources( - client, - releaseId, - ["cover", "credits", "kind"], - ); +): Effect.fn.Return { + const { release, cover, credits, kind } = yield* getReleaseResources(releaseId, config, [ + "cover", + "credits", + "kind", + ]); const recordingIds = release.tracks.map((track) => track.recordingId); - const { recordingStreamingTranscode, recordingEngineSession } = client.config.protocol; - const [titles, masterReferences, transcodes, engineSessions, trackCredits] = await Promise.all([ - getRecordingTitles( - client.protocol, - client.graphql, - recordingIds, - client.config.deployment.musicos, + const { recordingStreamingTranscode, recordingEngineSession } = config.protocol; + const [titles, masterReferences, transcodes, engineSessions, trackCredits] = yield* Effect.all([ + getRecordingTitles(recordingIds, config.deployment.musicos).pipe( + Effect.catch(() => Effect.succeed({} as Record)), + ), + getRecordingMasterReferencesByIds(recordingIds, config.protocol.recordingMasterReference).pipe( + Effect.catch(() => Effect.succeed({} as Partial>)), ), - getRecordingMasterReferencesByIds( - client.protocol, - recordingIds, - client.config.protocol.recordingMasterReference, - ).catch(() => ({})), recordingStreamingTranscode - ? getRecordingStreamingTranscodesByIds(client.protocol, recordingIds, recordingStreamingTranscode).catch(() => ({})) - : Promise.resolve({}), + ? getRecordingStreamingTranscodesByIds(recordingIds, recordingStreamingTranscode).pipe( + Effect.catch(() => Effect.succeed({} as Partial>)), + ) + : Effect.succeed({} as Partial>), recordingEngineSession - ? getRecordingEngineSessionsByIds(client.protocol, recordingIds, recordingEngineSession).catch( - (): Partial> => ({}), + ? getRecordingEngineSessionsByIds(recordingIds, recordingEngineSession).pipe( + Effect.catch(() => Effect.succeed({} as Partial>)), ) - : Promise.resolve>>({}), + : Effect.succeed({} as Partial>), options.include?.includes("trackCredits") - ? getTrackCreditsForRecordingIds(client, recordingIds) - : Promise.resolve(undefined), + ? getTrackCreditsForRecordingIds(recordingIds, config) + : Effect.succeed(undefined), ]); const audio: TrackAudio = { masterBlobIds: b64UrlByRecording(masterReferences), @@ -443,23 +424,18 @@ export async function getReleaseDetail( tracks: toTracks(release, titles, audio), ...(trackCredits !== undefined ? { trackCredits } : {}), }; -} +}); -async function getTrackCreditsForRecordingIds( - client: MisoClient, +const getTrackCreditsForRecordingIds = Effect.fn("getTrackCreditsForRecordingIds")(function* ( recordingIds: readonly string[], -): Promise> { - const { compositionCredits, recordingCredits } = client.config.protocol; - const tracks = await getTrackCreditsByRecordingIds( - client.protocol, - client.graphql, - recordingIds, - { - misoPackageId: client.config.deployment.musicos, - compositionCreditsPackageId: compositionCredits, - recordingCreditsPackageId: recordingCredits, - }, - ); + config: MisoConfig, +): Effect.fn.Return, SuiRpcError | BcsDecodeError, SuiClient | SuiGraphQL> { + const { compositionCredits, recordingCredits } = config.protocol; + const tracks = yield* getTrackCreditsByRecordingIds(recordingIds, { + misoPackageId: config.deployment.musicos, + compositionCreditsPackageId: compositionCredits, + recordingCreditsPackageId: recordingCredits, + }); return Object.fromEntries( Object.entries(tracks).map(([id, track]) => [ id, @@ -473,75 +449,83 @@ async function getTrackCreditsForRecordingIds( } satisfies TrackCredits, ]), ); -} +}); /** * Per-track credits for a release, keyed by recording id. A track with no credits * set maps to an empty entry rather than being absent, so a caller can tell * "nothing credited" from "no such track". */ -export async function getTrackCredits( - client: MisoClient, +export const getTrackCredits = Effect.fn("getTrackCredits")(function* ( releaseId: string, -): Promise> { - const release = await getReleaseById(client.protocol, releaseId); + config: MisoConfig, +): Effect.fn.Return, ObjectNotFoundError | SuiRpcError | BcsDecodeError, SuiClient | SuiGraphQL> { + const release = yield* getReleaseById(releaseId); const recordingIds = release.tracks.map((track) => track.recordingId); - return getTrackCreditsForRecordingIds(client, recordingIds); -} + return yield* getTrackCreditsForRecordingIds(recordingIds, config); +}); // ── Pressing ───────────────────────────────────────────────────────────────── /** Everything a Pressing page renders. `null` when no such Pressing exists. */ -export async function getPressingDetail( - client: MisoClient, +export const getPressingDetail = Effect.fn("getPressingDetail")(function* ( pressingId: string, + config: MisoConfig, options: GetReleaseOptions = {}, -): Promise { - const pressing = await getPressingView(client, pressingId); +): Effect.fn.Return< + PressingDetail | null, + ObjectTypeMismatchError | ReleaseNotFoundError | BcsDecodeError | SuiRpcError, + SuiClient | SuiGraphQL +> { + const pressing = yield* getPressingView(pressingId, config); if (!pressing) return null; - const release = await getReleaseDetail(client, pressing.releaseId, options); + const release = yield* getReleaseDetail(pressing.releaseId, config, options); return { pressing, release }; -} +}); /** * A Pressing page plus one currency-specific Listing. Unlike `getSaleDetail`, * this starts from a Pressing id, which is the durable route and Party-feature * reference exposed to users. */ -export async function getPressingSaleDetail( - client: MisoClient, +export const getPressingSaleDetail = Effect.fn("getPressingSaleDetail")(function* ( pressingId: string, currencyType: string, + config: MisoConfig, options: GetReleaseOptions = {}, -): Promise { - const pressing = await getPressingView(client, pressingId); +): Effect.fn.Return< + SaleDetail | null, + ObjectTypeMismatchError | ReleaseNotFoundError | BcsDecodeError | SuiRpcError, + SuiClient | SuiGraphQL +> { + const pressing = yield* getPressingView(pressingId, config); if (!pressing) return null; - const [listing, release] = await Promise.all([ - getListingView(client, pressing.id, currencyType), - getReleaseDetail(client, pressing.releaseId, options), + const [listing, release] = yield* Effect.all([ + getListingView(pressing.id, currencyType, config), + getReleaseDetail(pressing.releaseId, config, options), ]); if (!listing) return null; return { sale: { pressing, listing }, release }; -} +}); /** * The confirmation preview behind "paste a pressing id to pin it". Verifies the * object really is a `Pressing` before spending reads on it — a release id or a * record id pasted by mistake must come back as `null`, not as a half-built card. */ -export async function getPressingPreview( - client: MisoClient, +export const getPressingPreview = Effect.fn("getPressingPreview")(function* ( pressingId: string, -): Promise { - const pressing = await getPressingView(client, pressingId); + config: MisoConfig, +): Effect.fn.Return< + PressingPreview | null, + ObjectTypeMismatchError | ReleaseNotFoundError | BcsDecodeError | SuiRpcError, + SuiClient +> { + const pressing = yield* getPressingView(pressingId, config); if (!pressing) return null; - const { release, cover } = await getReleaseResources( - client, - pressing.releaseId, - ["cover"], - ); + const { release, cover } = yield* getReleaseResources(pressing.releaseId, config, ["cover"]); return { pressingId, @@ -553,22 +537,26 @@ export async function getPressingPreview( maxSupply: pressing.maxSupply, trackCount: release.tracks.length, }; -} +}); /** * Everything a currency-specific buy page renders. The Listing is derived from * the release's Pressing and the requested currency, never found through mutable * lookup state. */ -export async function getSaleDetail( - client: MisoClient, +export const getSaleDetail = Effect.fn("getSaleDetail")(function* ( releaseId: string, edition: number, currencyType: string, + config: MisoConfig, options: GetReleaseOptions = {}, -): Promise { - const sales = requireRecordSalesDeployment(client.config.recordSales); - const sale = await getSale(client.protocol, { +): Effect.fn.Return< + SaleDetail | null, + ObjectTypeMismatchError | ReleaseNotFoundError | BcsDecodeError | SuiRpcError, + SuiClient | SuiGraphQL +> { + const sales = requireRecordSalesDeployment(config.recordSales); + const sale = yield* getSale({ releaseId, edition, currencyType, @@ -576,9 +564,9 @@ export async function getSaleDetail( recordShopPackageId: sales.recordShopPackageId, }); if (!sale.pressing || !sale.listing) return null; - const release = await getReleaseDetail(client, sale.pressing.releaseId, options); + const release = yield* getReleaseDetail(sale.pressing.releaseId, config, options); return { sale: toSaleView(sale.pressing, sale.listing), release }; -} +}); // ── Discover ───────────────────────────────────────────────────────────────── @@ -587,60 +575,55 @@ export async function getSaleDetail( * * Configured by release + edition + currency: both addresses are deterministic. */ -export async function getDiscoverShelf( - client: MisoClient, -): Promise { - const configuredSales = [...client.config.discoverSales]; - const sales = requireRecordSalesDeployment(client.config.recordSales); - const settled = await Promise.all( - configuredSales.map(async (configured) => ({ - configured, - result: await getSale(client.protocol, { +export const getDiscoverShelf = Effect.fn("getDiscoverShelf")(function* ( + config: MisoConfig, +): Effect.fn.Return { + const configuredSales = [...config.discoverSales]; + const sales = requireRecordSalesDeployment(config.recordSales); + const settled = yield* Effect.forEach( + configuredSales, + (configured) => + getSale({ ...configured, recordPackageId: sales.recordPackageId, recordShopPackageId: sales.recordShopPackageId, - }), - })), + }).pipe(Effect.map((result) => ({ configured, result }))), + { concurrency: "unbounded" }, ); const available = settled.filter( (item): item is typeof item & { - result: { pressing: ContractPressingView; listing: ContractListingView }; + result: { pressing: ContractPressing; listing: ContractListing }; } => item.result.pressing !== null && item.result.listing !== null, ); const releaseIds = [...new Set(available.map((item) => item.result.pressing.releaseId))]; if (releaseIds.length === 0) return []; - const [releases, coverViews, credits] = await Promise.all([ - getReleasesByIds(client.protocol, releaseIds), - getReleaseCoversByIds(client.protocol, releaseIds, client.config.protocol.releaseCoverArt).catch(() => ({}) as Partial>), - getReleaseCreditsByIds( - client.protocol, - releaseIds, - client.config.protocol.releaseCredits, - ).catch(() => ({}) as Partial>), + const [releases, coverViews, credits] = yield* Effect.all([ + getReleasesByIds(releaseIds), + getReleaseCoversByIds(releaseIds, config.protocol.releaseCoverArt).pipe( + Effect.catch(() => Effect.succeed({} as Partial>)), + ), + getReleaseCreditsByIds(releaseIds, config.protocol.releaseCredits).pipe( + Effect.catch(() => Effect.succeed({} as Partial>)), + ), ]); return available.flatMap(({ result }) => { const releaseId = result.pressing.releaseId; const release = releases[releaseId]; if (!release) return []; - const cover = toCover( - client.config.walrusAggregatorUrl, - coverViews[releaseId] ?? null, - ); + const cover = toCover(config.walrusAggregatorUrl, coverViews[releaseId] ?? null); return [ { sale: toSaleView(result.pressing, result.listing), releaseId, title: release.title, - artist: primaryArtistNames(toCredits(credits[releaseId] ?? null)).join( - ", ", - ), + artist: primaryArtistNames(toCredits(credits[releaseId] ?? null)).join(", "), coverUrl: cover?.still.url ?? null, }, ]; }); -} +}); // ── Record → release ───────────────────────────────────────────────────────── @@ -656,37 +639,31 @@ export interface GetRecordAlbumOptions { include?: readonly RecordAlbumInclude[]; } -export async function getRecordAlbum( - client: MisoClient, +export const getRecordAlbum = Effect.fn("getRecordAlbum")(function* ( recordId: string, + config: MisoConfig, options: GetRecordAlbumOptions = {}, -): Promise { - try { - const sales = requireRecordSalesDeployment(client.config.recordSales); - const record = await getRecord(client.protocol, recordId, sales.recordPackageId); - if (!record) return null; - const releaseId = record.releaseId; - const includeRelease = - options.include?.some( - (part) => part === "release" || part === "trackCredits", - ) ?? false; - const release = - includeRelease && releaseId - ? await getReleaseDetail(client, releaseId, { - include: options.include?.includes("trackCredits") - ? ["trackCredits"] - : [], - }) - : includeRelease - ? null - : undefined; - return { - recordId, - releaseId, - ...(includeRelease ? { release } : {}), - }; - } catch (e) { - if (isNotFound(e)) return null; - throw e; - } -} +): Effect.fn.Return< + RecordAlbum | null, + ObjectTypeMismatchError | ReleaseNotFoundError | BcsDecodeError | SuiRpcError, + SuiClient | SuiGraphQL +> { + const sales = requireRecordSalesDeployment(config.recordSales); + const record = yield* getRecord(recordId, sales.recordPackageId); + if (!record) return null; + const releaseId = record.releaseId; + const includeRelease = options.include?.some((part) => part === "release" || part === "trackCredits") ?? false; + const release = + includeRelease && releaseId + ? yield* getReleaseDetail(releaseId, config, { + include: options.include?.includes("trackCredits") ? ["trackCredits"] : [], + }) + : includeRelease + ? null + : undefined; + return { + recordId, + releaseId, + ...(includeRelease ? { release } : {}), + }; +}); diff --git a/packages/platform/src/read/client.ts b/packages/platform/src/read/client.ts index a0856b0..025fac8 100644 --- a/packages/platform/src/read/client.ts +++ b/packages/platform/src/read/client.ts @@ -16,6 +16,7 @@ // Party now shares the same network SDK registration and deployment as protocol // core, so every read hangs off one `sui.miso` namespace. +import type { ClientWithCoreApi } from "@mysten/sui/client"; import { SuiGrpcClient } from "@mysten/sui/grpc"; import { SuiGraphQLClient } from "@mysten/sui/graphql"; import { miso, type MisoProtocolClient } from "@misofm/musicos/client"; @@ -28,25 +29,25 @@ function definedOverrides(value: T): Partial { } /** - * The structural slice of a Sui client the protocol SDK's read helpers want. They - * declare `ClientWithCoreApi` against their own @mysten/sui; this alias names the - * same shape from our side so the one cast in `createMisoClient` is explicit - * rather than an `any` smeared across the codebase. + * The transport slice every `@misofm/platform/read` function needs to run its + * `Effect`: `Effect.provide(SuiClient.layer(client.protocol))`. `sui` (below) + * satisfies this structurally, since it is a real `ClientWithCoreApi`. */ -export type ProtocolClient = Parameters[0]; +export type ProtocolClient = ClientWithCoreApi; -/** A GraphQL client in the shape the protocol SDK's type-discovery queries want. */ +/** A GraphQL client in the shape `@misofm/effect`'s `SuiGraphQL` service wants. */ export type ProtocolGraphQLClient = SuiGraphQLClient; export interface MisoClient { config: MisoConfig; /** gRPC data plane (object-model core only; Party is `client.party`). */ sui: SuiGrpcClient & { miso: MisoProtocolClient }; - /** The same client, typed for the protocol SDK's read helpers. */ + /** The same client, for `Effect.provide(SuiClient.layer(client.protocol))`. */ protocol: ProtocolClient; - /** GraphQL RPC, typed for the protocol SDK's type-discovery queries. */ + /** GraphQL RPC, for `Effect.provide(SuiGraphQL.layer(client.graphql))`. */ graphql: ProtocolGraphQLClient; - /** The raw GraphQL client, for this SDK's own queries (the pruned-transaction read). */ + /** The raw GraphQL client — identical to `graphql`; both names are kept for + * source compatibility with callers migrating from the pre-Effect client. */ graphqlRaw: SuiGraphQLClient; /** Party identity and profile reads/builders, bound to `config.partyos`/`config.party`. */ party: PartyPlatformClient; @@ -85,8 +86,8 @@ export function createMisoClient(options: CreateMisoClientOptions = {}): MisoCli return { config, sui, - protocol: sui as unknown as ProtocolClient, - graphql: graphqlRaw as unknown as ProtocolGraphQLClient, + protocol: sui, + graphql: graphqlRaw, graphqlRaw, party, }; diff --git a/packages/platform/src/read/genres.ts b/packages/platform/src/read/genres.ts index acf36b3..d287ca9 100644 --- a/packages/platform/src/read/genres.ts +++ b/packages/platform/src/read/genres.ts @@ -5,7 +5,9 @@ // object carries a screaming-snake name ("HIP_HOP"). There is no id→name table // anywhere off-chain, so the names are read from the objects and humanized. -import type { MisoClient } from "./client.ts"; +import { Effect } from "effect"; +import { getObjectsContent, type SuiClient } from "@misofm/effect"; +import { Genre as GenreBcs } from "../contracts/genre/genre.ts"; /** "HIP_HOP" → "Hip Hop". */ function humanize(name: string): string { @@ -18,20 +20,26 @@ function humanize(name: string): string { /** * Resolve `Genre` object ids to display names. Best-effort: an id that fails to - * read is dropped rather than failing the artist page around it. + * read, or whose content does not parse as a `Genre`, is dropped rather than + * failing the artist page around it. */ -export async function resolveGenreNames(client: MisoClient, ids: string[]): Promise { +export const resolveGenreNames = Effect.fn("resolveGenreNames")(function* ( + ids: string[], +): Effect.fn.Return { if (ids.length === 0) return []; - try { - const { objects } = await client.protocol.core.getObjects({ objectIds: ids, include: { json: true } }); - const out: string[] = []; - for (const o of objects) { - if (o instanceof Error) continue; - const name = (o.json as { name?: unknown } | null)?.name; - if (typeof name === "string" && name) out.push(humanize(name)); - } - return out; - } catch { - return []; - } -} + return yield* getObjectsContent(ids).pipe( + Effect.map((contentById) => { + const out: string[] = []; + for (const { content } of contentById.values()) { + try { + const name = GenreBcs.parse(content).name; + if (name) out.push(humanize(name)); + } catch { + // Malformed content — drop this genre rather than fail the page. + } + } + return out; + }), + Effect.catch(() => Effect.succeed([])), + ); +}); diff --git a/packages/platform/src/read/index.ts b/packages/platform/src/read/index.ts index 42afbbb..831b120 100644 --- a/packages/platform/src/read/index.ts +++ b/packages/platform/src/read/index.ts @@ -10,9 +10,15 @@ // // Browser- and server-compatible. The HTTP API is a thin cached transport over // this same surface; direct clients can use it without going through the API. +// Every read returns `Effect`; `createMisoClient` +// bundles the transport, config, and Party client — provide its `sui`/`graphqlRaw` +// as the Effect services once, at your program's boundary: // // const miso = createMisoClient({ network: "testnet" }) -// const pressing = await getPressingDetail(miso, pressingId) +// const layer = Layer.mergeAll(SuiClient.layer(miso.sui), SuiGraphQL.layer(miso.graphqlRaw)) +// const pressing = await Effect.runPromise( +// getPressingDetail(pressingId, miso.config).pipe(Effect.provide(layer)), +// ) export { createMisoClient } from "./client.ts"; export type { MisoClient, CreateMisoClientOptions } from "./client.ts"; diff --git a/packages/platform/src/read/receipts.ts b/packages/platform/src/read/receipts.ts index 3f0c2e1..fe768c5 100644 --- a/packages/platform/src/read/receipts.ts +++ b/packages/platform/src/read/receipts.ts @@ -26,10 +26,13 @@ import { getCompositionsByIds, } from "@misofm/musicos"; import { normalizeStructTag, normalizeSuiAddress } from "@mysten/sui/utils"; +import { Effect } from "effect"; +import { SuiClient, SuiGraphQL, SuiRpcError, type BcsDecodeError, type ObjectTypeMismatchError } from "@misofm/effect"; import { getPressingDetail } from "./catalog.ts"; -import type { MisoClient } from "./client.ts"; import { int } from "./internal/scalars.ts"; import { requireRecordSalesDeployment } from "../deployments.ts"; +import { MalformedRecordSoldEventError, ReceiptNotFoundError, ReleaseNotFoundError, RecordPurchaseNotFoundError } from "../errors.ts"; +import type { MisoConfig } from "./config.ts"; import type { Price, PressingDetail, PurchaseReceipt, RecordSale, TrackRoyalty } from "./types.ts"; import { getWorkAddressesByShareTypes } from "./works.ts"; @@ -46,10 +49,6 @@ const RECORD_SOLD_EVENT_NAME = "listing::RecordSoldEvent"; */ const FULLNODE_WAIT_MS = 5_000; -export class MalformedRecordSoldEventError extends Error { - override readonly name = "MalformedRecordSoldEventError"; -} - /** Composition royalty rate (bps) per recording id. Sparse — an unresolved parent has no entry. */ type CompositionRates = Record; @@ -225,7 +224,7 @@ export function findRecordSales( !pricing || embeddedCurrency !== currencyType || !validPricingRelationship(purchasePrice, pricing) || s.edition <= 0 || s.number <= 0 - ) throw new MalformedRecordSoldEventError("malformed canonical RecordSoldEvent"); + ) throw new MalformedRecordSoldEventError({ reason: "malformed canonical RecordSoldEvent" }); sales.push({ listingId: s.listing_id, pressingId: s.pressing_id, @@ -245,13 +244,13 @@ export function findRecordSales( // Never reinterpret malformed canonical BCS through a looser JSON view. if (e.bcs.length > 0) { if (error instanceof MalformedRecordSoldEventError) throw error; - throw new MalformedRecordSoldEventError( - `malformed canonical RecordSoldEvent BCS: ${error instanceof Error ? error.message : String(error)}`, - ); + throw new MalformedRecordSoldEventError({ + reason: `malformed canonical RecordSoldEvent BCS: ${error instanceof Error ? error.message : String(error)}`, + }); } } const fromJson = e.json && saleFromJson(e.json, currencyType); - if (!fromJson) throw new MalformedRecordSoldEventError("malformed canonical RecordSoldEvent JSON"); + if (!fromJson) throw new MalformedRecordSoldEventError({ reason: "malformed canonical RecordSoldEvent JSON" }); sales.push(fromJson); } return sales; @@ -270,23 +269,37 @@ export function findRecordSale( } /** Every canonical sale, read from the fullnode in event order. */ -async function salesFromFullnode(client: MisoClient, txDigest: string): Promise { - const result = await client.sui.waitForTransaction({ - digest: txDigest, - include: { events: true }, - timeout: FULLNODE_WAIT_MS, +const salesFromFullnode = Effect.fn("salesFromFullnode")(function* ( + txDigest: string, + config: MisoConfig, +): Effect.fn.Return< + RecordSale[], + ReceiptNotFoundError | RecordPurchaseNotFoundError | MalformedRecordSoldEventError | SuiRpcError, + SuiClient +> { + const client = yield* SuiClient; + const result = yield* Effect.tryPromise({ + try: (signal) => + client.core.waitForTransaction({ + digest: txDigest, + include: { events: true }, + timeout: FULLNODE_WAIT_MS, + signal, + }), + catch: (cause) => new SuiRpcError({ operation: "waitForTransaction", cause }), }); if (result.$kind !== "Transaction" || !result.Transaction.status.success) { - throw new Error(`Transaction did not succeed: ${txDigest}`); + return yield* new ReceiptNotFoundError({ digest: txDigest }); } - const sales = requireRecordSalesDeployment(client.config.recordSales); - const found = findRecordSales( - result.Transaction.events, - sales.recordShopPackageId, - ); - if (found.length === 0) throw new Error(`No record purchase in transaction ${txDigest}`); + const sales = requireRecordSalesDeployment(config.recordSales); + // `findRecordSales` only ever throws `MalformedRecordSoldEventError`. + const found = yield* Effect.try({ + try: () => findRecordSales(result.Transaction.events, sales.recordShopPackageId), + catch: (cause) => cause as MalformedRecordSoldEventError, + }); + if (found.length === 0) return yield* new RecordPurchaseNotFoundError({ digest: txDigest }); return found; -} +}); const TX_EVENTS_QUERY = `query TransactionEvents($digest: String!) { transaction(digest: $digest) { @@ -307,18 +320,35 @@ interface TxEventsResult { } /** Every canonical sale, read from the GraphQL indexer in event order. */ -async function salesFromIndexer(client: MisoClient, txDigest: string): Promise { - const { data, errors } = await client.graphqlRaw.query({ - query: TX_EVENTS_QUERY, - variables: { digest: txDigest }, +const salesFromIndexer = Effect.fn("salesFromIndexer")(function* ( + txDigest: string, + config: MisoConfig, +): Effect.fn.Return< + RecordSale[], + ReceiptNotFoundError | RecordPurchaseNotFoundError | MalformedRecordSoldEventError | SuiRpcError, + SuiGraphQL +> { + const client = yield* SuiGraphQL; + const { data, errors } = yield* Effect.tryPromise({ + try: () => + client.query({ + query: TX_EVENTS_QUERY, + variables: { digest: txDigest }, + }), + catch: (cause) => new SuiRpcError({ operation: "transactionEvents", cause }), }); - if (errors?.length) throw new Error(errors[0]!.message); + if (errors?.length) { + return yield* new SuiRpcError({ + operation: "transactionEvents", + cause: new AggregateError(errors.map((e) => new Error(e.message)), "Transaction events query failed"), + }); + } const effects = data?.transaction?.effects; - if (!effects) throw new Error(`Transaction not found: ${txDigest}`); - if (effects.status !== "SUCCESS") throw new Error(`Transaction did not succeed: ${txDigest}`); + if (!effects) return yield* new ReceiptNotFoundError({ digest: txDigest }); + if (effects.status !== "SUCCESS") return yield* new ReceiptNotFoundError({ digest: txDigest }); - const salesDeployment = requireRecordSalesDeployment(client.config.recordSales); + const salesDeployment = requireRecordSalesDeployment(config.recordSales); const sales: RecordSale[] = []; for (const node of effects.events?.nodes ?? []) { const contents = node.contents; @@ -332,19 +362,29 @@ async function salesFromIndexer(client: MisoClient, txDigest: string): Promise, currencyType) : null; - if (!sale) throw new MalformedRecordSoldEventError("malformed canonical RecordSoldEvent JSON"); + if (!sale) return yield* new MalformedRecordSoldEventError({ digest: txDigest, reason: "malformed canonical RecordSoldEvent JSON" }); sales.push(sale); } if (sales.length > 0) return sales; - throw new Error(`No record purchase in transaction ${txDigest}`); -} + return yield* new RecordPurchaseNotFoundError({ digest: txDigest }); +}); -async function salesFromChain(client: MisoClient, txDigest: string): Promise { - return salesFromFullnode(client, txDigest).catch((error) => { - if (error instanceof MalformedRecordSoldEventError) throw error; - return salesFromIndexer(client, txDigest); - }); -} +const salesFromChain = Effect.fn("salesFromChain")(function* ( + txDigest: string, + config: MisoConfig, +): Effect.fn.Return< + RecordSale[], + ReceiptNotFoundError | RecordPurchaseNotFoundError | MalformedRecordSoldEventError | SuiRpcError, + SuiClient | SuiGraphQL +> { + return yield* salesFromFullnode(txDigest, config).pipe( + Effect.catchIf( + (error): error is ReceiptNotFoundError | RecordPurchaseNotFoundError | SuiRpcError => + error._tag !== "MalformedRecordSoldEventError", + () => salesFromIndexer(txDigest, config), + ), + ); +}); /** * Each recording's parent composition royalty rate, in basis points, keyed by @@ -357,11 +397,18 @@ async function salesFromChain(client: MisoClient, txDigest: string): Promise { +const compositionRatesByRecording = Effect.fn("compositionRatesByRecording")(function* ( + recordingIds: string[], + config: MisoConfig, +): Effect.fn.Return { if (recordingIds.length === 0) return {}; + const client = yield* SuiClient; const shareTypeByRecording: Record = {}; - const { objects } = await client.protocol.core.getObjects({ objectIds: [...new Set(recordingIds)] }); + const { objects } = yield* Effect.tryPromise({ + try: (signal) => client.core.getObjects({ objectIds: [...new Set(recordingIds)], signal }), + catch: (cause) => new SuiRpcError({ operation: "getObjects", cause }), + }); for (const obj of objects) { if (obj instanceof Error || !obj.type) continue; try { @@ -375,13 +422,12 @@ async function compositionRatesByRecording(client: MisoClient, recordingIds: str const shareTypes = Object.values(shareTypeByRecording); if (shareTypes.length === 0) return {}; - const addresses = await getWorkAddressesByShareTypes( - client.graphql, + const addresses = yield* getWorkAddressesByShareTypes( { compositions: shareTypes, recordings: [] }, - client.config.deployment.musicos, + config.deployment.musicos, ); const compositionIds = Object.values(addresses.compositions).filter((id): id is string => !!id); - const compositions = await getCompositionsByIds(client.protocol, compositionIds); + const compositions = yield* getCompositionsByIds(compositionIds); const rates: CompositionRates = {}; for (const [recordingId, shareType] of Object.entries(shareTypeByRecording)) { @@ -390,7 +436,7 @@ async function compositionRatesByRecording(client: MisoClient, recordingIds: str if (composition) rates[recordingId] = int(composition.royaltyRate.value); } return rates; -} +}); /** * Split `paid` across the release's tracks by their `splitBps`, then split each @@ -428,18 +474,31 @@ export function breakdown( * The sale identifies the Pressing before its detail is fetched. `null` only when * that immutable Pressing cannot be read; it is never superseded or destroyed. */ -async function hydratePurchaseReceipt( - client: MisoClient, +type ReceiptError = + | ReceiptNotFoundError + | RecordPurchaseNotFoundError + | MalformedRecordSoldEventError + | ObjectTypeMismatchError + | ReleaseNotFoundError + | BcsDecodeError + | SuiRpcError; + +const hydratePurchaseReceipt = Effect.fn("hydratePurchaseReceipt")(function* ( sale: RecordSale, -): Promise { - const detail = await getPressingDetail(client, sale.pressingId); + config: MisoConfig, +): Effect.fn.Return< + PurchaseReceipt | null, + ObjectTypeMismatchError | ReleaseNotFoundError | BcsDecodeError | SuiRpcError, + SuiClient | SuiGraphQL +> { + const detail = yield* getPressingDetail(sale.pressingId, config); if (!detail) return null; // Best-effort: a failed composition lookup costs the sub-rows, not the page. - const rates = await compositionRatesByRecording( - client, + const rates = yield* compositionRatesByRecording( detail.release.tracks.map((t) => t.recordingId), - ).catch(() => ({})); + config, + ).pipe(Effect.catch(() => Effect.succeed({} as CompositionRates))); return { sale, @@ -447,30 +506,32 @@ async function hydratePurchaseReceipt( price: sale.pricing.amount, tracks: breakdown(detail.release.tracks, sale.purchasePrice, rates), }; -} +}); /** Read and hydrate every canonical Record sale in transaction event order. */ -export async function getPurchaseReceipts( - client: MisoClient, +export const getPurchaseReceipts = Effect.fn("getPurchaseReceipts")(function* ( txDigest: string, -): Promise { - const sales = await salesFromChain(client, txDigest); - const hydrated = await Promise.all(sales.map((sale) => hydratePurchaseReceipt(client, sale))); + config: MisoConfig, +): Effect.fn.Return { + const sales = yield* salesFromChain(txDigest, config); + const hydrated = yield* Effect.forEach(sales, (sale) => hydratePurchaseReceipt(sale, config), { + concurrency: "unbounded", + }); if (hydrated.some((receipt) => receipt === null)) { throw new Error(`A canonical Record sale in transaction ${txDigest} references an unreadable Pressing`); } return hydrated as PurchaseReceipt[]; -} +}); /** Read one canonical receipt selected by its Record ID. Selection is mandatory: * one transaction may purchase multiple Records from the same Pressing. */ -export async function getPurchaseReceipt( - client: MisoClient, +export const getPurchaseReceipt = Effect.fn("getPurchaseReceipt")(function* ( txDigest: string, recordId: string, -): Promise { - const sales = await salesFromChain(client, txDigest); + config: MisoConfig, +): Effect.fn.Return { + const sales = yield* salesFromChain(txDigest, config); const wanted = normalizeSuiAddress(recordId); const sale = sales.find((candidate) => normalizeSuiAddress(candidate.recordId) === wanted); - return sale ? hydratePurchaseReceipt(client, sale) : null; -} + return sale ? yield* hydratePurchaseReceipt(sale, config) : null; +}); diff --git a/packages/platform/src/read/wallet.ts b/packages/platform/src/read/wallet.ts index 14ffa76..12711dc 100644 --- a/packages/platform/src/read/wallet.ts +++ b/packages/platform/src/read/wallet.ts @@ -11,16 +11,27 @@ // than be reimplemented by every browser, Worker, server, or native client. import { normalizeStructTag, normalizeSuiAddress, parseStructTag } from "@mysten/sui/utils"; +import { Effect, Option } from "effect"; import { contracts as networkContracts, getCompositionByShareType, getOwnedReleaseAdminCaps, getRecordingByShareType, getReleaseById, - isNotFound, } from "@misofm/musicos"; import { party as partyContracts } from "@misofm/partyos/contracts"; -import type { MisoClient } from "./client.ts"; +import { derivePartyAdminCapId, getPartiesByIds, getPendingMemberships as getPartyPendingMemberships } from "@misofm/partyos"; +import { + getObjectsContent, + getOptionalObjectContent, + SuiClient, + SuiGraphQL, + SuiRpcError, + type BcsDecodeError, + type ObjectNotFoundError, + type ObjectTypeMismatchError, +} from "@misofm/effect"; +import type { MisoConfig } from "./config.ts"; import { int, u64 } from "./internal/scalars.ts"; import * as vaultContract from "../contracts/vault/vault.ts"; import * as recordContract from "../contracts/record/record.ts"; @@ -156,29 +167,52 @@ function isCanonicalRecordType( return true; } +/** Page shape this file needs from `client.core.listOwnedObjects` — content is optional per-call, not per-object. */ +interface OwnedObjectsPage { + objects: Array<{ objectId: string; type: string; content?: Uint8Array }>; + hasNextPage: boolean; + cursor: string | null; +} + +/** One page of `client.core.listOwnedObjects`, requesting `content` when `withContent` is set. */ +const listOwnedObjectsPage = Effect.fn("listOwnedObjectsPage")(function* ( + owner: string, + type: string, + cursor: string | null, + withContent: boolean, +): Effect.fn.Return { + const client = yield* SuiClient; + return yield* Effect.tryPromise({ + try: (signal) => + (client.core.listOwnedObjects as (args: unknown) => Promise)({ + owner, + type, + cursor, + limit: 50, + ...(withContent ? { include: { content: true } } : {}), + signal, + }), + catch: (cause) => new SuiRpcError({ operation: "listOwnedObjects", cause }), + }); +}); + /** * The records `owner` holds. Ownership is DIRECT — a record is an address-owned * `::record::Record` with no pressing/license/receipt intermediary. * The server-side type filter and exact local check deliberately exclude records * from any retired package namespace. */ -export async function getOwnedRecords(client: MisoClient, owner: string): Promise { +export const getOwnedRecords = Effect.fn("getOwnedRecords")(function* ( + owner: string, + config: MisoConfig, +): Effect.fn.Return { const out: OwnedRecord[] = []; let cursor: string | null = null; - const sales = requireRecordSalesDeployment(client.config.recordSales); + const sales = requireRecordSalesDeployment(config.recordSales); const recordType = `${sales.recordPackageId}::record::Record`; for (let page = 0; page < MAX_PAGES; page++) { - // Annotated: `cursor` is both an input and assigned from the result, which - // TypeScript reads as a circular initializer without an explicit type. - const res: Awaited> = - await client.protocol.core.listOwnedObjects({ - owner, - type: recordType, - cursor, - limit: 50, - include: { content: true }, - }); + const res = (yield* listOwnedObjectsPage(owner, recordType, cursor, true)) as OwnedObjectsPage; for (const obj of res.objects) { if (!isCanonicalRecordType(obj.type, sales.recordPackageId)) { continue; @@ -210,7 +244,7 @@ export async function getOwnedRecords(client: MisoClient, owner: string): Promis if (!cursor) break; } return out; -} +}); // ── Parties ────────────────────────────────────────────────────────────────── @@ -220,21 +254,19 @@ export async function getOwnedRecords(client: MisoClient, owner: string): Promis * cap type plus a name resolve. Caps are transferable, which is why this is * authoritative in a way that remembering created parties client-side is not. */ -export async function getOwnedParties(client: MisoClient, owner: string): Promise { - const capType = `${client.config.partyos.partyos}::party::PartyAdminCap`; +export const getOwnedParties = Effect.fn("getOwnedParties")(function* ( + owner: string, + config: MisoConfig, +): Effect.fn.Return { + const capType = `${config.partyos.partyos}::party::PartyAdminCap`; // One page of 50 caps is plenty for launch-scale artists; paginate if labels // ever start hitting the cap. - const { objects } = await client.protocol.core.listOwnedObjects({ - owner, - type: capType, - limit: 50, - include: { content: true }, - }); + const { objects } = yield* listOwnedObjectsPage(owner, capType, null, true); const caps = objects.flatMap((obj) => { try { - const cap = partyContracts.PartyAdminCap.parse(obj.content); + const cap = partyContracts.PartyAdminCap.parse(obj.content!); return [{ capId: obj.objectId, partyId: cap.party_id }]; } catch { return []; @@ -242,12 +274,15 @@ export async function getOwnedParties(client: MisoClient, owner: string): Promis }); if (caps.length === 0) return []; - const parties = await client.party.getPartiesByIds(caps.map((c) => c.partyId)); + const parties = yield* getPartiesByIds( + caps.map((c) => c.partyId), + config.partyos.partyos, + ); return caps.flatMap(({ capId, partyId }) => { const p = parties[partyId]; return p ? [{ partyId, capId, name: p.name, kind: p.kind }] : []; }); -} +}); /** * Pending group invitations for every individual party the wallet administers. @@ -257,24 +292,24 @@ export async function getOwnedParties(client: MisoClient, owner: string): Promis * names are resolved in one batch so the API can render an inbox without extra * browser reads. */ -export async function getPendingMemberships( - client: MisoClient, +export const getPendingMemberships = Effect.fn("getPendingMemberships")(function* ( owner: string, -): Promise { - const controlled = await getOwnedParties(client, owner); + config: MisoConfig, +): Effect.fn.Return { + const controlled = yield* getOwnedParties(owner, config); const individuals = controlled.filter((party) => party.kind === "individual"); if (individuals.length === 0) return []; - const invitations = await Promise.all( - individuals.map(async (member) => ({ - member, - groupIds: await client.party.getPendingMemberships(member.partyId), - })), + const invitations = yield* Effect.forEach( + individuals, + (member) => + getPartyPendingMemberships(member.partyId).pipe(Effect.map((groupIds) => ({ member, groupIds }))), + { concurrency: "unbounded" }, ); const groupIds = [...new Set(invitations.flatMap(({ groupIds }) => groupIds))]; if (groupIds.length === 0) return []; - const groups = await client.party.getPartiesByIds(groupIds); + const groups = yield* getPartiesByIds(groupIds, config.partyos.partyos); return invitations.flatMap(({ member, groupIds }) => groupIds.flatMap((groupId): PendingMembership[] => { const group = groups[groupId]; @@ -283,7 +318,7 @@ export async function getPendingMemberships( : []; }), ); -} +}); // ── Works (studio catalog) ─────────────────────────────────────────────────── @@ -293,12 +328,14 @@ export async function getPendingMemberships( * type `T` is parsed back out of each instance's type tag, because the cap does * not store the work's id — only its share type. */ -async function ownedGenericCaps(client: MisoClient, owner: string, capType: string) { +const ownedGenericCaps = Effect.fn("ownedGenericCaps")(function* ( + owner: string, + capType: string, +): Effect.fn.Return<{ id: string; shareType: string }[], SuiRpcError, SuiClient> { const caps: { id: string; shareType: string }[] = []; let cursor: string | null = null; for (let page = 0; page < MAX_PAGES; page++) { - const res: Awaited> = - await client.protocol.core.listOwnedObjects({ owner, type: capType, cursor, limit: 50 }); + const res = (yield* listOwnedObjectsPage(owner, capType, cursor, false)) as OwnedObjectsPage; for (const obj of res.objects) { const match = /<(.+)>$/.exec(obj.type); if (match?.[1]) caps.push({ id: obj.objectId, shareType: match[1] }); @@ -308,33 +345,33 @@ async function ownedGenericCaps(client: MisoClient, owner: string, capType: stri if (!cursor) break; } return caps; -} +}); /** * Discover owner-held VaultAdminCaps whose wrapped capability administers a * catalog work. PressingAdminCap is deliberately ignored: Catalog is a work * surface (composition / recording / release), not a sale-management surface. */ -async function ownedVaultedWorkCaps(client: MisoClient, owner: string) { +const ownedVaultedWorkCaps = Effect.fn("ownedVaultedWorkCaps")(function* ( + owner: string, + config: MisoConfig, +): Effect.fn.Return< + { compositions: VaultedGenericCap[]; recordings: VaultedGenericCap[]; releases: VaultedReleaseCap[] }, + SuiRpcError, + SuiClient +> { const out: { compositions: VaultedGenericCap[]; recordings: VaultedGenericCap[]; releases: VaultedReleaseCap[]; } = { compositions: [], recordings: [], releases: [] }; - const vaultPackageId = client.config.protocol.vault; - const misoPackageId = client.config.deployment.musicos; + const vaultPackageId = config.protocol.vault; + const misoPackageId = config.deployment.musicos; const capType = `${vaultPackageId}::vault::VaultAdminCap`; let cursor: string | null = null; for (let page = 0; page < MAX_PAGES; page++) { - const res: Awaited> = - await client.protocol.core.listOwnedObjects({ - owner, - type: capType, - cursor, - limit: 50, - include: { content: true }, - }); + const res = (yield* listOwnedObjectsPage(owner, capType, cursor, true)) as OwnedObjectsPage; for (const object of res.objects) { if (!object.content) continue; const work = classifyVaultedWorkAdminCapType( @@ -362,32 +399,24 @@ async function ownedVaultedWorkCaps(client: MisoClient, owner: string) { if (!cursor) break; } return out; -} +}); /** Read the ReleaseAdminCap wrapped by each shared release vault. */ -async function resolveVaultedReleaseCaps( - client: MisoClient, +const resolveVaultedReleaseCaps = Effect.fn("resolveVaultedReleaseCaps")(function* ( caps: readonly VaultedReleaseCap[], -): Promise<{ id: string; releaseId: string }[]> { + config: MisoConfig, +): Effect.fn.Return<{ id: string; releaseId: string }[], SuiRpcError, SuiClient> { if (caps.length === 0) return []; - const releaseCapType = `${client.config.deployment.musicos}::release::ReleaseAdminCap`; - const vaultType = normalizeStructTag( - `${client.config.protocol.vault}::vault::Vault<${releaseCapType}>`, - ); - const { objects } = await client.protocol.core.getObjects({ - objectIds: caps.map((cap) => cap.vaultId), - include: { content: true }, - }); + const releaseCapType = `${config.deployment.musicos}::release::ReleaseAdminCap`; + const vaultType = normalizeStructTag(`${config.protocol.vault}::vault::Vault<${releaseCapType}>`); + const contentById = yield* getObjectsContent(caps.map((cap) => cap.vaultId)); const releasesByVault = new Map(); - for (const object of objects) { - if (object instanceof Error || !object.content) continue; + for (const [objectId, object] of contentById) { try { if (normalizeStructTag(object.type) !== vaultType) continue; - const vault = vaultContract.Vault(networkContracts.release.ReleaseAdminCap).parse( - object.content, - ); + const vault = vaultContract.Vault(networkContracts.release.ReleaseAdminCap).parse(object.content); const releaseId = vault.cap?.value?.release_id; - if (releaseId) releasesByVault.set(object.objectId, releaseId); + if (releaseId) releasesByVault.set(objectId, releaseId); } catch { // Batch catalog discovery is best-effort per authority, matching the // existing direct-cap path's treatment of unreadable work objects. @@ -397,7 +426,7 @@ async function resolveVaultedReleaseCaps( const releaseId = releasesByVault.get(cap.vaultId); return releaseId ? [{ id: cap.id, releaseId }] : []; }); -} +}); /** * Every work the wallet administers, keyed by its ADMIN CAP id (the studio @@ -409,33 +438,33 @@ async function resolveVaultedReleaseCaps( * the GraphQL hop entirely — `ReleaseAdminCap` is not generic and carries * `release_id` directly. */ -export async function getOwnedWorks(client: MisoClient, owner: string): Promise { - const miso = client.config.deployment.musicos; - - const [directCompCaps, directRecCaps, directRelCaps, vaulted] = await Promise.all([ - ownedGenericCaps(client, owner, `${miso}::composition::CompositionAdminCap`), - ownedGenericCaps(client, owner, `${miso}::recording::RecordingAdminCap`), - getOwnedReleaseAdminCaps(client.protocol, owner, miso), - ownedVaultedWorkCaps(client, owner), +export const getOwnedWorks = Effect.fn("getOwnedWorks")(function* ( + owner: string, + config: MisoConfig, +): Effect.fn.Return { + const miso = config.deployment.musicos; + + const [directCompCaps, directRecCaps, directRelCaps, vaulted] = yield* Effect.all([ + ownedGenericCaps(owner, `${miso}::composition::CompositionAdminCap`), + ownedGenericCaps(owner, `${miso}::recording::RecordingAdminCap`), + getOwnedReleaseAdminCaps(owner, miso), + ownedVaultedWorkCaps(owner, config), ]); - const vaultedRelCaps = await resolveVaultedReleaseCaps(client, vaulted.releases); + const vaultedRelCaps = yield* resolveVaultedReleaseCaps(vaulted.releases, config); const compCaps = [...directCompCaps, ...vaulted.compositions]; const recCaps = [...directRecCaps, ...vaulted.recordings]; const relCaps = [...directRelCaps, ...vaultedRelCaps]; - const addresses = await getWorkAddressesByShareTypes( - client.graphql, + const addresses = yield* getWorkAddressesByShareTypes( { compositions: compCaps.map((c) => c.shareType), recordings: recCaps.map((c) => c.shareType) }, miso, ); - const works = await getWorksByIds(client.protocol, { + const works = yield* getWorksByIds({ compositions: Object.values(addresses.compositions).filter((id): id is string => id !== undefined), recordings: Object.values(addresses.recordings).filter((id): id is string => id !== undefined), releases: relCaps.map((c) => c.releaseId), }); - const recordingTitles = await getRecordingTitles( - client.protocol, - client.graphql, + const recordingTitles = yield* getRecordingTitles( Object.values(addresses.recordings).filter((id): id is string => id !== undefined), miso, ); @@ -465,32 +494,23 @@ export async function getOwnedWorks(client: MisoClient, owner: string): Promise< }); return [...comps, ...recs, ...rels]; -} +}); /** Classify a direct or vaulted cap by its on-chain type, then resolve its work. */ -export async function getWorkByCap(client: MisoClient, capId: string): Promise { - const miso = client.config.deployment.musicos; +export const getWorkByCap = Effect.fn("getWorkByCap")(function* ( + capId: string, + config: MisoConfig, +): Effect.fn.Return { + const miso = config.deployment.musicos; - let type: string; - let json: Record | null; - let content: Uint8Array | undefined; - try { - const { object } = await client.protocol.core.getObject({ - objectId: capId, - include: { json: true, content: true }, - }); - type = object.type; - json = (object.json ?? null) as Record | null; - content = object.content; - } catch (e) { - if (isNotFound(e)) return null; - throw e; - } + const found = yield* getOptionalObjectContent(capId); + if (Option.isNone(found)) return null; + const { content, type } = found.value; const direct = classifyWorkAdminCapType(type, miso); if (direct?.kind === "composition") { const { shareType } = direct; - const c = await getCompositionByShareType(client.protocol, client.graphql, shareType, miso); + const c = yield* getCompositionByShareType(shareType, miso); return { capId, kind: "composition", @@ -504,8 +524,8 @@ export async function getWorkByCap(client: MisoClient, capId: string): Promise 0 ? 1 : 0, trackCount: r.tracks.length, }; -} +}); // ── Balance ────────────────────────────────────────────────────────────────── -const decimalsByClient = new WeakMap>>(); +const decimalsByClient = new WeakMap>>(); -/** Resolve once per client/type. Failed lookups are evicted so a transient - * fullnode error cannot poison a long-lived API worker. */ -async function coinDecimals(client: MisoClient, coinType: string): Promise { +/** + * Resolve once per (transport client, coin type). Failed lookups are evicted so + * a transient fullnode error cannot poison a long-lived API worker. + */ +function coinDecimalsPromise(client: object & { core: { getCoinMetadata: (args: { coinType: string }) => Promise<{ coinMetadata: { decimals?: number } | null }> } }, coinType: string): Promise { let cache = decimalsByClient.get(client); if (!cache) { cache = new Map(); @@ -609,7 +617,7 @@ async function coinDecimals(client: MisoClient, coinType: string): Promise { + const pending = client.core.getCoinMetadata({ coinType: key }).then(({ coinMetadata }) => { const decimals = coinMetadata?.decimals; if (!Number.isSafeInteger(decimals) || decimals! < 0 || decimals! > 18) { throw new Error(`Coin metadata for ${key} has no supported decimal precision`); @@ -617,12 +625,10 @@ async function coinDecimals(client: MisoClient, coinType: string): Promise { + if (cache!.get(key) === pending) cache!.delete(key); + }); + return pending; } /** @@ -630,11 +636,22 @@ async function coinDecimals(client: MisoClient, coinType: string): Promise { - const type = coinType ?? client.config.money.usdCoinType; - const [res, decimals] = await Promise.all([ - client.protocol.core.getBalance({ owner: address, coinType: type }), - coinDecimals(client, type), +export const getBalance = Effect.fn("getBalance")(function* ( + address: string, + config: MisoConfig, + coinType?: string, +): Effect.fn.Return { + const client = yield* SuiClient; + const type = coinType ?? config.money.usdCoinType; + const [res, decimals] = yield* Effect.all([ + Effect.tryPromise({ + try: (signal) => client.core.getBalance({ owner: address, coinType: type, signal }), + catch: (cause) => new SuiRpcError({ operation: "getBalance", cause }), + }), + Effect.tryPromise({ + try: () => coinDecimalsPromise(client, type), + catch: (cause) => new SuiRpcError({ operation: "getCoinMetadata", cause }), + }), ]); return { address: normalizeSuiAddress(address), @@ -644,7 +661,7 @@ export async function getBalance(client: MisoClient, address: string, coinType?: addressBalance: u64(res.balance.addressBalance), decimals, }; -} +}); // ── Ownership ──────────────────────────────────────────────────────────────── @@ -664,14 +681,22 @@ function isAddressOwner(owner: unknown, address: string): boolean { * A failed read means "can't confirm", which resolves to `false` — the editor * stays hidden rather than being offered and then rejected on submit. */ -export async function ownsParty(client: MisoClient, address: string, partyId: string): Promise { - const capId = client.party.derivePartyAdminCapId(partyId); - const isOwner = await client.protocol.core - .getObject({ objectId: capId }) - .then(({ object }) => isAddressOwner(object?.owner, address)) - .catch(() => false); +export const ownsParty = Effect.fn("ownsParty")(function* ( + address: string, + partyId: string, + config: MisoConfig, +): Effect.fn.Return { + const capId = derivePartyAdminCapId(partyId, config.partyos.partyos); + const client = yield* SuiClient; + const isOwner = yield* Effect.tryPromise({ + try: (signal) => client.core.getObject({ objectId: capId, signal }), + catch: (cause) => cause, + }).pipe( + Effect.map(({ object }) => isAddressOwner(object?.owner, address)), + Effect.catch(() => Effect.succeed(false)), + ); return { address: normalizeSuiAddress(address), objectId: partyId, isOwner, capId }; -} +}); /** * Whether `address` owns a record. Records are address-owned objects, so this is @@ -679,10 +704,17 @@ export async function ownsParty(client: MisoClient, address: string, partyId: st * whether the Snapshots tab appears, and a non-owner has no snapshot content to * fetch regardless. */ -export async function ownsRecord(client: MisoClient, address: string, recordId: string): Promise { - const isOwner = await client.protocol.core - .getObject({ objectId: recordId }) - .then(({ object }) => isAddressOwner(object?.owner, address)) - .catch(() => false); +export const ownsRecord = Effect.fn("ownsRecord")(function* ( + address: string, + recordId: string, +): Effect.fn.Return { + const client = yield* SuiClient; + const isOwner = yield* Effect.tryPromise({ + try: (signal) => client.core.getObject({ objectId: recordId, signal }), + catch: (cause) => cause, + }).pipe( + Effect.map(({ object }) => isAddressOwner(object?.owner, address)), + Effect.catch(() => Effect.succeed(false)), + ); return { address: normalizeSuiAddress(address), objectId: recordId, isOwner }; -} +}); diff --git a/packages/platform/src/read/works.ts b/packages/platform/src/read/works.ts index 70853c8..36736dd 100644 --- a/packages/platform/src/read/works.ts +++ b/packages/platform/src/read/works.ts @@ -1,139 +1,27 @@ // Copyright (c) Miso Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -import type { ClientWithCoreApi } from "@mysten/sui/client"; -import type { SuiGraphQLClient } from "@mysten/sui/graphql"; +import { Effect } from "effect"; import { normalizeSuiAddress } from "@mysten/sui/utils"; import { contracts, extractTypeParams2, getCompositionsByIds, - type Composition, - type Recording, - type Release, + getWorkAddressesByShareTypes, + Composition, + Recording, + Release, type TrackState, + type WorkAddressesByShareType, + type WorkShareTypes, } from "@misofm/musicos"; +import { decodeBcs, SuiClient, SuiGraphQL, SuiRpcError, type BcsDecodeError } from "@misofm/effect"; -export interface WorkShareTypes { - compositions: readonly string[]; - recordings: readonly string[]; -} - -export interface WorkAddressesByShareType { - compositions: Partial>; - recordings: Partial>; -} - -interface WorkAddressConnection { - nodes: Array<{ - address: string; - asMoveObject?: { - contents?: { type?: { repr?: string } | null } | null; - } | null; - }>; - pageInfo?: { hasNextPage: boolean; endCursor: string | null }; -} - -/** Resolve all work share types in one aliased GraphQL request. */ -export async function getWorkAddressesByShareTypes( - client: SuiGraphQLClient, - shareTypes: WorkShareTypes, - misoPackageId: string, -): Promise { - const compositions = [...new Set(shareTypes.compositions)]; - const recordings = new Set(shareTypes.recordings); - const out: WorkAddressesByShareType = { compositions: {}, recordings: {} }; - if (compositions.length === 0 && recordings.size === 0) return out; - - const declarations: string[] = []; - const selections: string[] = []; - const variables: Record = {}; - compositions.forEach((shareType, index) => { - const variable = `compositionType${index}`; - declarations.push(`$${variable}: String!`); - selections.push( - `composition${index}: objects(first: 1, filter: { type: $${variable} }) { nodes { address } }`, - ); - variables[variable] = - `${misoPackageId}::composition::Composition<${shareType}>`; - }); - if (recordings.size > 0) { - declarations.push("$recordingType: String!"); - selections.push(`recordings: objects(first: 50, filter: { type: $recordingType }) { - pageInfo { hasNextPage endCursor } - nodes { address asMoveObject { contents { type { repr } } } } - }`); - variables.recordingType = `${misoPackageId}::recording::Recording`; - } - - const result = await client.query< - Record, - Record - >({ - query: `query WorkAddressesByShareTypes(${declarations.join(", ")}) { - ${selections.join("\n")} - }`, - variables, - }); - if (result.errors?.length) { - throw new AggregateError( - result.errors.map((error) => new Error(error.message)), - "Work type discovery failed", - ); - } - compositions.forEach((shareType, index) => { - const address = result.data?.[`composition${index}`]?.nodes[0]?.address; - if (address) out.compositions[shareType] = address; - }); - - const readRecordingPage = ( - page: WorkAddressConnection | null | undefined, - ) => { - for (const node of page?.nodes ?? []) { - const repr = node.asMoveObject?.contents?.type?.repr; - if (!repr) continue; - try { - const [shareType] = extractTypeParams2(repr); - if (recordings.has(shareType)) out.recordings[shareType] = node.address; - } catch { - // Ignore objects whose deployed type does not match the Recording ABI. - } - } - }; - - let page = result.data?.recordings; - readRecordingPage(page); - while ( - page?.pageInfo?.hasNextPage && - page.pageInfo.endCursor && - Object.keys(out.recordings).length < recordings.size - ) { - const next = await client.query< - { recordings: WorkAddressConnection | null }, - { recordingType: string; cursor: string } - >({ - query: `query RecordingWorkAddresses($recordingType: String!, $cursor: String!) { - recordings: objects(first: 50, after: $cursor, filter: { type: $recordingType }) { - pageInfo { hasNextPage endCursor } - nodes { address asMoveObject { contents { type { repr } } } } - } - }`, - variables: { - recordingType: variables.recordingType!, - cursor: page.pageInfo.endCursor, - }, - }); - if (next.errors?.length) { - throw new AggregateError( - next.errors.map((error) => new Error(error.message)), - "Recording type discovery failed", - ); - } - page = next.data?.recordings; - readRecordingPage(page); - } - return out; -} +// `getWorkAddressesByShareTypes` (GraphQL type discovery) is owned by +// `@misofm/musicos` now — re-exported here so existing platform imports keep +// working. +export { getWorkAddressesByShareTypes }; +export type { WorkAddressesByShareType, WorkShareTypes }; export interface WorkIds { compositions: readonly string[]; @@ -147,122 +35,121 @@ export interface WorksById { releases: Partial>; } +// deno-lint-ignore no-explicit-any -- generated parse output is loosely typed type Parsed = Record; -function workState(value: Parsed): Composition["state"] { +function workState(value: Parsed): (typeof Composition.Type)["state"] { return value?.$kind === "Published" ? { type: "Published", timestampMs: Number(value.Published) } : { type: "Initialized" }; } -function parseComposition(id: string, content: Uint8Array): Composition { - const value = contracts.composition.Composition.parse(content) as Parsed; +function mapComposition(id: string, d: Parsed) { return { id, - state: workState(value.state), - title: String(value.title), + state: workState(d.state), + title: String(d.title), royaltyRate: { - value: Number( - Array.isArray(value.royalty_rate) - ? value.royalty_rate[0] - : value.royalty_rate, - ), + value: Number(Array.isArray(d.royalty_rate) ? d.royalty_rate[0] : d.royalty_rate), }, }; } -function parseRecording(id: string, content: Uint8Array): Recording { - const value = contracts.recording.Recording.parse(content) as Parsed; +function mapRecording(id: string, d: Parsed) { return { id, - state: workState(value.state), - compositionId: String(value.composition_id), + state: workState(d.state), + compositionId: String(d.composition_id), }; } -export function parseReleaseObject( - id: string, - content: Uint8Array, -): Release { - const value = contracts.release.Release.parse(content) as Parsed; +function mapReleaseObject(id: string, d: Parsed) { return { id, - state: workState(value.state), - title: String(value.title), - tracks: (value.tracks ?? []).map((track: Parsed) => ({ + state: workState(d.state), + title: String(d.title), + tracks: (d.tracks ?? []).map((track: Parsed) => ({ state: (track.state?.$kind ?? "Unassigned") as TrackState, compositionId: String(track.composition_id), recordingId: String(track.recording_id), splitBps: { - value: Number( - Array.isArray(track.split_bps) ? track.split_bps[0] : track.split_bps, - ), + value: Number(Array.isArray(track.split_bps) ? track.split_bps[0] : track.split_bps), }, })), }; } +/** Decode a Release object's raw BCS content into the public `Release` type. */ +export function parseReleaseObject(id: string, content: Uint8Array): Effect.Effect { + return decodeBcs( + { parse: (bytes: Uint8Array) => mapReleaseObject(id, contracts.release.Release.parse(bytes) as Parsed) }, + Release, + content, + { type: "Release", objectId: id }, + ); +} + /** Fetch and parse heterogeneous works through one Core bulk request. */ -export async function getWorksByIds( - client: ClientWithCoreApi, +export const getWorksByIds = Effect.fn("getWorksByIds")(function* ( ids: WorkIds, -): Promise { +): Effect.fn.Return { const kinds = new Map(); - for (const [kind, objectIds] of Object.entries(ids) as Array< - [keyof WorksById, readonly string[]] - >) { + for (const [kind, objectIds] of Object.entries(ids) as Array<[keyof WorksById, readonly string[]]>) { for (const objectId of objectIds) { const normalized = normalizeSuiAddress(objectId); const previous = kinds.get(normalized); if (previous && previous !== kind) { - throw new Error( - `Work ${normalized} was requested as both ${previous} and ${kind}`, - ); + throw new Error(`Work ${normalized} was requested as both ${previous} and ${kind}`); } kinds.set(normalized, kind); } } const out: WorksById = { compositions: {}, recordings: {}, releases: {} }; if (kinds.size === 0) return out; - const { objects } = await client.core.getObjects({ - objectIds: [...kinds.keys()], - include: { content: true }, + + const client = yield* SuiClient; + const { objects } = yield* Effect.tryPromise({ + try: (signal) => client.core.getObjects({ objectIds: [...kinds.keys()], include: { content: true }, signal }), + catch: (cause) => new SuiRpcError({ operation: "getObjects", cause }), }); for (const object of objects) { if (object instanceof Error || !object.content) continue; const kind = kinds.get(normalizeSuiAddress(object.objectId)); - if (kind === "compositions") - out.compositions[object.objectId] = parseComposition( - object.objectId, - object.content, + const objectId = object.objectId; + const content = object.content; + if (kind === "compositions") { + out.compositions[objectId] = yield* decodeBcs( + { parse: (bytes: Uint8Array) => mapComposition(objectId, contracts.composition.Composition.parse(bytes) as Parsed) }, + Composition, + content, + { type: "Composition", objectId }, ); - else if (kind === "recordings") - out.recordings[object.objectId] = parseRecording( - object.objectId, - object.content, - ); - else if (kind === "releases") - out.releases[object.objectId] = parseReleaseObject( - object.objectId, - object.content, + } else if (kind === "recordings") { + out.recordings[objectId] = yield* decodeBcs( + { parse: (bytes: Uint8Array) => mapRecording(objectId, contracts.recording.Recording.parse(bytes) as Parsed) }, + Recording, + content, + { type: "Recording", objectId }, ); + } else if (kind === "releases") { + out.releases[objectId] = yield* parseReleaseObject(objectId, content); + } } return out; -} +}); /** Resolve recording titles through each recording's canonical composition type. */ -export async function getRecordingTitles( - client: ClientWithCoreApi, - graphql: SuiGraphQLClient, +export const getRecordingTitles = Effect.fn("getRecordingTitles")(function* ( recordingIds: readonly string[], misoPackageId: string, -): Promise> { +): Effect.fn.Return, SuiRpcError | BcsDecodeError, SuiClient | SuiGraphQL> { const ids = [...new Set(recordingIds)]; if (ids.length === 0) return {}; - const { objects } = await client.core.getObjects({ - objectIds: ids, - include: {}, + const client = yield* SuiClient; + const { objects } = yield* Effect.tryPromise({ + try: (signal) => client.core.getObjects({ objectIds: ids, include: {}, signal }), + catch: (cause) => new SuiRpcError({ operation: "getObjects", cause }), }); const titles: Record = {}; const compositionShareByRecording: Record = {}; @@ -275,23 +162,17 @@ export async function getRecordingTitles( const compositionShareTypes = Object.values(compositionShareByRecording); if (compositionShareTypes.length === 0) return titles; - const addresses = await getWorkAddressesByShareTypes( - graphql, + const addresses = yield* getWorkAddressesByShareTypes( { compositions: compositionShareTypes, recordings: [] }, misoPackageId, ); - const compositions = await getCompositionsByIds( - client, + const compositions = yield* getCompositionsByIds( Object.values(addresses.compositions).filter((id): id is string => !!id), ); - for (const [recordingId, shareType] of Object.entries( - compositionShareByRecording, - )) { + for (const [recordingId, shareType] of Object.entries(compositionShareByRecording)) { const compositionId = addresses.compositions[shareType]; - const title = compositionId - ? compositions[compositionId]?.title - : undefined; + const title = compositionId ? compositions[compositionId]?.title : undefined; if (title) titles[recordingId] = title; } return titles; -} +}); diff --git a/packages/platform/src/recording-extensions.ts b/packages/platform/src/recording-extensions.ts index 13b5421..df281de 100644 --- a/packages/platform/src/recording-extensions.ts +++ b/packages/platform/src/recording-extensions.ts @@ -4,10 +4,10 @@ /** Cap-authorized builders and reads for data-only Recording metadata extensions. */ import { bcs } from "@mysten/sui/bcs"; -import type { ClientWithCoreApi } from "@mysten/sui/client"; import type { Transaction, TransactionArgument, TransactionObjectArgument } from "@mysten/sui/transactions"; import { deriveDynamicFieldID, fromHex, toHex } from "@mysten/sui/utils"; -import { isNotFound } from "@misofm/musicos"; +import { Effect, Option } from "effect"; +import { getObjectsContent, getOptionalObjectContent, type SuiClient, type SuiRpcError } from "@misofm/effect"; import type { TxThunk } from "./transactions.ts"; import { invokeWithAdminCap, type AdminCapAuthority, type ObjectInput } from "./vault.ts"; import * as advisory from "./contracts/recording_advisory/recording_advisory.ts"; @@ -271,33 +271,23 @@ export function recordingStreamingTranscodeFieldId( } /** Read one Recording's streaming-transcode Quilt id, or null when absent. */ -export async function getRecordingStreamingTranscode( - client: ClientWithCoreApi, +export const getRecordingStreamingTranscode = Effect.fn("getRecordingStreamingTranscode")(function* ( recordingId: string, recordingStreamingTranscodePackageId: string, -): Promise { - return ( - ( - await getRecordingStreamingTranscodesByIds( - client, - [recordingId], - recordingStreamingTranscodePackageId, - ) - )[recordingId] ?? null - ); -} +): Effect.fn.Return { + const found = yield* getRecordingStreamingTranscodesByIds([recordingId], recordingStreamingTranscodePackageId); + return found[recordingId] ?? null; +}); /** * Read streaming-transcode Quilt ids for many Recordings in one Core request. * Missing and malformed fields are omitted, like master references. */ -export async function getRecordingStreamingTranscodesByIds( - client: ClientWithCoreApi, +export function getRecordingStreamingTranscodesByIds( recordingIdsInput: readonly string[], recordingStreamingTranscodePackageId: string, -): Promise>> { +): Effect.Effect>, SuiRpcError, SuiClient> { return readSoftFields( - client, recordingIdsInput, (recordingId) => recordingStreamingTranscodeFieldId(recordingId, recordingStreamingTranscodePackageId), parseRecordingStreamingTranscodeContent, @@ -309,30 +299,27 @@ export async function getRecordingStreamingTranscodesByIds( * parse each, dropping Recordings whose field is missing or malformed so one * stale extension cannot take a whole tracklist down. */ -async function readSoftFields( - client: ClientWithCoreApi, +const readSoftFields = Effect.fn("readSoftFields")(function* ( recordingIdsInput: readonly string[], fieldIdOf: (recordingId: string) => string, parse: (content: Uint8Array) => T, -): Promise>> { +): Effect.fn.Return>, SuiRpcError, SuiClient> { const recordingIds = [...new Set(recordingIdsInput)]; if (recordingIds.length === 0) return {}; - const { objects } = await client.core.getObjects({ - objectIds: recordingIds.map(fieldIdOf), - include: { content: true }, - }); + const fieldIds = recordingIds.map(fieldIdOf); + const contentById = yield* getObjectsContent(fieldIds); const out: Partial> = {}; - objects.forEach((object, index) => { - const recordingId = recordingIds[index]; - if (!recordingId || object instanceof Error || !object.content) return; + recordingIds.forEach((recordingId, index) => { + const found = contentById.get(fieldIds[index]!); + if (!found) return; try { - out[recordingId] = parse(object.content); + out[recordingId] = parse(found.content); } catch { // Extension metadata is soft: retain valid tracks when one field is stale. } }); return out; -} +}); // ── Engine-session reads ───────────────────────────────────────────────────── @@ -381,37 +368,25 @@ export function recordingEngineSessionFieldId( } /** Read one Recording's engine session, or null when none is attached. */ -export async function getRecordingEngineSession( - client: ClientWithCoreApi, +export const getRecordingEngineSession = Effect.fn("getRecordingEngineSession")(function* ( recordingId: string, recordingEngineSessionPackageId: string, -): Promise { +): Effect.fn.Return { const fieldId = recordingEngineSessionFieldId(recordingId, recordingEngineSessionPackageId); - const { objects } = await client.core.getObjects({ - objectIds: [fieldId], - include: { content: true }, - }); - const field = objects[0]; - if (!field) throw new Error("Recording engine-session read returned no result"); - if (field instanceof Error) { - if (isNotFound(field)) return null; - throw field; - } - if (!field.content) throw new Error("Recording engine-session field is missing BCS content"); - return parseRecordingEngineSessionContent(field.content); -} + const found = yield* getOptionalObjectContent(fieldId); + if (Option.isNone(found)) return null; + return parseRecordingEngineSessionContent(found.value.content); +}); /** * Read engine sessions for many Recordings in one Core request. Missing and * malformed fields are omitted. */ -export async function getRecordingEngineSessionsByIds( - client: ClientWithCoreApi, +export function getRecordingEngineSessionsByIds( recordingIdsInput: readonly string[], recordingEngineSessionPackageId: string, -): Promise>> { +): Effect.Effect>, SuiRpcError, SuiClient> { return readSoftFields( - client, recordingIdsInput, (recordingId) => recordingEngineSessionFieldId(recordingId, recordingEngineSessionPackageId), parseRecordingEngineSessionContent, @@ -455,21 +430,13 @@ export function recordingMasterReferenceFieldId( } /** Read one Recording's master-reference blob id, or null when absent. */ -export async function getRecordingMasterReference( - client: ClientWithCoreApi, +export const getRecordingMasterReference = Effect.fn("getRecordingMasterReference")(function* ( recordingId: string, recordingMasterReferencePackageId: string, -): Promise { - return ( - ( - await getRecordingMasterReferencesByIds( - client, - [recordingId], - recordingMasterReferencePackageId, - ) - )[recordingId] ?? null - ); -} +): Effect.fn.Return { + const found = yield* getRecordingMasterReferencesByIds([recordingId], recordingMasterReferencePackageId); + return found[recordingId] ?? null; +}); /** * Read master-reference blob ids for many Recordings in one Core request. @@ -477,34 +444,13 @@ export async function getRecordingMasterReference( * Missing fields and malformed individual objects are omitted so one Recording * without a playable master cannot make the rest of an album unplayable. */ -export async function getRecordingMasterReferencesByIds( - client: ClientWithCoreApi, +export function getRecordingMasterReferencesByIds( recordingIdsInput: readonly string[], recordingMasterReferencePackageId: string, -): Promise>> { - const recordingIds = [...new Set(recordingIdsInput)]; - const targets = recordingIds.map((recordingId) => ({ - recordingId, - fieldId: recordingMasterReferenceFieldId( - recordingId, - recordingMasterReferencePackageId, - ), - })); - if (targets.length === 0) return {}; - - const { objects } = await client.core.getObjects({ - objectIds: targets.map((target) => target.fieldId), - include: { content: true }, - }); - const out: Partial> = {}; - objects.forEach((object, index) => { - const target = targets[index]; - if (!target || object instanceof Error || !object.content) return; - try { - out[target.recordingId] = parseRecordingMasterReferenceContent(object.content); - } catch { - // Extension metadata is soft: retain valid tracks when one field is stale. - } - }); - return out; +): Effect.Effect>, SuiRpcError, SuiClient> { + return readSoftFields( + recordingIdsInput, + (recordingId) => recordingMasterReferenceFieldId(recordingId, recordingMasterReferencePackageId), + parseRecordingMasterReferenceContent, + ); } diff --git a/packages/platform/src/release-extensions.ts b/packages/platform/src/release-extensions.ts index 971ccd3..41311ea 100644 --- a/packages/platform/src/release-extensions.ts +++ b/packages/platform/src/release-extensions.ts @@ -5,7 +5,6 @@ // intent aggregates these concerns, but every value is written by its own // independently deployed extension package. -import type { ClientWithCoreApi } from "@mysten/sui/client"; import { bcs } from "@mysten/sui/bcs"; import { deriveDynamicFieldID } from "@mysten/sui/utils"; import type { @@ -13,6 +12,8 @@ import type { TransactionArgument, TransactionObjectArgument, } from "@mysten/sui/transactions"; +import { Effect, Option } from "effect"; +import { getOptionalObjectContent, type SuiClient, type SuiRpcError } from "@misofm/effect"; import type { TxThunk } from "./transactions.ts"; import * as releaseDescription from "./contracts/release_description/release_description.ts"; import * as releaseDspLink from "./contracts/release_dsp_link/release_dsp_link.ts"; @@ -86,19 +87,13 @@ export function parseReleaseKindContent(content: Uint8Array): string { } /** Read a Release's self-declared kind, or `null` when the extension is absent. */ -export async function getReleaseKind( - client: ClientWithCoreApi, +export const getReleaseKind = Effect.fn("getReleaseKind")(function* ( releaseId: string, releaseKindPackageId: string, -): Promise { - const { objects } = await client.core.getObjects({ - objectIds: [releaseKindFieldId(releaseId, releaseKindPackageId)], - include: { content: true }, - }); - const object = objects[0]; - if (!object || object instanceof Error || !object.content) return null; - return parseReleaseKindContent(object.content); -} +): Effect.fn.Return { + const found = yield* getOptionalObjectContent(releaseKindFieldId(releaseId, releaseKindPackageId)); + return Option.isNone(found) ? null : parseReleaseKindContent(found.value.content); +}); export type SetReleaseDescriptionParams = ReleaseExtensionTarget & { description: string; diff --git a/packages/platform/src/royalty.ts b/packages/platform/src/royalty.ts index 1344963..ca5e024 100644 --- a/packages/platform/src/royalty.ts +++ b/packages/platform/src/royalty.ts @@ -7,10 +7,10 @@ // royalty pool plugins, routed-stake Actions bound to a VaultAdminCap) live in // `./vault.ts` instead; this module does not duplicate those. -import type { ClientWithCoreApi } from "@mysten/sui/client"; import { Transaction, type TransactionArgument, type TransactionObjectArgument } from "@mysten/sui/transactions"; -import { deriveObjectID } from "@mysten/sui/utils"; -import { getObjectByBcs } from "@misofm/musicos/queries"; +import { deriveObjectID, normalizeStructTag } from "@mysten/sui/utils"; +import { Effect, Schema } from "effect"; +import { decodeBcs, getOptionalObjectContent, type BcsDecodeError, type SuiClient, type SuiRpcError } from "@misofm/effect"; import { RoyaltyPool as RoyaltyPoolBcs } from "./contracts/royalty_pool/pool.ts"; import { Stake as RoyaltyStakeBcs } from "./contracts/royalty_pool/stake.ts"; @@ -22,34 +22,131 @@ import * as routedStake from "./contracts/routed_stake/routed_stake.ts"; /** Key bytes for Move unit structs (single `0x00` for `dummy_field: bool = false`). */ const UNIT_STRUCT_KEY_BYTES = new Uint8Array([0x00]); +// ============================================================================ +// Domain types +// ============================================================================ + +/** A stake's active registration against one royalty pool, keyed by currency type. */ +export class RoyaltyStakeRegistration extends Schema.Class( + "@misofm/platform/RoyaltyStakeRegistration", +)({ + currencyType: Schema.String, + poolId: Schema.String, + /** Reward debt in `shares · index` units (u256, decimal string). */ + debt: Schema.String, +}) {} + +/** A bare `royalty_pool::pool::RoyaltyPool` object. Phantom type arguments do not affect BCS. */ +export class RoyaltyPool extends Schema.Class("@misofm/platform/RoyaltyPool")({ + id: Schema.String, + /** Currency balance held by the pool (u64, decimal string). */ + balance: Schema.String, + /** Sum of every registered stake's shares (u64, decimal string). */ + stakedShares: Schema.String, + /** Cumulative reward-per-share index (u256, decimal string). */ + cumulativeRewardPerShare: Schema.String, + /** Deposit remainder not yet folded into the index (u128, decimal string). */ + carry: Schema.String, + /** Lifetime sum of every deposited value; analytics only (u128, decimal string). */ + cumulativeDeposits: Schema.String, +}) {} + +/** A bare `royalty_pool::stake::Stake` object. */ +export class RoyaltyStake extends Schema.Class("@misofm/platform/RoyaltyStake")({ + id: Schema.String, + /** The staked balance (u64, decimal string). Immutable after creation. */ + balance: Schema.String, + registrations: Schema.Array(RoyaltyStakeRegistration), +}) {} + +/** A bare `routed_stake::routed_stake::RoutedStake` object. */ +export class RoutedStake extends Schema.Class("@misofm/platform/RoutedStake")({ + id: Schema.String, + /** The wrapped position; `null` between `unstake` and `restake`. */ + stake: Schema.NullOr(RoyaltyStake), +}) {} + // ============================================================================ // Reads // ============================================================================ -/** Parse a royalty pool by object ID. Phantom type arguments do not affect BCS. */ -export async function getRoyaltyPoolById( - client: ClientWithCoreApi, - poolId: string, -) { - return getObjectByBcs(client, poolId, RoyaltyPoolBcs); +function mapRegistration(currencyType: string, poolId: string, debt: string): typeof RoyaltyStakeRegistration.Encoded { + return { currencyType: normalizeStructTag(currencyType), poolId, debt }; } -/** Parse an owned `Stake` by object ID. */ -export async function getRoyaltyStakeById( - client: ClientWithCoreApi, - stakeId: string, -) { - return getObjectByBcs(client, stakeId, RoyaltyStakeBcs); +function mapRoyaltyPool(poolId: string, parsed: ReturnType): typeof RoyaltyPool.Encoded { + return { + id: poolId, + balance: String(parsed.balance.value), + stakedShares: String(parsed.staked_shares), + cumulativeRewardPerShare: String(parsed.cumulative_reward_per_share), + carry: String(parsed.carry), + cumulativeDeposits: String(parsed.cumulative_deposits), + }; +} + +function mapRoyaltyStake(stakeId: string, parsed: ReturnType): typeof RoyaltyStake.Encoded { + return { + id: stakeId, + balance: String(parsed.balance.value), + registrations: parsed.registrations.contents.map((entry) => + mapRegistration(entry.key.name, entry.value.pool_id, String(entry.value.debt)), + ), + }; } -/** Parse a shared routed stake by object ID. */ -export async function getRoutedStakeById( - client: ClientWithCoreApi, +function mapRoutedStake( routedStakeId: string, -) { - return getObjectByBcs(client, routedStakeId, RoutedStakeBcs); + parsed: ReturnType, +): typeof RoutedStake.Encoded { + return { + id: routedStakeId, + stake: parsed.stake ? mapRoyaltyStake(routedStakeId, parsed.stake) : null, + }; } +/** One royalty pool by object ID, or `null` when no such object exists. */ +export const getRoyaltyPoolById = Effect.fn("getRoyaltyPoolById")(function* ( + poolId: string, +): Effect.fn.Return { + const found = yield* getOptionalObjectContent(poolId); + if (found._tag === "None") return null; + return yield* decodeBcs( + { parse: (bytes) => mapRoyaltyPool(poolId, RoyaltyPoolBcs.parse(bytes)) }, + RoyaltyPool, + found.value.content, + { type: "RoyaltyPool", objectId: poolId }, + ); +}); + +/** One owned `Stake` by object ID, or `null` when no such object exists. */ +export const getRoyaltyStakeById = Effect.fn("getRoyaltyStakeById")(function* ( + stakeId: string, +): Effect.fn.Return { + const found = yield* getOptionalObjectContent(stakeId); + if (found._tag === "None") return null; + return yield* decodeBcs( + { parse: (bytes) => mapRoyaltyStake(stakeId, RoyaltyStakeBcs.parse(bytes)) }, + RoyaltyStake, + found.value.content, + { type: "RoyaltyStake", objectId: stakeId }, + ); +}); + +/** One shared routed stake by object ID, or `null` when no such object exists. */ +export const getRoutedStakeById = Effect.fn("getRoutedStakeById")(function* ( + routedStakeId: string, +): Effect.fn.Return { + const found = yield* getOptionalObjectContent(routedStakeId); + if (found._tag === "None") return null; + return yield* decodeBcs( + { parse: (bytes) => mapRoutedStake(routedStakeId, RoutedStakeBcs.parse(bytes)) }, + RoutedStake, + found.value.content, + { type: "RoutedStake", objectId: routedStakeId }, + ); +}); + /** Deterministically derive the royalty-pool ID for a parent and type pair. */ export function deriveRoyaltyPoolId( parentId: string, diff --git a/packages/platform/src/share.ts b/packages/platform/src/share.ts index bcb7a5a..fb137b3 100644 --- a/packages/platform/src/share.ts +++ b/packages/platform/src/share.ts @@ -21,10 +21,10 @@ // currency still gets a descriptive, work-specific name. import type { ParallelTransactionExecutor } from "@mysten/sui/transactions"; -import type { ClientWithCoreApi } from "@mysten/sui/client"; import type { Signer } from "@mysten/sui/cryptography"; import { fromBase64, fromHex, toBase64 } from "@mysten/sui/utils"; import { update_constants } from "@mysten/move-bytecode-template"; +import { Effect } from "effect"; import { execThunks, publishedPackageId, @@ -33,6 +33,7 @@ import { allCreatedByType, type ExecResult, } from "@misofm/musicos"; +import { SuiClient, type SuiRpcError, type TransactionFailedError } from "@misofm/effect"; import { publishShareCurrency, initializeShareCurrency, type PackageBytecode } from "./transactions.ts"; import { SHARE_TEMPLATE } from "./share-template.ts"; @@ -107,23 +108,21 @@ export interface CreateShareCurrencyParams { } /** Publishes an immutable package, then initializes a fresh share currency in a second tx. */ -export async function createShareCurrency( - client: ClientWithCoreApi, +export const createShareCurrency = Effect.fn("createShareCurrency")(function* ( signer: Signer, params: CreateShareCurrencyParams, -): Promise { +): Effect.fn.Return { const signerAddress = signer.toSuiAddress(); const initializer = params.initializerAddress ?? signerAddress; const recipient = params.treasuryCapRecipient ?? signerAddress; // Tx 1: publish the share package with the initializer baked in. const patched = patchInitializer(params.template ?? SHARE_TEMPLATE, initializer); - const publishRes = await execThunks(client, signer, publishShareCurrency(patched)); + const publishRes = yield* execThunks(signer, publishShareCurrency(patched)); const packageId = publishedPackageId(publishRes); // Tx 2: initialize → creates the Currency object + transfers the TreasuryCap. - const initRes = await execThunks( - client, + const initRes = yield* execThunks( signer, initializeShareCurrency({ shareCurrencyPackageId: packageId, @@ -141,7 +140,7 @@ export async function createShareCurrency( treasuryCapId: createdByType(initRes, TREASURY_CAP_TYPE), gasUsed: publishRes.gasUsed + initRes.gasUsed, }; -} +}); // ── Batched (parallel) ───────────────────────────────────────────────────── @@ -158,12 +157,12 @@ function chunk(items: T[], size: number): T[][] { * consumes its UpgradeCap in the same PTB. Returns the published package ids * (fungible — order is not meaningful; the caller assigns them to work slots). */ -export async function publishShareCurrencies( +export const publishShareCurrencies = Effect.fn("publishShareCurrencies")(function* ( executor: ParallelTransactionExecutor, initializerAddress: string, count: number, template: PackageBytecode = SHARE_TEMPLATE, -): Promise<{ packageIds: string[]; gasUsed: number }> { +): Effect.fn.Return<{ packageIds: string[]; gasUsed: number }, SuiRpcError> { if (count <= 0) return { packageIds: [], gasUsed: 0 }; // One patched template, reused for every publish (identical bytecode → distinct packages). @@ -174,8 +173,10 @@ export async function publishShareCurrencies( batchSizes.push(Math.min(PUBLISHES_PER_PTB, remaining)); } - const results = await Promise.all( - batchSizes.map((n) => executeViaExecutor(executor, ...Array.from({ length: n }, () => publishShareCurrency(patched)))), + const results = yield* Effect.forEach( + batchSizes, + (n) => executeViaExecutor(executor, ...Array.from({ length: n }, () => publishShareCurrency(patched))), + { concurrency: "unbounded" }, ); const packageIds = results.flatMap(allPublishedPackageIds); @@ -184,7 +185,7 @@ export async function publishShareCurrencies( throw new Error(`Expected ${count} published share packages, got ${packageIds.length}.`); } return { packageIds, gasUsed }; -} +}); /** Re-associates one init PTB's created objects back to their packages by share type. */ function currenciesFromResult(res: ExecResult, batchPkgs: string[]): ShareCurrency[] { @@ -211,19 +212,22 @@ function currenciesFromResult(res: ExecResult, batchPkgs: string[]): ShareCurren * persist that batch immediately. If any batch fails, the succeeded ones are already * reported and this throws, so a resume re-initializes only the still-missing packages. */ -export async function initializeShareCurrencies( +export const initializeShareCurrencies = Effect.fn("initializeShareCurrencies")(function* ( executor: ParallelTransactionExecutor, signerAddress: string, packageIds: string[], metaOf: (packageId: string) => ShareCurrencyMeta, onBatch?: (currencies: ShareCurrency[], gasUsed: number) => void | Promise, -): Promise<{ currencies: ShareCurrency[]; gasUsed: number }> { +): Effect.fn.Return<{ currencies: ShareCurrency[]; gasUsed: number }, never> { if (packageIds.length === 0) return { currencies: [], gasUsed: 0 }; const batches = chunk(packageIds, INITS_PER_PTB); - const settled = await Promise.allSettled( - batches.map(async (batchPkgs) => { - const res = await executeViaExecutor( + // `Effect.either` per batch preserves `Promise.allSettled`'s partial-failure + // tolerance: one batch's `SuiRpcError` does not cancel the others in flight. + const outcomes = yield* Effect.forEach( + batches, + (batchPkgs) => + executeViaExecutor( executor, ...batchPkgs.map((pkg) => { const meta = metaOf(pkg); @@ -235,22 +239,25 @@ export async function initializeShareCurrencies( treasuryCapRecipient: signerAddress, }); }), - ); - const batchCurrencies = currenciesFromResult(res, batchPkgs); - if (onBatch) await onBatch(batchCurrencies, res.gasUsed); - return { batchCurrencies, gasUsed: res.gasUsed }; - }), + ).pipe( + Effect.map((res) => ({ batchCurrencies: currenciesFromResult(res, batchPkgs), gasUsed: res.gasUsed })), + Effect.tap(({ batchCurrencies, gasUsed }) => + onBatch ? Effect.promise(() => Promise.resolve(onBatch(batchCurrencies, gasUsed))) : Effect.void, + ), + Effect.result, + ), + { concurrency: "unbounded" }, ); const currencies: ShareCurrency[] = []; const errors: unknown[] = []; let gasUsed = 0; - for (const s of settled) { - if (s.status === "fulfilled") { - currencies.push(...s.value.batchCurrencies); - gasUsed += s.value.gasUsed; + for (const outcome of outcomes) { + if (outcome._tag === "Success") { + currencies.push(...outcome.success.batchCurrencies); + gasUsed += outcome.success.gasUsed; } else { - errors.push(s.reason); + errors.push(outcome.failure); } } if (errors.length > 0) { @@ -258,4 +265,4 @@ export async function initializeShareCurrencies( throw new Error(`${errors.length}/${batches.length} initialize batch(es) failed: ${msg}`); } return { currencies, gasUsed }; -} +}); diff --git a/packages/platform/src/vault.ts b/packages/platform/src/vault.ts index 72c8cea..9e2beba 100644 --- a/packages/platform/src/vault.ts +++ b/packages/platform/src/vault.ts @@ -13,13 +13,22 @@ */ import type { BcsType } from "@mysten/sui/bcs"; -import type { ClientWithCoreApi } from "@mysten/sui/client"; import type { Transaction, TransactionArgument, TransactionObjectArgument, } from "@mysten/sui/transactions"; import { deriveObjectID, normalizeStructTag } from "@mysten/sui/utils"; +import { Effect, Schema } from "effect"; +import { + assertObjectType, + decodeBcs, + getOptionalObjectContent, + SuiClient, + SuiRpcError, + type BcsDecodeError, + type ObjectTypeMismatchError, +} from "@misofm/effect"; import * as vault from "./contracts/vault/vault.ts"; import * as releaseRevenueDistributor from "./contracts/release_revenue_distributor/release_revenue_distributor.ts"; import * as compositionRoyaltyPoolPlugin from "./contracts/composition_royalty_pool_plugin/composition_royalty_pool_plugin.ts"; @@ -948,27 +957,38 @@ export function parseReleaseTrackRevenueDistributedEvent(content: Uint8Array) { }; } -/** Read and BCS-parse an owner-held VaultAdminCap. */ -export async function getVaultAdminCap( - client: ClientWithCoreApi, +/** + * A caller-held handle to a shared `Vault`: the vault's id and this cap's + * own id. There is no separate `Vault` BCS struct to decode — the vault + * itself is a UID-only shared object whose state lives entirely in dynamic + * fields, so `VaultAdminCap` is the addressable domain value for vault + * custody. + */ +export class VaultAdminCap extends Schema.Class("@misofm/platform/VaultAdminCap")({ + id: Schema.String, + vaultId: Schema.String, +}) {} + +function mapVaultAdminCap(id: string, parsed: ReturnType): typeof VaultAdminCap.Encoded { + return { id, vaultId: parsed.vault_id }; +} + +/** Read and BCS-parse an owner-held VaultAdminCap, or `null` when no such object exists. */ +export const getVaultAdminCap = Effect.fn("getVaultAdminCap")(function* ( vaultAdminCapId: string, expected: { readonly vaultPackageId: string; readonly capType: string }, -) { - const { object } = await client.core.getObject({ - objectId: vaultAdminCapId, - include: { content: true }, - }); - if (!object || object instanceof Error || !object.content) return null; - const expectedType = normalizeStructTag( - `${expected.vaultPackageId}::vault::VaultAdminCap<${expected.capType}>`, +): Effect.fn.Return { + const found = yield* getOptionalObjectContent(vaultAdminCapId); + if (found._tag === "None") return null; + const expectedType = normalizeStructTag(`${expected.vaultPackageId}::vault::VaultAdminCap<${expected.capType}>`); + yield* assertObjectType(vaultAdminCapId, normalizeStructTag(found.value.type), expectedType); + return yield* decodeBcs( + { parse: (bytes) => mapVaultAdminCap(vaultAdminCapId, vault.VaultAdminCap.parse(bytes)) }, + VaultAdminCap, + found.value.content, + { type: "VaultAdminCap", objectId: vaultAdminCapId }, ); - if (!object.type || normalizeStructTag(object.type) !== expectedType) { - throw new Error( - `getVaultAdminCap: expected ${expectedType}, received ${object.type ?? "unknown"}`, - ); - } - return parseVaultAdminCap(object.content); -} +}); /** * Build a `vector>>` for receive-and-* plugin calls. @@ -982,18 +1002,21 @@ export interface ReceivingObjectRef { } /** Resolve owned coins to the exact references required by a Receiving input. */ -export async function resolveReceivingCoins( - client: ClientWithCoreApi, +export const resolveReceivingCoins = Effect.fn("resolveReceivingCoins")(function* ( coinIds: readonly string[], -): Promise { - const { objects } = await client.core.getObjects({ objectIds: [...coinIds] }); +): Effect.fn.Return { + const client = yield* SuiClient; + const { objects } = yield* Effect.tryPromise({ + try: (signal) => client.core.getObjects({ objectIds: [...coinIds], signal }), + catch: (cause) => new SuiRpcError({ operation: "getObjects", cause }), + }); return objects.map((coin, index) => { if (coin instanceof Error || !coin) { throw new Error(`resolveReceivingCoins: could not resolve ${coinIds[index]}`); } return { objectId: coin.objectId, version: coin.version, digest: coin.digest }; }); -} +}); export function receivingCoins( tx: Transaction, diff --git a/packages/platform/tests/auth.test.ts b/packages/platform/tests/auth.test.ts index 240f214..bf6ecca 100644 --- a/packages/platform/tests/auth.test.ts +++ b/packages/platform/tests/auth.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, test } from "bun:test"; +import { Effect } from "effect"; import { AUTHORIZATION_MAX_AGE_MS, MISO_AUTH_HEADERS, @@ -44,7 +45,7 @@ describe("auth contract", () => { test("validates the challenge before signing and returns all required headers", async () => { let signed = ""; - const result = await createAuthorizationHeaders({ + const result = await Effect.runPromise(createAuthorizationHeaders({ apiUrl: "https://api.testnet.miso.fm/platform/usernames/alice", token: "oidc-token", address: ADDRESS, @@ -64,7 +65,7 @@ describe("auth contract", () => { expect(new Headers(init?.headers).get("authorization")).toBe("Bearer oidc-token"); return Response.json(challenge()); }, - }); + })); expect(signed).toBe(challenge().payload); expect(result.headers.get("authorization")).toBe("Bearer oidc-token"); @@ -75,29 +76,32 @@ describe("auth contract", () => { test("does not sign a challenge for another path", async () => { let signCalls = 0; - await expect(createAuthorizationHeaders({ - apiUrl: "https://api.testnet.miso.fm/platform/usernames/alice", - token: "oidc-token", - address: ADDRESS, - method: "PUT", - path: "/platform/usernames/alice", - network: "testnet", - nowMs: NOW, - signer: { - async signPersonalMessage() { - signCalls += 1; - return { signature: "must-not-run" }; + const error = await Effect.runPromise( + createAuthorizationHeaders({ + apiUrl: "https://api.testnet.miso.fm/platform/usernames/alice", + token: "oidc-token", + address: ADDRESS, + method: "PUT", + path: "/platform/usernames/alice", + network: "testnet", + nowMs: NOW, + signer: { + async signPersonalMessage() { + signCalls += 1; + return { signature: "must-not-run" }; + }, }, - }, - fetch: async () => Response.json(challenge("PUT", "/platform/usernames/bob")), - })).rejects.toMatchObject({ code: "invalid_challenge" }); + fetch: async () => Response.json(challenge("PUT", "/platform/usernames/bob")), + }).pipe(Effect.flip), + ); + expect(error).toMatchObject({ code: "invalid_challenge" }); expect(signCalls).toBe(0); }); test("performs the mutation only after signing", async () => { const calls: string[] = []; const nowMs = Date.now(); - const response = await authenticatedFetch( + const response = await Effect.runPromise(authenticatedFetch( "https://api.testnet.miso.fm/platform/usernames/alice", { method: "PUT", @@ -127,15 +131,15 @@ describe("auth contract", () => { return Response.json({ ok: true }); }, }, - ); + )); expect(response.ok).toBe(true); expect(calls).toEqual(["challenge", "sign", "mutation"]); }); test("surfaces a rejected challenge without invoking the signer", async () => { let signed = false; - try { - await createAuthorizationHeaders({ + const error = await Effect.runPromise( + createAuthorizationHeaders({ apiUrl: "https://api.testnet.miso.fm/platform/usernames/alice", token: "expired-token", address: ADDRESS, @@ -151,12 +155,10 @@ describe("auth contract", () => { { error: { message: "Sign in again." } }, { status: 401 }, ), - }); - throw new Error("expected rejection"); - } catch (error) { - expect(error).toBeInstanceOf(MisoAuthError); - expect(error).toMatchObject({ code: "challenge_rejected", status: 401, message: "Sign in again." }); - } + }).pipe(Effect.flip), + ); + expect(error).toBeInstanceOf(MisoAuthError); + expect(error).toMatchObject({ code: "challenge_rejected", status: 401, message: "Sign in again." }); expect(signed).toBe(false); }); }); diff --git a/packages/platform/tests/batching.test.ts b/packages/platform/tests/batching.test.ts index 81e9b8a..76d4f6e 100644 --- a/packages/platform/tests/batching.test.ts +++ b/packages/platform/tests/batching.test.ts @@ -2,9 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, test } from "bun:test"; +import { Effect, Layer } from "effect"; import type { ClientWithCoreApi } from "@mysten/sui/client"; import type { SuiGraphQLClient } from "@mysten/sui/graphql"; import { contracts } from "@misofm/musicos"; +import { SuiClient, SuiGraphQL } from "@misofm/effect"; import { getTrackCreditsByRecordingIds } from "../src/catalog.ts"; import { getReleaseCoversByIds } from "../src/cover.ts"; import { @@ -14,14 +16,14 @@ import { } from "../src/credits.ts"; import { getListing, getPressing, getSale } from "../src/pressing.ts"; import { getReleaseResources } from "../src/read/catalog.ts"; -import type { MisoClient } from "../src/read/client.ts"; +import type { MisoConfig } from "../src/read/config.ts"; function missingClient(calls: unknown[][]): ClientWithCoreApi { return { core: { getObject: async ({ objectId }: { objectId: string }) => { calls.push([objectId]); - return { object: undefined }; + throw new Error(`Object ${objectId} not found`); }, getObjects: async ({ objectIds }: { objectIds: string[] }) => { calls.push(objectIds); @@ -35,14 +37,14 @@ function missingClient(calls: unknown[][]): ClientWithCoreApi { } as unknown as ClientWithCoreApi; } +function runWith(client: ClientWithCoreApi, effect: Effect.Effect): Promise { + return Effect.runPromise(effect.pipe(Effect.provide(SuiClient.layer(client)))); +} + describe("bulk Core reads", () => { test("covers for many releases use one request", async () => { const calls: unknown[][] = []; - const result = await getReleaseCoversByIds( - missingClient(calls), - ["0x1", "0x2"], - "0xa", - ); + const result = await runWith(missingClient(calls), getReleaseCoversByIds(["0x1", "0x2"], "0xa")); expect(result).toEqual({}); expect(calls).toHaveLength(1); expect(calls[0]).toHaveLength(2); @@ -55,31 +57,32 @@ describe("bulk Core reads", () => { getReleaseCreditsByIds, ]) { const calls: unknown[][] = []; - expect( - await read(missingClient(calls), ["0x1", "0x2", "0x1"], "0xa"), - ).toEqual({}); + expect(await runWith(missingClient(calls), read(["0x1", "0x2", "0x1"], "0xa"))).toEqual({}); expect(calls).toHaveLength(1); } }); test("pressings, listings, and a two-object sale never fan out", async () => { const pressingCalls: unknown[][] = []; - expect(await getPressing(missingClient(pressingCalls), "0x1", "0xa")).toBeNull(); + expect(await runWith(missingClient(pressingCalls), getPressing("0x1", "0xa"))).toBeNull(); expect(pressingCalls).toHaveLength(1); const listingCalls: unknown[][] = []; - expect(await getListing(missingClient(listingCalls), "0x2", "0xa")).toBeNull(); + expect(await runWith(missingClient(listingCalls), getListing("0x2", "0xa"))).toBeNull(); expect(listingCalls).toHaveLength(1); const saleCalls: unknown[][] = []; expect( - await getSale(missingClient(saleCalls), { - releaseId: "0x1", - edition: 1, - currencyType: "0x2::sui::SUI", - recordPackageId: "0xa", - recordShopPackageId: "0xb", - }), + await runWith( + missingClient(saleCalls), + getSale({ + releaseId: "0x1", + edition: 1, + currencyType: "0x2::sui::SUI", + recordPackageId: "0xa", + recordShopPackageId: "0xb", + }), + ), ).toEqual({ pressing: null, listing: null }); expect(saleCalls).toHaveLength(1); expect(saleCalls[0]).toHaveLength(2); @@ -121,15 +124,12 @@ test("track credits stay at three Core batches plus one GraphQL query as track c }, } as unknown as SuiGraphQLClient; - const result = await getTrackCreditsByRecordingIds( - client, - graphql, - recordingIds, - { + const result = await Effect.runPromise( + getTrackCreditsByRecordingIds(recordingIds, { misoPackageId: "0xa", compositionCreditsPackageId: "0xd", recordingCreditsPackageId: "0xe", - }, + }).pipe(Effect.provide(Layer.mergeAll(SuiClient.layer(client), SuiGraphQL.layer(graphql)))), ); expect(Object.keys(result)).toEqual(recordingIds); @@ -141,41 +141,36 @@ test("release identity, cover, and credits share one Core request", async () => const releaseId = "0x1"; const calls: Array<{ objectIds: string[] }> = []; const client = { - config: { - protocol: { - releaseCoverArt: "0xa", - releaseCredits: "0xc", + core: { + getObjects: async (input: { objectIds: string[] }) => { + calls.push(input); + return { + objects: [ + { + objectId: releaseId, + content: contracts.release.Release.serialize({ + id: releaseId, + state: { Initialized: true }, + title: "One request", + tracks: [], + }).toBytes(), + json: null, + }, + ...input.objectIds.slice(1).map(() => new Error("field not set")), + ], + }; }, - walrusAggregatorUrl: "https://walrus.example", }, + } as unknown as ClientWithCoreApi; + const config = { protocol: { - core: { - getObjects: async (input: { objectIds: string[] }) => { - calls.push(input); - return { - objects: [ - { - objectId: releaseId, - content: contracts.release.Release.serialize({ - id: releaseId, - state: { Initialized: true }, - title: "One request", - tracks: [], - }).toBytes(), - json: null, - }, - ...input.objectIds.slice(1).map(() => new Error("field not set")), - ], - }; - }, - }, + releaseCoverArt: "0xa", + releaseCredits: "0xc", }, - } as unknown as MisoClient; + walrusAggregatorUrl: "https://walrus.example", + } as unknown as MisoConfig; - const result = await getReleaseResources(client, releaseId, [ - "cover", - "credits", - ]); + const result = await runWith(client, getReleaseResources(releaseId, config, ["cover", "credits"])); expect(result.release.title).toBe("One request"); expect(result.cover).toBeNull(); expect(result.credits).toEqual([]); diff --git a/packages/platform/tests/client.test.ts b/packages/platform/tests/client.test.ts index cec39f5..0e99a9d 100644 --- a/packages/platform/tests/client.test.ts +++ b/packages/platform/tests/client.test.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { expect, test } from "bun:test"; +import { Effect } from "effect"; +import { ObjectNotFoundError } from "@misofm/effect"; import { SuiGrpcClient } from "@mysten/sui/grpc"; import { Transaction, @@ -137,7 +139,7 @@ test("explicit verified deployment binds both finalized sales packages", async ( value: async () => ({ chainIdentifier: DEPLOYMENT.chainIdentifier }), }); const client = base.$extend(miso({ deployment: DEPLOYMENT })); - await client.miso.ready(); + await Effect.runPromise(client.miso.ready()); expect(client.miso.recordPackageId).toBe(RECORD); expect(client.miso.recordShopPackageId).toBe(SHOP); @@ -196,7 +198,7 @@ test("configured client binds composable streaming-transcode attach and unset bu value: async () => ({ chainIdentifier: deployment.chainIdentifier }), }); const client = base.$extend(miso({ deployment })); - await client.miso.ready(); + await Effect.runPromise(client.miso.ready()); const target = { recordingId: A, @@ -230,7 +232,7 @@ test("an available operations deployment binds all nine exact package targets", value: async () => ({ chainIdentifier: DEPLOYMENT.chainIdentifier }), }); const client = base.$extend(miso({ deployment: DEPLOYMENT })); - await client.miso.ready(); + await Effect.runPromise(client.miso.ready()); const tx = new Transaction(); const share = `${id(301)}::share::Share`; const currency = "0x2::sui::SUI"; @@ -296,7 +298,7 @@ test("bundled Testnet deployment exposes the verified sales and operations ABIs" }), }); const client = base.$extend(miso()); - await client.miso.ready(); + await Effect.runPromise(client.miso.ready()); expect(MISO_PLATFORM_DEPLOYMENTS.testnet.recordSales.status).toBe("available"); expect(MISO_PLATFORM_DEPLOYMENTS.testnet.operations.status).toBe("available"); @@ -483,7 +485,7 @@ test("custom deployment registration snapshots nested targets before readiness", custom.objects.releaseRegistry = id(708); custom.legacy.releaseCoverArtPackages.push(id(709)); - await client.miso.ready(); + await Effect.runPromise(client.miso.ready()); expect(client.miso.deployment).not.toBe(custom); expect(client.miso.deployment).toEqual(expected); expectRecursivelyFrozen(client.miso.deployment); @@ -555,7 +557,7 @@ test("deprecated custom config registration snapshots nested targets", async () config.recordSales.recordPackageId = id(801); if (config.operations?.status !== "available") throw new Error("test fixture"); config.operations.actions.partyWallet = id(802); - await client.misoPlatform.ready(); + await Effect.runPromise(client.misoPlatform.ready()); expect(client.misoPlatform.recordPackageId).toBe(RECORD); const tx = new Transaction(); @@ -646,9 +648,9 @@ test("ready memoizes exact-chain validation and gates synchronous builders", asy const first = client.miso.ready(); const second = client.miso.ready(); expect(first).toBe(second); - await Promise.all([first, second]); + await Promise.all([Effect.runPromise(first), Effect.runPromise(second)]); expect(calls).toBe(1); - expect(await client.miso.validateChainIdentifier()).toBe( + expect(await Effect.runPromise(client.miso.validateChainIdentifier())).toBe( DEPLOYMENT.chainIdentifier, ); expect(calls).toBe(1); @@ -706,7 +708,7 @@ test("protocol and nested Party surfaces cannot read or build before readiness", expect(objectReads).toBe(0); expect(chainReads).toBe(0); - await Promise.all([client.miso.ready(), client.miso.ready()]); + await Promise.all([Effect.runPromise(client.miso.ready()), Effect.runPromise(client.miso.ready())]); expect(chainReads).toBe(1); tx.add( client.miso.protocol!.call.release.releaseRegistryId({ arguments: [A] }), @@ -723,14 +725,12 @@ test("protocol and nested Party surfaces cannot read or build before readiness", PARTYOS_DEPLOYMENT.partyos, ]); - await expect(client.miso.protocol!.getReleaseById(A)).rejects.toThrow( - /Release not found/, - ); - await expect(client.miso.party.getPartyById(A)).rejects.toThrow( - /Party not found/, - ); + const releaseError = await Effect.runPromise(Effect.flip(client.miso.protocol!.getReleaseById(A))); + expect(releaseError).toBeInstanceOf(ObjectNotFoundError); + const partyError = await Effect.runPromise(Effect.flip(client.miso.party.getPartyById(A))); + expect(partyError).toBeInstanceOf(ObjectNotFoundError); expect(objectReads).toBe(2); - await client.miso.ready(); + await Effect.runPromise(client.miso.ready()); expect(chainReads).toBe(1); }); @@ -758,11 +758,11 @@ test("MisoPlatformClient.party delegates core reads to PartyosClient and exposes }), }); const client = base.$extend(miso({ deployment: DEPLOYMENT })); - await client.miso.ready(); + await Effect.runPromise(client.miso.ready()); // Core read: `client.miso.party` delegates straight to a `PartyosClient`. expect(client.miso.party.core).toBeInstanceOf(PartyosClient); - const party = await client.miso.party.getPartyById(A); + const party = await Effect.runPromise(client.miso.party.getPartyById(A)); expect(party).toMatchObject({ id: A, kind: "individual", name: "Test Party" }); // Extension builder: `tx.setProfile` is not part of the Party core. @@ -796,7 +796,7 @@ test("deprecated misoPlatform protocol access uses the same explicit readiness g ); expect(() => client.misoPlatform.protocol).toThrow(MisoClientNotReadyError); expect(chainReads).toBe(0); - await client.misoPlatform.ready(); + await Effect.runPromise(client.misoPlatform.ready()); expect(client.misoPlatform.protocol?.deployment.packageId).toBe(MISO); expect(chainReads).toBe(1); }); @@ -816,10 +816,12 @@ test("high-level online reads await readiness automatically", async () => { }); Object.defineProperty(base.core, "getObject", { configurable: true, - value: async () => ({ object: null }), + value: async ({ objectId }: { objectId: string }) => { + throw new Error(`Object ${objectId} not found`); + }, }); const client = base.$extend(miso({ deployment: DEPLOYMENT })); - expect(await client.miso.getRecord(A)).toBeNull(); + expect(await Effect.runPromise(client.miso.getRecord(A))).toBeNull(); expect(chainReads).toBe(1); }); @@ -833,9 +835,8 @@ test("ready rejects a mismatched exact chain identifier", async () => { value: async () => ({ chainIdentifier: "wrong-ledger" }), }); const client = base.$extend(miso({ deployment: DEPLOYMENT })); - await expect(client.miso.ready()).rejects.toBeInstanceOf( - MisoChainIdentifierMismatchError, - ); + const readyError = await Effect.runPromise(Effect.flip(client.miso.ready())); + expect(readyError).toBeInstanceOf(MisoChainIdentifierMismatchError); let executions = 0; const executor = { executeTransaction: async () => { @@ -843,9 +844,10 @@ test("ready rejects a mismatched exact chain identifier", async () => { throw new Error("must not execute"); }, } as unknown as ParallelTransactionExecutor; - await expect( - client.miso.executeViaExecutor(executor, () => {}), - ).rejects.toBeInstanceOf(MisoChainIdentifierMismatchError); + const executeError = await Effect.runPromise( + Effect.flip(client.miso.executeViaExecutor(executor, () => {})), + ); + expect(executeError).toBeInstanceOf(MisoChainIdentifierMismatchError); expect(executions).toBe(0); }); diff --git a/packages/platform/tests/dependency-contract.test.ts b/packages/platform/tests/dependency-contract.test.ts index b3557dd..7397d39 100644 --- a/packages/platform/tests/dependency-contract.test.ts +++ b/packages/platform/tests/dependency-contract.test.ts @@ -27,11 +27,13 @@ test("Sui uses one exact development and peer dependency contract", async () => ); expect(Object.keys(consumer.dependencies ?? {})).toEqual([ + "@misofm/effect", "@misofm/musicos", "@misofm/partyos", "@misofm/platform", "@misofm/streaming", "@misofm/transcoding", + "effect", "typescript", ]); diff --git a/packages/platform/tests/fixtures/isolated-consumer/imports.ts b/packages/platform/tests/fixtures/isolated-consumer/imports.ts index 9231bfd..7825a54 100644 --- a/packages/platform/tests/fixtures/isolated-consumer/imports.ts +++ b/packages/platform/tests/fixtures/isolated-consumer/imports.ts @@ -1,15 +1,20 @@ -// Proves the published `.d.ts` for @misofm/musicos, @misofm/platform, -// @misofm/streaming, and @misofm/transcoding are self-contained when -// installed as real tarballs (see ../../../../scripts/check-consumer.ts, or -// `bun run test:consumer` at the repo root, driving this fixture). Every -// subpath below is used for both a value and a type so tsc cannot elide the -// import — if any packaged declaration file is missing, malformed, or has a -// broken relative path, this file fails to typecheck with `skipLibCheck: false`. +// Proves the published `.d.ts` for @misofm/effect, @misofm/musicos, +// @misofm/partyos, @misofm/platform, @misofm/streaming, and +// @misofm/transcoding are self-contained when installed as real tarballs (see +// ../../../../scripts/check-consumer.ts, or `bun run test:consumer` at the +// repo root, driving this fixture). Every subpath below is used for both a +// value and a type so tsc cannot elide the import — if any packaged +// declaration file is missing, malformed, or has a broken relative path, this +// file fails to typecheck with `skipLibCheck: false`. + +import { SuiClient } from "@misofm/effect"; +import { SuiRpcError } from "@misofm/effect/errors"; import { MisoClient as MusicosRootClient, type MisoOptions as MusicosRootOptions } from "@misofm/musicos"; import { MisoProtocolClient, type MisoProtocolClientOptions } from "@misofm/musicos/client"; -import { isNotFound, type BcsParser } from "@misofm/musicos/queries"; +import { type BcsParser } from "@misofm/musicos/queries"; import { composition as musicosComposition } from "@misofm/musicos/contracts"; +import { BcsDecodeError as MusicosBcsDecodeError } from "@misofm/musicos/errors"; // The raw wildcard subpath (`./contracts/*`): a deep module not exposed // through the curated barrel. @@ -18,6 +23,7 @@ import { Composition as CompositionStruct } from "@misofm/musicos/contracts/musi import { PartyosClient, type Party as PartyosParty } from "@misofm/partyos"; import { getPartyDeployment, type PartyDeployment } from "@misofm/partyos/deployments"; import { Party as PartyStruct } from "@misofm/partyos/contracts/partyos/party"; +import { PartyNotFoundError } from "@misofm/partyos/errors"; import { MisoClient as PlatformRootClient, type MisoOptions as PlatformRootOptions } from "@misofm/platform"; import { MisoPlatformClient, type MisoPlatformConfig } from "@misofm/platform/client"; @@ -27,6 +33,7 @@ import { misoConfig, type MisoConfig } from "@misofm/platform/read"; import { attachCompositionCredit, type CompositionRole } from "@misofm/platform/credits"; import { PartyPlatformClient, type Profile } from "@misofm/platform/party"; import { record as platformRecord } from "@misofm/platform/contracts"; +import { RecordSalesUnavailableError } from "@misofm/platform/errors"; // The raw wildcard subpath (`./contracts/*`): a deep module and a nested // `deps/*` module, neither of which is exposed through the curated barrel. @@ -34,21 +41,28 @@ import { Record as RecordStruct } from "@misofm/platform/contracts/record/record import { LanguageCode as LanguageCodeTuple } from "@misofm/platform/contracts/recording_language/deps/language_code/language_code"; import { HLS_CONTRACT, type Rendition } from "@misofm/streaming"; +import { WarmError } from "@misofm/streaming/errors"; import { chooseSegmentTargetMs, type TranscodeRequest } from "@misofm/transcoding"; +import { ProcessExitError } from "@misofm/transcoding/errors"; // Type-position uses of every imported type. Each is either a directly // exported interface/type alias, or (for the curated `contracts` barrels and // the raw BCS codecs, which expose no standalone type export) derived from // the value itself so it still proves the underlying declaration resolves. +type _SuiClientLayerArg = Parameters[0]; +type _SuiRpcError = SuiRpcError; + type _MusicosRootOptions = MusicosRootOptions; type _MusicosClientOptions = MisoProtocolClientOptions; type _MusicosBcsParser = BcsParser; type _MusicosContractsCompositionNewOptions = Parameters[0]; type _Composition = ReturnType; +type _MusicosBcsDecodeError = MusicosBcsDecodeError; type _PartyosParty = PartyosParty; type _PartyosDeployment = PartyDeployment; type _PartyStruct = ReturnType; +type _PartyNotFoundError = PartyNotFoundError; type _PlatformRootOptions = PlatformRootOptions; type _PlatformClientConfig = MisoPlatformConfig; @@ -60,20 +74,26 @@ type _PlatformParty = Profile; type _PlatformContractsRecordReleaseIdOptions = Parameters[0]; type _Record = ReturnType; type _LanguageCode = ReturnType; +type _PlatformRecordSalesUnavailableError = RecordSalesUnavailableError; type _StreamingRendition = Rendition; +type _StreamingWarmError = WarmError; type _TranscodingRequest = TranscodeRequest; +type _TranscodingProcessExitError = ProcessExitError; // Value-position uses of every imported value. void ([ + SuiClient, + SuiRpcError, MusicosRootClient, MisoProtocolClient, - isNotFound, musicosComposition, CompositionStruct, + MusicosBcsDecodeError, PartyosClient, getPartyDeployment, PartyStruct, + PartyNotFoundError, PlatformRootClient, MisoPlatformClient, derivePressingId, @@ -84,21 +104,28 @@ void ([ platformRecord, RecordStruct, LanguageCodeTuple, + RecordSalesUnavailableError, HLS_CONTRACT, + WarmError, chooseSegmentTargetMs, + ProcessExitError, ] satisfies unknown[]); // Referenced so the otherwise-unused type aliases above cannot be reported as // dead code by a stricter consumer tsconfig than this fixture's own. export type IsolatedConsumerTypeProbe = [ + _SuiClientLayerArg, + _SuiRpcError, _MusicosRootOptions, _MusicosClientOptions, _MusicosBcsParser, _MusicosContractsCompositionNewOptions, _Composition, + _MusicosBcsDecodeError, _PartyosParty, _PartyosDeployment, _PartyStruct, + _PartyNotFoundError, _PlatformRootOptions, _PlatformClientConfig, _PlatformPressingParams, @@ -109,6 +136,9 @@ export type IsolatedConsumerTypeProbe = [ _PlatformContractsRecordReleaseIdOptions, _Record, _LanguageCode, + _PlatformRecordSalesUnavailableError, _StreamingRendition, + _StreamingWarmError, _TranscodingRequest, + _TranscodingProcessExitError, ]; diff --git a/packages/platform/tests/fixtures/isolated-consumer/package.json b/packages/platform/tests/fixtures/isolated-consumer/package.json index ec87476..a4013eb 100644 --- a/packages/platform/tests/fixtures/isolated-consumer/package.json +++ b/packages/platform/tests/fixtures/isolated-consumer/package.json @@ -6,11 +6,13 @@ "check": "tsc --noEmit && node verify.mjs" }, "dependencies": { + "@misofm/effect": "file:../misofm-effect.tgz", "@misofm/musicos": "file:../misofm-musicos.tgz", "@misofm/partyos": "file:../misofm-partyos.tgz", "@misofm/platform": "file:../misofm-platform.tgz", "@misofm/streaming": "file:../misofm-streaming.tgz", "@misofm/transcoding": "file:../misofm-transcoding.tgz", + "effect": "4.0.0-rc.112", "typescript": "5.9.3" } } diff --git a/packages/platform/tests/fixtures/isolated-consumer/verify.mjs b/packages/platform/tests/fixtures/isolated-consumer/verify.mjs index 9a97db1..3709aae 100644 --- a/packages/platform/tests/fixtures/isolated-consumer/verify.mjs +++ b/packages/platform/tests/fixtures/isolated-consumer/verify.mjs @@ -1,13 +1,18 @@ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; import { join } from "node:path"; +import * as effectRoot from "@misofm/effect"; +import * as effectErrors from "@misofm/effect/errors"; + import * as musicosRoot from "@misofm/musicos"; import * as musicosClient from "@misofm/musicos/client"; import * as musicosQueries from "@misofm/musicos/queries"; import * as musicosContracts from "@misofm/musicos/contracts"; +import * as musicosErrors from "@misofm/musicos/errors"; import * as partyosRoot from "@misofm/partyos"; import * as partyosContracts from "@misofm/partyos/contracts"; +import * as partyosErrors from "@misofm/partyos/errors"; import * as platformRoot from "@misofm/platform"; import * as platformClient from "@misofm/platform/client"; @@ -16,11 +21,14 @@ import * as platformVault from "@misofm/platform/vault"; import * as platformRead from "@misofm/platform/read"; import * as platformCredits from "@misofm/platform/credits"; import * as platformParty from "@misofm/platform/party"; +import * as platformErrors from "@misofm/platform/errors"; import * as recordModule from "@misofm/platform/contracts/record/record"; import * as languageCodeModule from "@misofm/platform/contracts/recording_language/deps/language_code/language_code"; import * as streamingRoot from "@misofm/streaming"; +import * as streamingErrors from "@misofm/streaming/errors"; import * as transcodingRoot from "@misofm/transcoding"; +import * as transcodingErrors from "@misofm/transcoding/errors"; function invariant(condition, message) { if (!condition) throw new Error(message); @@ -32,6 +40,13 @@ function hasExport(namespace, name) { // --- module load + expected-export checks ------------------------------- +invariant(Object.keys(effectRoot).length > 0, "@misofm/effect root export did not load"); +invariant(hasExport(effectRoot, "SuiClient"), "@misofm/effect root missing SuiClient"); + +invariant(Object.keys(effectErrors).length > 0, "@misofm/effect/errors export did not load"); +invariant(hasExport(effectErrors, "SuiRpcError"), "@misofm/effect/errors missing SuiRpcError"); +invariant(hasExport(effectErrors, "ObjectNotFoundError"), "@misofm/effect/errors missing ObjectNotFoundError"); + invariant(Object.keys(musicosRoot).length > 0, "@misofm/musicos root export did not load"); invariant(hasExport(musicosRoot, "MisoClient"), "@misofm/musicos root missing MisoClient"); invariant(hasExport(musicosRoot, "contracts"), "@misofm/musicos root missing contracts namespace"); @@ -40,8 +55,9 @@ invariant(Object.keys(musicosClient).length > 0, "@misofm/musicos/client export invariant(hasExport(musicosClient, "MisoProtocolClient"), "@misofm/musicos/client missing MisoProtocolClient"); invariant(Object.keys(musicosQueries).length > 0, "@misofm/musicos/queries export did not load"); -invariant(hasExport(musicosQueries, "isNotFound"), "@misofm/musicos/queries missing isNotFound"); -invariant(musicosQueries.isNotFound(new Error("not found")) === false, "isNotFound behaved unexpectedly"); + +invariant(Object.keys(musicosErrors).length > 0, "@misofm/musicos/errors export did not load"); +invariant(hasExport(musicosErrors, "ObjectNotFoundError"), "@misofm/musicos/errors missing ObjectNotFoundError"); invariant(Object.keys(musicosContracts).length > 0, "@misofm/musicos/contracts export did not load"); invariant(hasExport(musicosContracts, "composition"), "@misofm/musicos/contracts (curated) missing composition"); @@ -59,6 +75,9 @@ invariant(hasExport(partyosRoot, "PartyosClient"), "@misofm/partyos root missing invariant(hasExport(partyosContracts, "party"), "@misofm/partyos/contracts missing party"); invariant(!("uid" in partyosContracts.party), "@misofm/partyos/contracts leaked the raw `uid` accessor"); +invariant(Object.keys(partyosErrors).length > 0, "@misofm/partyos/errors export did not load"); +invariant(hasExport(partyosErrors, "PartyNotFoundError"), "@misofm/partyos/errors missing PartyNotFoundError"); + invariant(Object.keys(platformRoot).length > 0, "@misofm/platform root export did not load"); invariant(hasExport(platformRoot, "MisoClient"), "@misofm/platform root missing MisoClient"); invariant(hasExport(platformRoot, "contracts"), "@misofm/platform root missing contracts namespace"); @@ -81,6 +100,9 @@ invariant(hasExport(platformCredits, "attachCompositionCredit"), "@misofm/platfo invariant(Object.keys(platformParty).length > 0, "@misofm/platform/party export did not load"); invariant(hasExport(platformParty, "PartyPlatformClient"), "@misofm/platform/party missing PartyPlatformClient"); +invariant(Object.keys(platformErrors).length > 0, "@misofm/platform/errors export did not load"); +invariant(hasExport(platformErrors, "RecordSalesUnavailableError"), "@misofm/platform/errors missing RecordSalesUnavailableError"); + // Raw wildcard subpath: a deep module and a nested `deps/*` module that are // NOT reachable through the curated `./contracts` barrel. invariant(Object.keys(recordModule).length > 0, "@misofm/platform/contracts/record/record did not load"); @@ -97,12 +119,18 @@ invariant(typeof languageCodeModule.LanguageCode.parse === "function", "Language invariant(Object.keys(streamingRoot).length > 0, "@misofm/streaming root export did not load"); invariant(hasExport(streamingRoot, "HLS_CONTRACT"), "@misofm/streaming root missing HLS_CONTRACT"); +invariant(Object.keys(streamingErrors).length > 0, "@misofm/streaming/errors export did not load"); +invariant(hasExport(streamingErrors, "WarmError"), "@misofm/streaming/errors missing WarmError"); + invariant(Object.keys(transcodingRoot).length > 0, "@misofm/transcoding root export did not load"); invariant( hasExport(transcodingRoot, "chooseSegmentTargetMs"), "@misofm/transcoding root missing chooseSegmentTargetMs", ); +invariant(Object.keys(transcodingErrors).length > 0, "@misofm/transcoding/errors export did not load"); +invariant(hasExport(transcodingErrors, "ProcessExitError"), "@misofm/transcoding/errors missing ProcessExitError"); + // --- dependency identity: package manager agnostic ----------------------- // // Walks every `node_modules` directory reachable from this consumer (real @@ -159,6 +187,13 @@ function assertSingleInstance(packageName) { assertSingleInstance("@mysten/sui"); +// Every `@misofm/*` package peers on exactly one `effect`; two copies would +// give the shared `SuiClient`/`SuiGraphQL` Context.Service tags (and every +// Schema.TaggedError class) distinct identities across package boundaries, +// silently breaking `Effect.provide`/`Effect.catchTag` for a consumer that +// composes programs from more than one `@misofm/*` package. +assertSingleInstance("effect"); + // A consumer that ends up resolving two copies of the same `@misofm/*` // package (e.g. because one package pins another at a version distinct from // the workspace's) gets two distinct class identities for the same name, so diff --git a/packages/platform/tests/genre.test.ts b/packages/platform/tests/genre.test.ts index 0c625b8..fb1425a 100644 --- a/packages/platform/tests/genre.test.ts +++ b/packages/platform/tests/genre.test.ts @@ -6,6 +6,8 @@ import { bcs } from "@mysten/sui/bcs"; import type { ClientWithCoreApi } from "@mysten/sui/client"; import { Transaction } from "@mysten/sui/transactions"; import { fromBase64 } from "@mysten/sui/utils"; +import { Effect } from "effect"; +import { SuiClient } from "@misofm/effect"; import * as releaseGenreContract from "../src/contracts/release_genre/release_genre.ts"; import * as recordingGenreContract from "../src/contracts/recording_genre/recording_genre.ts"; import { @@ -340,19 +342,28 @@ test("getReleaseGenres and getRecordingGenres read through Core and default to [ }).toBytes(); const client = { core: { - getObjects: async (input: { objectIds: string[] }) => { - expect(input.objectIds).toEqual([fieldId]); - return { objects: [{ objectId: fieldId, content }] }; + getObject: async (input: { objectId: string }) => { + expect(input.objectId).toBe(fieldId); + return { object: { objectId: fieldId, type: "0x2::dynamic_field::Field", version: "1", content } }; }, }, } as unknown as ClientWithCoreApi; - await expect(getReleaseGenres(client, RELEASE, PKG)).resolves.toEqual([A, B]); + const result = await Effect.runPromise( + getReleaseGenres(RELEASE, PKG).pipe(Effect.provide(SuiClient.layer(client))), + ); + expect(result).toEqual([A, B]); const emptyClient = { core: { - getObjects: async () => ({ objects: [undefined] }), + getObject: async () => ({ object: { content: undefined } }), }, } as unknown as ClientWithCoreApi; - await expect(getReleaseGenres(emptyClient, RELEASE, PKG)).resolves.toEqual([]); - await expect(getRecordingGenres(emptyClient, RECORDING, PKG)).resolves.toEqual([]); + const emptyRelease = await Effect.runPromise( + getReleaseGenres(RELEASE, PKG).pipe(Effect.provide(SuiClient.layer(emptyClient))), + ); + expect(emptyRelease).toEqual([]); + const emptyRecording = await Effect.runPromise( + getRecordingGenres(RECORDING, PKG).pipe(Effect.provide(SuiClient.layer(emptyClient))), + ); + expect(emptyRecording).toEqual([]); }); diff --git a/packages/platform/tests/pressing-read.test.ts b/packages/platform/tests/pressing-read.test.ts index c30e930..3ba874d 100644 --- a/packages/platform/tests/pressing-read.test.ts +++ b/packages/platform/tests/pressing-read.test.ts @@ -2,9 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { expect, test } from "bun:test"; +import { Effect } from "effect"; import type { ClientWithCoreApi } from "@mysten/sui/client"; import { Transaction } from "@mysten/sui/transactions"; import { normalizeSuiAddress } from "@mysten/sui/utils"; +import { SuiClient } from "@misofm/effect"; import { authorizeRecordShop, deriveListingId, @@ -130,62 +132,69 @@ test("matches the fixed independent edition, cap, listing, and record derivation ); }); +function run(client: ClientWithCoreApi, effect: Effect.Effect): Promise { + return Effect.runPromise(effect.pipe(Effect.provide(SuiClient.layer(client)))); +} + +function flip(client: ClientWithCoreApi, effect: Effect.Effect): Promise { + return Effect.runPromise(effect.pipe(Effect.provide(SuiClient.layer(client)), Effect.flip)); +} + test("reads exact Pressing, Listing, and concrete Record provenance", async () => { const client = fixtureClient(); - await expect( - getPressing(client, PRESSING, RECORD_PACKAGE), - ).resolves.toMatchObject({ + await expect(run(client, getPressing(PRESSING, RECORD_PACKAGE))).resolves.toMatchObject({ edition: EDITION, supply: 7, maxSupply: 100, }); - await expect( - getListing(client, LISTING, SHOP_PACKAGE), - ).resolves.toMatchObject({ + await expect(run(client, getListing(LISTING, SHOP_PACKAGE))).resolves.toMatchObject({ pricing: { kind: "floor", amount: "2500" }, state: "enabled", }); - await expect( - getRecord(client, RECORD, RECORD_PACKAGE), - ).resolves.toMatchObject({ + await expect(run(client, getRecord(RECORD, RECORD_PACKAGE))).resolves.toMatchObject({ pressingId: PRESSING, edition: EDITION, number: 7, purchasePrice: "3000", purchasedTimestampMs: "1234", }); - const sale = await getSale(client, { - releaseId: RELEASE, - edition: EDITION, - currencyType: CURRENCY, - recordPackageId: RECORD_PACKAGE, - recordShopPackageId: SHOP_PACKAGE, - }); + const sale = await run( + client, + getSale({ + releaseId: RELEASE, + edition: EDITION, + currencyType: CURRENCY, + recordPackageId: RECORD_PACKAGE, + recordShopPackageId: SHOP_PACKAGE, + }), + ); expect(sale.pressing?.supply).toBe(7); expect(sale.listing?.pricing.kind).toBe("floor"); }); test("exact readers reject the wrong package and Listing currency", async () => { const client = fixtureClient(); - await expect(getPressing(client, PRESSING, "0xc")).rejects.toThrow( - /expected/, - ); - await expect(getListing(client, LISTING, "0xc")).rejects.toThrow( - /configured/, - ); + const pressingError = await flip(client, getPressing(PRESSING, "0xc")); + expect(pressingError._tag).toBe("ObjectTypeMismatchError"); + + const listingError = await flip(client, getListing(LISTING, "0xc")); + expect(listingError._tag).toBe("ObjectTypeMismatchError"); + const wrongCurrency = { core: { getObject: async () => ({ object: { + objectId: LISTING, content: listingBytes, type: `${SHOP_PACKAGE}::listing::Listing<0x2::other::OTHER>`, + version: "1", }, }), }, } as unknown as ClientWithCoreApi; - await expect( - getListing(wrongCurrency, LISTING, SHOP_PACKAGE), - ).rejects.toThrow(/derived id/); + const decodeError = await flip(wrongCurrency, getListing(LISTING, SHOP_PACKAGE)); + expect(decodeError._tag).toBe("BcsDecodeError"); + expect(String((decodeError as { cause?: unknown }).cause)).toMatch(/derived id/); }); function calls( diff --git a/packages/platform/tests/read/catalog-resources.test.ts b/packages/platform/tests/read/catalog-resources.test.ts index adf4caf..523650e 100644 --- a/packages/platform/tests/read/catalog-resources.test.ts +++ b/packages/platform/tests/read/catalog-resources.test.ts @@ -2,16 +2,17 @@ // SPDX-License-Identifier: Apache-2.0 import { expect, test } from "bun:test"; +import { Effect } from "effect"; import type { ClientWithCoreApi } from "@mysten/sui/client"; +import { SuiClient } from "@misofm/effect"; import { deriveSaleIds } from "../../src/pressing.ts"; import * as listing from "../../src/contracts/record_shop/listing.ts"; import * as pressing from "../../src/contracts/record/pressing.ts"; import { getListingView, getPressingView, - toTracks, } from "../../src/read/catalog.ts"; -import type { MisoClient } from "../../src/read/client.ts"; +import type { MisoConfig } from "../../src/read/config.ts"; const RELEASE = `0x${"33".repeat(32)}`; const CURRENCY = "0x2::sui::SUI"; @@ -42,40 +43,47 @@ const listingBytes = listing.Listing.serialize({ state: { Enabled: true }, }).toBytes(); -function fixtureClient(): MisoClient { - const protocol = { +function fixtureClient(): ClientWithCoreApi { + return { core: { getObject: async ({ objectId }: { objectId: string }) => ({ object: objectId === PRESSING ? { + objectId, content: pressingBytes, type: `${RECORD_PACKAGE}::pressing::Pressing`, + version: "1", } : objectId === LISTING ? { + objectId, content: listingBytes, type: `${SHOP_PACKAGE}::listing::Listing<${CURRENCY}>`, + version: "1", } - : undefined, + : (() => { + throw new Error(`Object ${objectId} not found`); + })(), }), }, } as unknown as ClientWithCoreApi; +} - return { - config: { - recordSales: { - status: "available", - recordPackageId: RECORD_PACKAGE, - recordShopPackageId: SHOP_PACKAGE, - }, - }, - protocol, - } as unknown as MisoClient; +const config = { + recordSales: { + status: "available", + recordPackageId: RECORD_PACKAGE, + recordShopPackageId: SHOP_PACKAGE, + }, +} as unknown as MisoConfig; + +function run(effect: Effect.Effect): Promise { + return Effect.runPromise(effect.pipe(Effect.provide(SuiClient.layer(fixtureClient())))); } test("projects an atomic pressing to JSON-safe values", async () => { - const view = await getPressingView(fixtureClient(), PRESSING); + const view = await run(getPressingView(PRESSING, config)); expect(view).toMatchObject({ id: PRESSING, releaseId: RELEASE, @@ -87,7 +95,7 @@ test("projects an atomic pressing to JSON-safe values", async () => { }); test("projects a derived listing to JSON-safe values", async () => { - const view = await getListingView(fixtureClient(), PRESSING, CURRENCY); + const view = await run(getListingView(PRESSING, CURRENCY, config)); expect(view).toMatchObject({ id: LISTING, pressingId: PRESSING, diff --git a/packages/platform/tests/read/receipts.test.ts b/packages/platform/tests/read/receipts.test.ts index 8fe9420..443a443 100644 --- a/packages/platform/tests/read/receipts.test.ts +++ b/packages/platform/tests/read/receipts.test.ts @@ -4,11 +4,11 @@ import { describe, expect, test } from "bun:test"; import * as listing from "../../src/contracts/record_shop/listing.ts"; import { - MalformedRecordSoldEventError, findRecordSale, findRecordSales, isRecordSoldEventType, } from "../../src/read/receipts.ts"; +import { MalformedRecordSoldEventError } from "../../src/errors.ts"; const SHOP_PACKAGE = "0xa"; const CURRENCY = "0x2::sui::SUI"; diff --git a/packages/platform/tests/read/release-kind.test.ts b/packages/platform/tests/read/release-kind.test.ts index d4b679b..7577907 100644 --- a/packages/platform/tests/read/release-kind.test.ts +++ b/packages/platform/tests/read/release-kind.test.ts @@ -2,8 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { expect, test } from "bun:test"; +import { Effect } from "effect"; import { bcs } from "@mysten/sui/bcs"; import type { ClientWithCoreApi } from "@mysten/sui/client"; +import { SuiClient } from "@misofm/effect"; import * as releaseKind from "../../src/contracts/release_kind/release_kind.ts"; import { getReleaseKind, @@ -27,21 +29,22 @@ const content = Field.serialize({ function client(value: Uint8Array | null): ClientWithCoreApi { return { core: { - getObjects: async () => ({ - objects: value - ? [{ objectId: FIELD, content: value, type: "0x2::dynamic_field::Field" }] - : [new Error("not found")], - }), + getObject: async ({ objectId }: { objectId: string }) => { + if (!value || objectId !== FIELD) throw new Error(`Object ${objectId} not found`); + return { object: { objectId, content: value, type: "0x2::dynamic_field::Field", version: "1" } }; + }, }, } as unknown as ClientWithCoreApi; } +function run(effect: Effect.Effect, fake: ClientWithCoreApi): Promise { + return Effect.runPromise(effect.pipe(Effect.provide(SuiClient.layer(fake)))); +} + test("reads the release_kind dynamic field", async () => { - await expect(getReleaseKind(client(content), RELEASE, PACKAGE)).resolves.toBe( - "EP", - ); + await expect(run(getReleaseKind(RELEASE, PACKAGE), client(content))).resolves.toBe("EP"); }); test("an absent release_kind is null", async () => { - await expect(getReleaseKind(client(null), RELEASE, PACKAGE)).resolves.toBeNull(); + await expect(run(getReleaseKind(RELEASE, PACKAGE), client(null))).resolves.toBeNull(); }); diff --git a/packages/platform/tests/read/vaulted-wallet.test.ts b/packages/platform/tests/read/vaulted-wallet.test.ts index 9f20e36..1d9eed8 100644 --- a/packages/platform/tests/read/vaulted-wallet.test.ts +++ b/packages/platform/tests/read/vaulted-wallet.test.ts @@ -2,9 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, test } from "bun:test"; +import { Effect } from "effect"; +import type { ClientWithCoreApi } from "@mysten/sui/client"; import { contracts } from "@misofm/musicos"; +import { SuiClient } from "@misofm/effect"; import * as vaultContract from "../../src/contracts/vault/vault.ts"; -import type { MisoClient } from "../../src/read/client.ts"; +import type { MisoConfig } from "../../src/read/config.ts"; import { classifyVaultedWorkAdminCapType, getOwnedWorks, @@ -51,7 +54,7 @@ function vaultCapBytes(): Uint8Array { return vaultContract.VaultAdminCap.serialize({ id: VAULT_CAP, vault_id: VAULT }).toBytes(); } -function fakeClient(): MisoClient { +function fakeClient(): ClientWithCoreApi { const core = { listOwnedObjects: async ({ type }: { type?: string }) => ({ objects: @@ -76,6 +79,7 @@ function fakeClient(): MisoClient { type: VAULT_CAP_TYPE, content: vaultCapBytes(), json: { vault_id: VAULT }, + version: "1", }, }; } @@ -85,20 +89,23 @@ function fakeClient(): MisoClient { objectId, type: `${MISO}::release::Release`, content: releaseBytes(), + version: "1", }, }; } throw new Error(`Unexpected object ${objectId}`); }, }; - return { - config: { - deployment: { musicos: MISO }, - protocol: { vault: VAULT_PACKAGE }, - }, - protocol: { core }, - graphql: {}, - } as unknown as MisoClient; + return { core } as unknown as ClientWithCoreApi; +} + +const config = { + deployment: { musicos: MISO }, + protocol: { vault: VAULT_PACKAGE }, +} as unknown as MisoConfig; + +function run(effect: Effect.Effect): Promise { + return Effect.runPromise(effect.pipe(Effect.provide(SuiClient.layer(fakeClient())))); } describe("vaulted work cap classification", () => { @@ -140,7 +147,7 @@ describe("vaulted work cap classification", () => { }); test("getOwnedWorks lists a release through its owner-held VaultAdminCap", async () => { - await expect(getOwnedWorks(fakeClient(), `0x${"bb".repeat(32)}`)).resolves.toEqual([ + await expect(run(getOwnedWorks(`0x${"bb".repeat(32)}`, config))).resolves.toEqual([ { capId: VAULT_CAP, kind: "release", @@ -152,7 +159,7 @@ test("getOwnedWorks lists a release through its owner-held VaultAdminCap", async }); test("getWorkByCap resolves a VaultAdminCap detail route through its shared vault", async () => { - await expect(getWorkByCap(fakeClient(), VAULT_CAP)).resolves.toEqual({ + await expect(run(getWorkByCap(VAULT_CAP, config))).resolves.toEqual({ capId: VAULT_CAP, kind: "release", workId: RELEASE, diff --git a/packages/platform/tests/read/wallet.test.ts b/packages/platform/tests/read/wallet.test.ts index b07dc48..eced2bc 100644 --- a/packages/platform/tests/read/wallet.test.ts +++ b/packages/platform/tests/read/wallet.test.ts @@ -2,9 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, test } from "bun:test"; +import { Effect } from "effect"; +import type { ClientWithCoreApi } from "@mysten/sui/client"; +import { SuiClient } from "@misofm/effect"; import * as recordContract from "../../src/contracts/record/record.ts"; import { deriveRecordId } from "../../src/pressing.ts"; -import type { MisoClient } from "../../src/read/client.ts"; +import type { MisoConfig } from "../../src/read/config.ts"; import { getBalance, getOwnedRecords } from "../../src/read/wallet.ts"; type Obj = { objectId: string; type: string; content?: Uint8Array | null }; @@ -48,27 +51,18 @@ function recordObject( } /** A client whose `listOwnedObjects` serves the given pages in order. */ -function fakeClient(pages: Page[]): { client: MisoClient; calls: number; types: string[] } { +function fakeClient(pages: Page[]): { client: ClientWithCoreApi; calls: number; types: string[] } { const state = { calls: 0, types: [] as string[] }; const client = { - config: { - recordSales: { - status: "available", - recordPackageId: RECORD_PACKAGE, - recordShopPackageId: RECORD_SHOP_PACKAGE, + core: { + listOwnedObjects: async ({ type }: { type?: string }) => { + if (type) state.types.push(type); + const page = pages[state.calls] ?? { objects: [], hasNextPage: false, cursor: null }; + state.calls++; + return page; }, }, - protocol: { - core: { - listOwnedObjects: async ({ type }: { type?: string }) => { - if (type) state.types.push(type); - const page = pages[state.calls] ?? { objects: [], hasNextPage: false, cursor: null }; - state.calls++; - return page; - }, - }, - }, - } as unknown as MisoClient; + } as unknown as ClientWithCoreApi; return { client, get calls() { @@ -80,6 +74,18 @@ function fakeClient(pages: Page[]): { client: MisoClient; calls: number; types: }; } +const recordSalesConfig = { + recordSales: { + status: "available", + recordPackageId: RECORD_PACKAGE, + recordShopPackageId: RECORD_SHOP_PACKAGE, + }, +} as unknown as MisoConfig; + +function run(client: ClientWithCoreApi, effect: Effect.Effect): Promise { + return Effect.runPromise(effect.pipe(Effect.provide(SuiClient.layer(client)))); +} + describe("getOwnedRecords", () => { test("projects exact finalized Record purchase provenance", async () => { const record = recordObject(7); @@ -92,7 +98,7 @@ describe("getOwnedRecords", () => { cursor: null, }]); - await expect(getOwnedRecords(fake.client, "0xowner")).resolves.toEqual([{ + await expect(run(fake.client, getOwnedRecords("0xowner", recordSalesConfig))).resolves.toEqual([{ id: record.objectId, type: RECORD_TYPE, releaseId: RELEASE_ID, @@ -120,7 +126,7 @@ describe("getOwnedRecords", () => { cursor: null, }]); - const records = await getOwnedRecords(client, "0xowner"); + const records = await run(client, getOwnedRecords("0xowner", recordSalesConfig)); expect(records.map(({ id }) => id)).toEqual([trusted.objectId]); }); @@ -131,14 +137,16 @@ describe("getOwnedRecords", () => { cursor: null, }]); - await expect(getOwnedRecords(client, "0xowner")).rejects.toThrow(/no BCS content/); + await expect(run(client, getOwnedRecords("0xowner", recordSalesConfig))).rejects.toThrow(/no BCS content/); }); test("fails closed when the embedded UID disagrees with the object id", async () => { const object = recordObject(5, { embeddedId: `0x${"56".repeat(32)}` }); const { client } = fakeClient([{ objects: [object], hasNextPage: false, cursor: null }]); - await expect(getOwnedRecords(client, "0xowner")).rejects.toThrow(/mismatched embedded UID/); + await expect(run(client, getOwnedRecords("0xowner", recordSalesConfig))).rejects.toThrow( + /mismatched embedded UID/, + ); }); test("fails closed when the Record identity is not derived from its Pressing and number", async () => { @@ -146,7 +154,7 @@ describe("getOwnedRecords", () => { const object = recordObject(6, { objectId: arbitraryId, embeddedId: arbitraryId }); const { client } = fakeClient([{ objects: [object], hasNextPage: false, cursor: null }]); - await expect(getOwnedRecords(client, "0xowner")).rejects.toThrow(/not derived/); + await expect(run(client, getOwnedRecords("0xowner", recordSalesConfig))).rejects.toThrow(/not derived/); }); test("follows pagination until the last page", async () => { @@ -157,7 +165,7 @@ describe("getOwnedRecords", () => { }); const fake = fakeClient([page(1, true, "c1"), page(2, true, "c2"), page(3, false, null)]); - const records = await getOwnedRecords(fake.client, "0xowner"); + const records = await run(fake.client, getOwnedRecords("0xowner", recordSalesConfig)); expect(records.map(({ id }) => id)).toEqual([ deriveRecordId(PRESSING_ID, 1, RECORD_PACKAGE), deriveRecordId(PRESSING_ID, 2, RECORD_PACKAGE), @@ -174,7 +182,7 @@ describe("getOwnedRecords", () => { })); const fake = fakeClient(endless); - const records = await getOwnedRecords(fake.client, "0xowner"); + const records = await run(fake.client, getOwnedRecords("0xowner", recordSalesConfig)); expect(fake.calls).toBe(20); expect(records).toHaveLength(20); }); @@ -186,16 +194,17 @@ describe("getOwnedRecords", () => { cursor: null, }]); - await expect(getOwnedRecords(fake.client, "0xowner")).resolves.toHaveLength(1); + await expect(run(fake.client, getOwnedRecords("0xowner", recordSalesConfig))).resolves.toHaveLength(1); expect(fake.calls).toBe(1); }); test("fails closed when Record sales are unavailable", async () => { - const client = { - config: { recordSales: { status: "unavailable", reason: "legacy deployment" } }, - } as unknown as MisoClient; + const config = { + recordSales: { status: "unavailable", reason: "legacy deployment" }, + } as unknown as MisoConfig; + const { client } = fakeClient([]); - await expect(getOwnedRecords(client, "0xowner")).rejects.toThrow(/unavailable: legacy deployment/); + await expect(run(client, getOwnedRecords("0xowner", config))).rejects.toThrow(/unavailable: legacy deployment/); }); }); @@ -204,36 +213,34 @@ describe("getBalance", () => { const balanceCalls: Array<{ owner: string; coinType: string }> = []; const metadataCalls: string[] = []; const client = { - config: { money: { usdCoinType: "0x2::usd::USD", usdDecimals: 99 } }, - protocol: { - core: { - getBalance: async ({ owner, coinType }: { owner: string; coinType: string }) => { - balanceCalls.push({ owner, coinType }); - return { - balance: { - coinType, - balance: "90000000", - coinBalance: "55000000", - addressBalance: "35000000", - }, - }; - }, - getCoinMetadata: async ({ coinType }: { coinType: string }) => { - metadataCalls.push(coinType); - return { - coinMetadata: { - id: "0xmetadata", - decimals: 6, - name: "USD", - symbol: "USD", - description: "", - iconUrl: null, - }, - }; - }, + core: { + getBalance: async ({ owner, coinType }: { owner: string; coinType: string }) => { + balanceCalls.push({ owner, coinType }); + return { + balance: { + coinType, + balance: "90000000", + coinBalance: "55000000", + addressBalance: "35000000", + }, + }; + }, + getCoinMetadata: async ({ coinType }: { coinType: string }) => { + metadataCalls.push(coinType); + return { + coinMetadata: { + id: "0xmetadata", + decimals: 6, + name: "USD", + symbol: "USD", + description: "", + iconUrl: null, + }, + }; }, }, - } as unknown as MisoClient; + } as unknown as ClientWithCoreApi; + const config = { money: { usdCoinType: "0x2::usd::USD", usdDecimals: 99 } } as unknown as MisoConfig; const expected = { address: `0x${"0".repeat(63)}1`, @@ -243,8 +250,8 @@ describe("getBalance", () => { addressBalance: "35000000", decimals: 6, }; - await expect(getBalance(client, "0x1")).resolves.toEqual(expected); - await expect(getBalance(client, "0x1")).resolves.toEqual(expected); + await expect(run(client, getBalance("0x1", config))).resolves.toEqual(expected); + await expect(run(client, getBalance("0x1", config))).resolves.toEqual(expected); expect(balanceCalls).toEqual([ { owner: "0x1", coinType: "0x2::usd::USD" }, { owner: "0x1", coinType: "0x2::usd::USD" }, @@ -254,22 +261,24 @@ describe("getBalance", () => { test("fails closed when coin metadata is unavailable", async () => { const client = { - config: { money: { usdCoinType: "0x2::usd::USD", usdDecimals: 99 } }, - protocol: { - core: { - getBalance: async () => ({ - balance: { - coinType: "0x2::usd::USD", - balance: "0", - coinBalance: "0", - addressBalance: "0", - }, - }), - getCoinMetadata: async () => ({ coinMetadata: null }), - }, + core: { + getBalance: async () => ({ + balance: { + coinType: "0x2::usd::USD", + balance: "0", + coinBalance: "0", + addressBalance: "0", + }, + }), + getCoinMetadata: async () => ({ coinMetadata: null }), }, - } as unknown as MisoClient; + } as unknown as ClientWithCoreApi; + const config = { money: { usdCoinType: "0x2::usd::USD", usdDecimals: 99 } } as unknown as MisoConfig; - await expect(getBalance(client, "0x1")).rejects.toThrow(/decimal precision/); + const error = await Effect.runPromise( + getBalance("0x1", config).pipe(Effect.provide(SuiClient.layer(client)), Effect.flip), + ); + expect(error._tag).toBe("SuiRpcError"); + expect(String((error as { cause?: unknown }).cause)).toMatch(/decimal precision/); }); }); diff --git a/packages/platform/tests/read/works.test.ts b/packages/platform/tests/read/works.test.ts index dc119f7..9539dd4 100644 --- a/packages/platform/tests/read/works.test.ts +++ b/packages/platform/tests/read/works.test.ts @@ -2,9 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { expect, test } from "bun:test"; +import { Effect, Layer } from "effect"; import type { ClientWithCoreApi } from "@mysten/sui/client"; import type { SuiGraphQLClient } from "@mysten/sui/graphql"; import { contracts } from "@misofm/musicos"; +import { SuiClient, SuiGraphQL } from "@misofm/effect"; import { getRecordingTitles, getWorksByIds, parseReleaseObject } from "../../src/read/works.ts"; const RELEASE = "0x" + "11".repeat(32); @@ -28,8 +30,8 @@ function releaseBytes(): Uint8Array { }).toBytes(); } -test("parseReleaseObject reads the canonical flat BCS tracklist", () => { - const release = parseReleaseObject(RELEASE, releaseBytes()); +test("parseReleaseObject reads the canonical flat BCS tracklist", async () => { + const release = await Effect.runPromise(parseReleaseObject(RELEASE, releaseBytes())); expect(release).toEqual({ id: RELEASE, state: { type: "Published", timestampMs: 123 }, @@ -58,7 +60,11 @@ test("getWorksByIds ignores the retired discs-shaped JSON projection", async () }, } as unknown as ClientWithCoreApi; - const works = await getWorksByIds(client, { compositions: [], recordings: [], releases: [RELEASE] }); + const works = await Effect.runPromise( + getWorksByIds({ compositions: [], recordings: [], releases: [RELEASE] }).pipe( + Effect.provide(SuiClient.layer(client)), + ), + ); expect(calls[0]?.include).toEqual({ content: true }); expect(works.releases[RELEASE]?.title).toBe("Canonical flat release"); expect(works.releases[RELEASE]?.tracks).toHaveLength(2); @@ -97,7 +103,11 @@ test("getRecordingTitles derives titles from the canonical composition type, not query: async () => ({ data: { composition0: { nodes: [{ address: COMPOSITION }] } } }), } as unknown as SuiGraphQLClient; - const titles = await getRecordingTitles(client, graphql, [RECORDING_ONE], PACKAGE); + const titles = await Effect.runPromise( + getRecordingTitles([RECORDING_ONE], PACKAGE).pipe( + Effect.provide(Layer.mergeAll(SuiClient.layer(client), SuiGraphQL.layer(graphql))), + ), + ); expect(titles).toEqual({ [RECORDING_ONE]: "Canonical composition title" }); expect(calls[0]?.include).toEqual({}); }); diff --git a/packages/platform/tests/recording-extensions.test.ts b/packages/platform/tests/recording-extensions.test.ts index 86911b9..b4f9c95 100644 --- a/packages/platform/tests/recording-extensions.test.ts +++ b/packages/platform/tests/recording-extensions.test.ts @@ -5,6 +5,8 @@ import { expect, test } from "bun:test"; import { bcs } from "@mysten/sui/bcs"; import type { ClientWithCoreApi } from "@mysten/sui/client"; import { Transaction } from "@mysten/sui/transactions"; +import { Effect } from "effect"; +import { SuiClient } from "@misofm/effect"; import * as walrusData from "../src/contracts/recording_master_reference/deps/ori/data.ts"; import * as masterReference from "../src/contracts/recording_master_reference/recording_master_reference.ts"; import * as engineSessionContract from "../src/contracts/recording_engine_session/recording_engine_session.ts"; @@ -60,6 +62,10 @@ function moveCalls(tx: Transaction): MoveCall[] { ); } +function run(effect: Effect.Effect, client: ClientWithCoreApi): Promise { + return Effect.runPromise(effect.pipe(Effect.provide(SuiClient.layer(client)))); +} + test("builds a composable streaming-transcode attachment from a complete Quilt ID", () => { const tx = new Transaction(); setRecordingStreamingTranscode({ @@ -203,13 +209,13 @@ test("reads an engine session's session blob and stems table", async () => { }).toBytes(); const client = { core: { - getObjects: async (input: { objectIds: string[] }) => { - expect(input.objectIds).toEqual([fieldId]); - return { objects: [{ objectId: fieldId, content }] }; + getObject: async (input: { objectId: string }) => { + expect(input.objectId).toBe(fieldId); + return { object: { objectId: fieldId, type: "0x2::dynamic_field::Field", version: "1", content } }; }, }, } as unknown as ClientWithCoreApi; - await expect(getRecordingEngineSession(client, RECORDING_ONE, ENGINE_SESSION_PACKAGE)).resolves.toEqual({ + await expect(run(getRecordingEngineSession(RECORDING_ONE, ENGINE_SESSION_PACKAGE), client)).resolves.toEqual({ sessionBlobId: "7", stems: [ { digest: "00".repeat(32), blobId: "1" }, @@ -247,7 +253,7 @@ test("reads a Recording's master-reference blob id", async () => { } as unknown as ClientWithCoreApi; await expect( - getRecordingMasterReference(client, RECORDING_ONE, PACKAGE), + run(getRecordingMasterReference(RECORDING_ONE, PACKAGE), client), ).resolves.toBe(String(BLOB_ID)); }); @@ -271,11 +277,7 @@ test("batches unique master-reference fields and omits absent recordings", async } as unknown as ClientWithCoreApi; await expect( - getRecordingMasterReferencesByIds( - client, - [RECORDING_ONE, RECORDING_TWO, RECORDING_ONE], - PACKAGE, - ), + run(getRecordingMasterReferencesByIds([RECORDING_ONE, RECORDING_TWO, RECORDING_ONE], PACKAGE), client), ).resolves.toEqual({ [RECORDING_ONE]: String(BLOB_ID) }); expect(calls).toEqual([ [ @@ -353,10 +355,9 @@ function batchClient(content: (recordingId: string) => Uint8Array, calls: string test("getRecordingStreamingTranscodesByIds derives one field per recording and drops missing ones", async () => { const calls: string[][] = []; await expect( - getRecordingStreamingTranscodesByIds( + run( + getRecordingStreamingTranscodesByIds([RECORDING_ONE, RECORDING_TWO, RECORDING_ONE], PACKAGE), batchClient(transcodeContent, calls), - [RECORDING_ONE, RECORDING_TWO, RECORDING_ONE], - PACKAGE, ), ).resolves.toEqual({ [RECORDING_ONE]: String(QUILT_ID) }); expect(calls).toEqual([ @@ -369,17 +370,20 @@ test("getRecordingStreamingTranscodesByIds derives one field per recording and d test("getRecordingStreamingTranscode returns null for a recording without a transcode", async () => { const client = batchClient(transcodeContent, []); - await expect(getRecordingStreamingTranscode(client, RECORDING_ONE, PACKAGE)).resolves.toBe(String(QUILT_ID)); + await expect(run(getRecordingStreamingTranscode(RECORDING_ONE, PACKAGE), client)).resolves.toBe(String(QUILT_ID)); const missing = { core: { getObjects: async () => ({ objects: [new Error("not found")] }) }, } as unknown as ClientWithCoreApi; - await expect(getRecordingStreamingTranscode(missing, RECORDING_TWO, PACKAGE)).resolves.toBeNull(); + await expect(run(getRecordingStreamingTranscode(RECORDING_TWO, PACKAGE), missing)).resolves.toBeNull(); }); test("getRecordingEngineSessionsByIds parses sessions and stems in one request", async () => { const calls: string[][] = []; await expect( - getRecordingEngineSessionsByIds(batchClient(sessionContent, calls), [RECORDING_ONE, RECORDING_TWO], PACKAGE), + run( + getRecordingEngineSessionsByIds([RECORDING_ONE, RECORDING_TWO], PACKAGE), + batchClient(sessionContent, calls), + ), ).resolves.toEqual({ [RECORDING_ONE]: { sessionBlobId: String(BLOB_ID), diff --git a/packages/transcoding/package.json b/packages/transcoding/package.json index 0dbfbdb..6e76a7b 100644 --- a/packages/transcoding/package.json +++ b/packages/transcoding/package.json @@ -57,7 +57,7 @@ "test:ffmpeg": "bun test test/ffmpeg-integration.test.ts" }, "dependencies": { - "@misofm/streaming": "0.2.1", + "@misofm/streaming": "0.3.0", "m3u8-parser": "7.2.0" }, "peerDependencies": { diff --git a/packages/transcoding/test/dependency-contract.test.ts b/packages/transcoding/test/dependency-contract.test.ts index 923319b..096e5a7 100644 --- a/packages/transcoding/test/dependency-contract.test.ts +++ b/packages/transcoding/test/dependency-contract.test.ts @@ -12,7 +12,7 @@ test("Effect peers are exact and the runtime dependency is storage-neutral", asy effect: "4.0.0-rc.112", }); expect(manifest.dependencies).toEqual({ - "@misofm/streaming": "0.2.1", + "@misofm/streaming": "0.3.0", "m3u8-parser": "7.2.0", }); }); From 465d15b9730e7f63c1f98176ee40292353ebff97 Mon Sep 17 00:00:00 2001 From: BL Date: Tue, 8 Sep 2026 17:49:47 +0000 Subject: [PATCH 4/5] fix(platform,musicos): review follow-ups for the Effect migration - share.initializeShareCurrencies: a rejecting onBatch callback is that batch's typed failure (SuiRpcError) instead of a defect, so it no longer interrupts sibling batches whose transactions are already submitted. - musicos.getWorksByIds: an id requested under two work kinds fails with ConflictingWorkKindError instead of a thrown defect. - platform README: the protocol accessor is optional until ready(). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QGmuA3eMr4iaRTdz3J8JZJ --- packages/musicos/src/client.ts | 3 ++- packages/musicos/src/errors.ts | 11 +++++++++++ packages/musicos/src/queries.ts | 5 +++-- packages/platform/README.md | 6 +++--- packages/platform/src/share.ts | 12 ++++++++++-- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/packages/musicos/src/client.ts b/packages/musicos/src/client.ts index d78455a..9eaff47 100644 --- a/packages/musicos/src/client.ts +++ b/packages/musicos/src/client.ts @@ -1,6 +1,7 @@ // Copyright (c) Miso Labs, Inc. // SPDX-License-Identifier: Apache-2.0 +import type { ConflictingWorkKindError } from "./errors.ts"; import type { ClientWithCoreApi, SuiClientRegistration, @@ -166,7 +167,7 @@ export class MisoProtocolClient { getCompositionsByIds(ids: string[]): Effect.Effect, SuiRpcError | BcsDecodeError> { return this.#run(queries.getCompositionsByIds(ids)); } - getWorksByIds(ids: queries.WorkIds): Effect.Effect { + getWorksByIds(ids: queries.WorkIds): Effect.Effect { return this.#run(queries.getWorksByIds(ids)); } getWorkAddressesByShareTypes( diff --git a/packages/musicos/src/errors.ts b/packages/musicos/src/errors.ts index 0aa6864..a54af80 100644 --- a/packages/musicos/src/errors.ts +++ b/packages/musicos/src/errors.ts @@ -7,6 +7,8 @@ // `@misofm/effect`'s tagged errors. Re-exported here so callers can import the // complete error vocabulary from one place (`@misofm/musicos/errors`) without // also depending on `@misofm/effect` directly. +import { Schema } from "effect"; + export { ObjectNotFoundError, ObjectTypeMismatchError, @@ -17,3 +19,12 @@ export { DeploymentError, type SuiReadError, } from "@misofm/effect/errors"; + +/** One object id was requested under two different work kinds in a single batched read. */ +export class ConflictingWorkKindError extends Schema.TaggedError()( + "ConflictingWorkKindError", + { + objectId: Schema.String, + kinds: Schema.Array(Schema.String), + }, +) {} diff --git a/packages/musicos/src/queries.ts b/packages/musicos/src/queries.ts index 0c3ec52..5808222 100644 --- a/packages/musicos/src/queries.ts +++ b/packages/musicos/src/queries.ts @@ -14,6 +14,7 @@ // return `Option.none()` for "not attached" — extension data is optional by // design, and absence is a normal, expected state, not a failure. +import { ConflictingWorkKindError } from "./errors.ts"; import { Effect, Option, Schema } from "effect"; import { graphql } from "@mysten/sui/graphql/schema"; import { deriveObjectID, normalizeSuiAddress } from "@mysten/sui/utils"; @@ -462,14 +463,14 @@ export interface WorksById { /** Fetch and parse heterogeneous work objects through one Core bulk request. */ export const getWorksByIds = Effect.fn("getWorksByIds")(function* ( ids: WorkIds, -): Effect.fn.Return { +): Effect.fn.Return { const kinds = new Map(); for (const [kind, objectIds] of Object.entries(ids) as Array<[keyof WorksById, readonly string[]]>) { for (const objectId of objectIds) { const normalized = normalizeSuiAddress(objectId); const previous = kinds.get(normalized); if (previous && previous !== kind) { - throw new Error(`Work ${normalized} was requested as both ${previous} and ${kind}`); + return yield* new ConflictingWorkKindError({ objectId: normalized, kinds: [previous, kind] }); } kinds.set(normalized, kind); } diff --git a/packages/platform/README.md b/packages/platform/README.md index 22eff4c..23eb27a 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -120,7 +120,7 @@ const program = Effect.gen(function* () { yield* client.miso.ready(); // The permissionless object-model SDK is part of the same facade. - const release = yield* client.miso.protocol.getReleaseById(releaseId); + const release = yield* client.miso.protocol!.getReleaseById(releaseId); // protocol is set once ready() succeeded // Party identity comes from @misofm/partyos; platform adds the extensions. // client.miso.party is a PartyPlatformClient wrapping a PartyosClient (core // reads/builders delegate straight through) bound to this deployment's @@ -161,7 +161,7 @@ recover with `Effect.catchTag`/`Effect.catchTags` instead of inspecting a thrown message: ```ts -const releaseOrNull = client.miso.protocol +const releaseOrNull = client.miso.protocol! .getReleaseById(releaseId) .pipe(Effect.catchTag("ObjectNotFoundError", () => Effect.succeed(null))); ``` @@ -759,7 +759,7 @@ Platform-specific tags: ```ts import { Effect } from "effect"; -const release = client.miso.protocol +const release = client.miso.protocol! .getReleaseById(releaseId) .pipe( Effect.catchTags({ diff --git a/packages/platform/src/share.ts b/packages/platform/src/share.ts index fb137b3..7f5558e 100644 --- a/packages/platform/src/share.ts +++ b/packages/platform/src/share.ts @@ -33,7 +33,7 @@ import { allCreatedByType, type ExecResult, } from "@misofm/musicos"; -import { SuiClient, type SuiRpcError, type TransactionFailedError } from "@misofm/effect"; +import { SuiClient, SuiRpcError, type TransactionFailedError } from "@misofm/effect"; import { publishShareCurrency, initializeShareCurrency, type PackageBytecode } from "./transactions.ts"; import { SHARE_TEMPLATE } from "./share-template.ts"; @@ -241,8 +241,16 @@ export const initializeShareCurrencies = Effect.fn("initializeShareCurrencies")( }), ).pipe( Effect.map((res) => ({ batchCurrencies: currenciesFromResult(res, batchPkgs), gasUsed: res.gasUsed })), + // A rejecting callback is this batch's failure, not a defect: a defect + // would fail the whole forEach and interrupt sibling batches whose + // transactions are already submitted. Effect.tap(({ batchCurrencies, gasUsed }) => - onBatch ? Effect.promise(() => Promise.resolve(onBatch(batchCurrencies, gasUsed))) : Effect.void, + onBatch + ? Effect.tryPromise({ + try: () => Promise.resolve(onBatch(batchCurrencies, gasUsed)), + catch: (cause) => new SuiRpcError({ operation: "onBatch", cause }), + }) + : Effect.void, ), Effect.result, ), From 8fa84e03d5b96ce25631890855fbe054697cc27a Mon Sep 17 00:00:00 2001 From: BL Date: Tue, 8 Sep 2026 18:10:09 +0000 Subject: [PATCH 5/5] test(transcoding): the isolated consumer expects the ./errors subpath Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QGmuA3eMr4iaRTdz3J8JZJ --- packages/transcoding/test/isolated-consumer.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/transcoding/test/isolated-consumer.mjs b/packages/transcoding/test/isolated-consumer.mjs index 390e677..203e763 100644 --- a/packages/transcoding/test/isolated-consumer.mjs +++ b/packages/transcoding/test/isolated-consumer.mjs @@ -62,7 +62,7 @@ if (runtime === "node") { await run("node", [ "--input-type=module", "-e", - 'await import("@misofm/transcoding"); await import("@misofm/transcoding/node")', + 'await import("@misofm/transcoding"); await import("@misofm/transcoding/node"); await import("@misofm/transcoding/errors")', ]); } else { await run("bun", [ @@ -75,13 +75,13 @@ if (runtime === "node") { ]); await run("bun", [ "-e", - 'await import("@misofm/transcoding"); await import("@misofm/transcoding/node")', + 'await import("@misofm/transcoding"); await import("@misofm/transcoding/node"); await import("@misofm/transcoding/errors")', ]); } const installed = join(directory, "node_modules", "@misofm", "transcoding"); const manifest = JSON.parse(await readFile(join(installed, "package.json"))); -const expectedExports = [".", "./node", "./package.json"]; +const expectedExports = [".", "./errors", "./node", "./package.json"]; if ( JSON.stringify(Object.keys(manifest.exports).sort()) !== JSON.stringify(expectedExports)